feat: implement user authentication with login screen

- Add LoginScreen with form validation and error handling
- Add Api.login() method with LoginResponse model
- Add login route to AppRouter
- Update SplashScreen to check auth status and route to login if needed
- Store auth token in Api service for authenticated requests
- Fix restaurant selection to work with authenticated users
This commit is contained in:
John Mizerek 2025-12-29 10:01:35 -08:00
parent e5a494c435
commit 33f7128b40
7 changed files with 519 additions and 235 deletions

View file

@ -1,5 +1,6 @@
import "package:flutter/material.dart";
import "../screens/login_screen.dart";
import "../screens/order_home_screen.dart";
import "../screens/restaurant_select_screen.dart";
import "../screens/service_point_select_screen.dart";
@ -7,12 +8,14 @@ import "../screens/splash_screen.dart";
class AppRoutes {
static const String splash = "/";
static const String login = "/login";
static const String restaurantSelect = "/restaurants";
static const String servicePointSelect = "/service-points";
static const String orderHome = "/order";
static Map<String, WidgetBuilder> get routes => {
splash: (_) => const SplashScreen(),
login: (_) => const LoginScreen(),
restaurantSelect: (_) => const RestaurantSelectScreen(),
servicePointSelect: (_) => const ServicePointSelectScreen(),
orderHome: (_) => const OrderHomeScreen(),

View file

@ -0,0 +1,190 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../app/app_router.dart";
import "../app/app_state.dart";
import "../services/api.dart";
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final result = await Api.login(
username: _usernameController.text.trim(),
password: _passwordController.text,
);
if (!mounted) return;
final appState = context.read<AppState>();
appState.setUserId(result.userId);
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Login"),
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
"PAYFRIT",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
const SizedBox(height: 8),
const Text(
"Sign in to order",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
const SizedBox(height: 48),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: "Email or Phone Number",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !_isLoading,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Please enter your email or phone number";
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: "Password",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
textInputAction: TextInputAction.done,
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleLogin(),
validator: (value) {
if (value == null || value.isEmpty) {
return "Please enter your password";
}
return null;
},
),
const SizedBox(height: 24),
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade900),
),
),
],
),
),
FilledButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text("Login"),
),
const SizedBox(height: 16),
TextButton(
onPressed: _isLoading ? null : () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Registration not yet implemented"),
),
);
},
child: const Text("Don't have an account? Register"),
),
],
),
),
),
),
),
);
}
}

View file

