From 33f7128b40a600a52f08020a834fdde3603af64f Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Mon, 29 Dec 2025 10:01:35 -0800 Subject: [PATCH] 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 --- lib/app/app_router.dart | 3 + lib/screens/login_screen.dart | 190 ++++++++++++++++++ lib/screens/order_home_screen.dart | 179 +++++++++++------ lib/screens/restaurant_select_screen.dart | 107 +++++----- lib/screens/service_point_select_screen.dart | 200 ++++++++----------- lib/screens/splash_screen.dart | 13 +- lib/services/api.dart | 62 ++++++ 7 files changed, 519 insertions(+), 235 deletions(-) create mode 100644 lib/screens/login_screen.dart diff --git a/lib/app/app_router.dart b/lib/app/app_router.dart index e065966..f1ff21e 100644 --- a/lib/app/app_router.dart +++ b/lib/app/app_router.dart @@ -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 get routes => { splash: (_) => const SplashScreen(), + login: (_) => const LoginScreen(), restaurantSelect: (_) => const RestaurantSelectScreen(), servicePointSelect: (_) => const ServicePointSelectScreen(), orderHome: (_) => const OrderHomeScreen(), diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..f737637 --- /dev/null +++ b/lib/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _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.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(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"), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/order_home_screen.dart b/lib/screens/order_home_screen.dart index 0c28a43..13a506f 100644 --- a/lib/screens/order_home_screen.dart +++ b/lib/screens/order_home_screen.dart @@ -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 { - 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 _run(Future Function() fn) async { @@ -67,21 +117,26 @@ class _OrderHomeScreenState extends State { } } - Future _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 _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 { }); } + 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 { 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 { ); } } - -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; -} diff --git a/lib/screens/restaurant_select_screen.dart b/lib/screens/restaurant_select_screen.dart index dc1c050..b9c4ae5 100644 --- a/lib/screens/restaurant_select_screen.dart +++ b/lib/screens/restaurant_select_screen.dart @@ -35,30 +35,45 @@ class _RestaurantSelectScreenState extends State { } Future _selectBusinessAndContinue(Restaurant r) async { + final appState = context.read(); + + // 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().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().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 { 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"), + ], ), - ], + ), ), ), ), diff --git a/lib/screens/service_point_select_screen.dart b/lib/screens/service_point_select_screen.dart index f30b579..3c2f7bf 100644 --- a/lib/screens/service_point_select_screen.dart +++ b/lib/screens/service_point_select_screen.dart @@ -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 { - late Future> _future; + Future>? _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> _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([]); + } + } + } else { + // No args at all + _businessId = null; + _userId = null; + _future = Future.value([]); } } - 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>( - 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>( + 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 []; + final items = snapshot.data ?? const []; + 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(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(sp); + }, + ); + }, + ); + }, + ), ); } } diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 181f0d9..d7b4322 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -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 { 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(); + + // Navigate based on authentication status + if (appState.isLoggedIn) { + Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); + } else { + Navigator.of(context).pushReplacementNamed(AppRoutes.login); + } }); } diff --git a/lib/services/api.dart b/lib/services/api.dart index 7215d9b..17cad03 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -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 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 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) // -------------------------