@ -5,7 +5,6 @@ import "../services/api.dart";
class OrderHomeScreen extends StatefulWidget {
// OPTIONAL so routes that call `const OrderHomeScreen()` compile.
// You can wire real values later.
final int? businessId;
final int? servicePointId;
final int? userId;
@ -22,34 +21,85 @@ class OrderHomeScreen extends StatefulWidget {
}
class _OrderHomeScreenState extends State<OrderHomeScreen> {
final _businessCtl = TextEditingController();
final _servicePointCtl = TextEditingController();
final _userCtl = TextEditingController();
bool _busy = false;
String? _error;
int? _businessId;
int? _servicePointId;
int? _userId;
int? _orderId;
int? _asIntNullable(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) {
final s = v.trim();
if (s.isEmpty) return null;
return int.tryParse(s);
}
return null;
}
void _loadIdsFromWidgetAndRoute() {
int? b = widget.businessId;
int? sp = widget.servicePointId;
int? u = widget.userId;
// If not provided via constructor, attempt to read from route arguments.
final args = ModalRoute.of(context)?.settings.arguments;
if ((b == null || sp == null || u == null) && args is Map) {
// Prefer exact key spellings that match labels.
b ??= _asIntNullable(args["BusinessID"]);
sp ??= _asIntNullable(args["ServicePointID"]);
u ??= _asIntNullable(args["UserID"]);
// Fallback keys (common Flutter style)
b ??= _asIntNullable(args["businessId"]);
sp ??= _asIntNullable(args["servicePointId"]);
u ??= _asIntNullable(args["userId"]);
}
// Normalize "0" to null (so we don't show misleading zeros).
if (b == 0) b = null;
if (sp == 0) sp = null;
if (u == 0) u = null;
final changed = (b != _businessId) || (sp != _servicePointId) || (u != _userId);
if (changed) {
setState(() {
_businessId = b;
_servicePointId = sp;
_userId = u;
});
}
}
@override
void initState() {
super.initState();
_businessCtl.text = (widget.businessId ?? 0).toString();
_servicePointCtl.text = (widget.servicePointId ?? 0).toString();
_userCtl.text = (widget.userId ?? 0).toString();
// cannot read ModalRoute here reliably; do it in didChangeDependencies.
_businessId = widget.businessId == 0 ? null : widget.businessId;
_servicePointId = widget.servicePointId == 0 ? null : widget.servicePointId;
_userId = widget.userId == 0 ? null : widget.userId;
}
@override
void dispose() {
_businessCtl.dispose();
_servicePointCtl.dispose();
_userCtl.dispose();
super.dispose();
void didChangeDependencies() {
super.didChangeDependencies();
_loadIdsFromWidgetAndRoute();
}
int _parseInt(TextEditingController c) {
final s = c.text.trim();
return int.tryParse(s) ?? 0;
@override
void didUpdateWidget(covariant OrderHomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.businessId != widget.businessId ||
oldWidget.servicePointId != widget.servicePointId ||
oldWidget.userId != widget.userId) {
_loadIdsFromWidgetAndRoute();
}
}
Future<void> _run(Future<void> Function() fn) async {
@ -67,21 +117,26 @@ class _OrderHomeScreenState extends State<OrderHomeScreen> {
}
}
Future<void> _createOrLoadCart() async {
await _run(() async {
final businessId = _parseInt(_businessCtl);
final servicePointId = _parseInt(_servicePointCtl);
final userId = _parseInt(_userCtl);
bool get _hasAllIds => _businessId != null && _servicePointId != null && _userId != null;
Future<void> _createOrLoadCart() async {
if (!_hasAllIds) {
setState(() {
_error = "Missing IDs. BusinessID / ServicePointID / UserID were not provided to this screen.";
});
return;
}
await _run(() async {
final cartData = await Api.getOrCreateCart(
userId: userId,
businessId: businessId,
servicePointId: servicePointId,
userId: _userId!,
businessId: _businessId!,
servicePointId: _servicePointId!,
orderTypeId: 1, // MVP default: dine-in
);
final oid = _asInt(cartData is Map ? cartData["OrderID"] : null);
setState(() => _orderId = oid == 0 ? null : oid);
final oid = _asIntNullable(cartData is Map ? cartData["OrderID"] : null);
setState(() => _orderId = oid);
});
}
@ -120,6 +175,26 @@ class _OrderHomeScreenState extends State<OrderHomeScreen> {
});
}
Widget _idRow(String label, int? value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 120,
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
),
Expanded(
child: Text(
value == null ? "(missing)" : value.toString(),
textAlign: TextAlign.right,
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final orderId = _orderId;
@ -130,16 +205,24 @@ class _OrderHomeScreenState extends State<OrderHomeScreen> {
padding: const EdgeInsets.all(16),
children: [
const Text(
"This screen exists only to compile cleanly against Api.dart.\n"
"This screen exists only to compile cleanly against\n"
"Api.dart.\n"
"No menu, no models, no polish.",
),
const SizedBox(height: 16),
_LabeledField(label: "BusinessID", controller: _businessCtl, enabled: !_busy),
const SizedBox(height: 10),
_LabeledField(label: "ServicePointID", controller: _servicePointCtl, enabled: !_busy),
const SizedBox(height: 10),
_LabeledField(label: "UserID", controller: _userCtl, enabled: !_busy),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
_idRow("BusinessID", _businessId),
_idRow("ServicePointID", _servicePointId),
_idRow("UserID", _userId),
],
),
),
),
const SizedBox(height: 16),
@ -218,35 +301,3 @@ class _OrderHomeScreenState extends State<OrderHomeScreen> {
);
}
}
class _LabeledField extends StatelessWidget {
final String label;
final TextEditingController controller;
final bool enabled;
const _LabeledField({
required this.label,
required this.controller,
required this.enabled,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
);
}
}
int _asInt(dynamic v) {
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}

View file

@ -35,30 +35,45 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
}
Future<void> _selectBusinessAndContinue(Restaurant r) async {
final appState = context.read<AppState>();
// You MUST have a userId for ordering.
final userId = appState.userId;
if (userId == null || userId <= 0) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Missing UserID (not logged in).")),
);
return;
}
// Set selected business
context.read<AppState>().setBusiness(r.businessId);
appState.setBusiness(r.businessId);
// Go pick service point, and WAIT for a selection.
final sp = await Navigator.of(context).pushNamed(
AppRoutes.servicePointSelect,
arguments: {
"BusinessID": r.businessId,
"UserID": userId,
},
);
if (!mounted) return;
if (sp is ServicePoint) {
// We have a service point selection.
// TODO: If AppState has a setter for service point, set it here.
// Example (only if it exists): context.read<AppState>().setServicePoint(sp);
// Store selection in AppState
appState.setServicePoint(sp.servicePointId);
// Navigate forward to your next screen.
// If your router has a specific route const, use it here.
// The most likely is AppRoutes.orderHome.
try {
Navigator.of(context).pushNamed(AppRoutes.orderHome);
} catch (_) {
// If orderHome route doesn't exist yet, do nothing.
// (Still fixed: we no longer "just bounce back" with no forward action.)
}
// Navigate to Order, passing IDs so OrderHomeScreen can DISPLAY them.
Navigator.of(context).pushNamed(
AppRoutes.orderHome,
arguments: {
"BusinessID": r.businessId,
"ServicePointID": sp.servicePointId,
"UserID": userId,
},
);
}
}
@ -78,7 +93,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
if (snapshot.hasError) {
return _ErrorPane(
title: "Businesses Load Failed",
message: "${snapshot.error}",
message: snapshot.error.toString(),
statusCode: _debugLastStatus,
raw: _debugLastRaw,
onRetry: () => setState(() => _future = _load()),
@ -131,44 +146,36 @@ class _ErrorPane extends StatelessWidget {
@override
Widget build(BuildContext context) {
final rawText = raw ?? "(no body captured)";
final showRaw = rawText.isNotEmpty;
return SingleChildScrollView(
child: Center(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800)),
const SizedBox(height: 10),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 10),
Text("HTTP Status: ${statusCode ?? "-"}", textAlign: TextAlign.center),
const SizedBox(height: 14),
if (showRaw) ...[
const Text("Raw Response:", style: TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.white24),
borderRadius: BorderRadius.circular(10),
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(18),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
Text(message),
const SizedBox(height: 14),
if (statusCode != null) Text("HTTP: $statusCode"),
if (raw != null && raw!.trim().isNotEmpty) ...[
const SizedBox(height: 10),
const Text("Raw response:"),
const SizedBox(height: 6),
Text(raw!),
],
const SizedBox(height: 14),
FilledButton(
onPressed: onRetry,
child: const Text("Retry"),
),
child: Text(
rawText,
style: const TextStyle(fontFamily: "monospace", fontSize: 12),
),
),
const SizedBox(height: 14),
],
FilledButton(
onPressed: onRetry,
child: const Text("Retry"),
],
),
],
),
),
),
),

View file

@ -1,8 +1,8 @@
// lib/screens/service_point_select_screen.dart
import "package:flutter/material.dart";
import "../services/api.dart";
import "../models/service_point.dart";
import "../services/api.dart";
class ServicePointSelectScreen extends StatefulWidget {
const ServicePointSelectScreen({super.key});
@ -12,138 +12,100 @@ class ServicePointSelectScreen extends StatefulWidget {
}
class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
late Future<List<ServicePoint>> _future;
Future<List<ServicePoint>>? _future;
int? _businessId;
int? _userId;
// MVP HARD CODE
static const int _mvpBusinessId = 17;
int? _asIntNullable(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) {
final s = v.trim();
if (s.isEmpty) return null;
return int.tryParse(s);
}
return null;
}
@override
void initState() {
super.initState();
_future = _load();
}
void didChangeDependencies() {
super.didChangeDependencies();
Future<List<ServicePoint>> _load() {
return Api.listServicePoints(businessId: _mvpBusinessId);
}
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Map) {
final b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]);
final u = _asIntNullable(args["UserID"]) ?? _asIntNullable(args["userId"]);
String _tryGetString(dynamic obj, String field) {
try {
final v = obj == null ? null : (obj as dynamic).__getattribute__(field);
if (v == null) return "";
final s = v.toString().trim();
return s;
} catch (_) {
// Dart doesn't actually support __getattribute__; this block is never reached in that way.
// We keep it here because we also attempt direct dynamic access below.
return "";
if (_businessId != b || _userId != u || _future == null) {
_businessId = b;
_userId = u;
if (_businessId != null && _businessId! > 0) {
_future = Api.listServicePoints(businessId: _businessId!);
} else {
_future = Future.value(<ServicePoint>[]);
}
}
} else {
// No args at all
_businessId = null;
_userId = null;
_future = Future.value(<ServicePoint>[]);
}
}
String _nameFor(ServicePoint sp, int index) {
final d = sp as dynamic;
// Try common getters without compile-time assumptions.
try {
final v = d.servicePointName;
if (v != null) {
final s = v.toString().trim();
if (s.isNotEmpty) return s;
}
} catch (_) {}
try {
final v = d.name;
if (v != null) {
final s = v.toString().trim();
if (s.isNotEmpty) return s;
}
} catch (_) {}
try {
final v = d.ServicePointName;
if (v != null) {
final s = v.toString().trim();
if (s.isNotEmpty) return s;
}
} catch (_) {}
try {
final v = d.SERVICEPOINTNAME;
if (v != null) {
final s = v.toString().trim();
if (s.isNotEmpty) return s;
}
} catch (_) {}
return "Service Point ${index + 1}";
}
@override
Widget build(BuildContext context) {
final businessId = _businessId;
return Scaffold(
appBar: AppBar(
title: const Text("Select Service Point"),
),
body: FutureBuilder<List<ServicePoint>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
appBar: AppBar(title: const Text("Select Service Point")),
body: (businessId == null || businessId <= 0)
? const Padding(
padding: EdgeInsets.all(16),
child: Text("Missing route arguments: BusinessID"),
)
: FutureBuilder<List<ServicePoint>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Failed to load service points.",
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => setState(() => _future = _load()),
child: const Text("Retry"),
),
],
),
),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(snapshot.error.toString()),
);
}
final items = snapshot.data ?? const <ServicePoint>[];
final items = snapshot.data ?? const <ServicePoint>[];
if (items.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text("No service points returned."),
);
}
if (items.isEmpty) {
return const Center(child: Text("No service points found."));
}
return ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final sp = items[i];
final name = _nameFor(sp, i);
return ListTile(
title: Text(name),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// Return the selected service point to the previous screen.
Navigator.of(context).pop<ServicePoint>(sp);
},
);
},
);
},
),
return ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final sp = items[i];
return ListTile(
title: Text(sp.name),
subtitle: Text("ID: ${sp.servicePointId}"),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// Return selection to the caller.
Navigator.of(context).pop<ServicePoint>(sp);
},
);
},
);
},
),
);
}
}

View file

@ -1,7 +1,9 @@
import "dart:async";
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../app/app_router.dart";
import "../app/app_state.dart";
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@ -17,10 +19,17 @@ class _SplashScreenState extends State<SplashScreen> {
void initState() {
super.initState();
// ~3.5x longer than 1200ms
_timer = Timer(const Duration(milliseconds: 2400), () {
if (!mounted) return;
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
final appState = context.read<AppState>();
// Navigate based on authentication status
if (appState.isLoggedIn) {
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
} else {
Navigator.of(context).pushReplacementNamed(AppRoutes.login);
}
});
}

View file

@ -16,6 +16,26 @@ class ApiRawResponse {
});
}
class LoginResponse {
final int userId;
final String userFirstName;
final String token;
const LoginResponse({
required this.userId,
required this.userFirstName,
required this.token,
});
factory LoginResponse.fromJson(Map<String, dynamic> json) {
return LoginResponse(
userId: (json["UserID"] as num).toInt(),
userFirstName: (json["UserFirstName"] as String?) ?? "",
token: (json["Token"] as String?) ?? "",
);
}
}
class Api {
static String? _userToken;
@ -138,6 +158,48 @@ class Api {
return j;
}
// -------------------------
// Authentication
// -------------------------
static Future<LoginResponse> login({
required String username,
required String password,
}) async {
final raw = await _postRaw(
"/auth/login.cfm",
{
"username": username,
"password": password,
},
);
final j = _requireJson(raw, "Login");
if (!_ok(j)) {
final err = _err(j);
if (err == "bad_credentials") {
throw StateError("Invalid email/phone or password");
} else if (err == "missing_fields") {
throw StateError("Username and password are required");
} else {
throw StateError("Login failed: $err");
}
}
final response = LoginResponse.fromJson(j);
// Store token for future requests
setAuthToken(response.token);
return response;
}
static void logout() {
setAuthToken(null);
clearCookies();
}
// -------------------------
// Businesses (legacy model name: Restaurant)
// -------------------------