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 "package:flutter/material.dart";
import "../screens/login_screen.dart";
import "../screens/order_home_screen.dart"; import "../screens/order_home_screen.dart";
import "../screens/restaurant_select_screen.dart"; import "../screens/restaurant_select_screen.dart";
import "../screens/service_point_select_screen.dart"; import "../screens/service_point_select_screen.dart";
@ -7,12 +8,14 @@ import "../screens/splash_screen.dart";
class AppRoutes { class AppRoutes {
static const String splash = "/"; static const String splash = "/";
static const String login = "/login";
static const String restaurantSelect = "/restaurants"; static const String restaurantSelect = "/restaurants";
static const String servicePointSelect = "/service-points"; static const String servicePointSelect = "/service-points";
static const String orderHome = "/order"; static const String orderHome = "/order";
static Map<String, WidgetBuilder> get routes => { static Map<String, WidgetBuilder> get routes => {
splash: (_) => const SplashScreen(), splash: (_) => const SplashScreen(),
login: (_) => const LoginScreen(),
restaurantSelect: (_) => const RestaurantSelectScreen(), restaurantSelect: (_) => const RestaurantSelectScreen(),
servicePointSelect: (_) => const ServicePointSelectScreen(), servicePointSelect: (_) => const ServicePointSelectScreen(),
orderHome: (_) => const OrderHomeScreen(), 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 { class OrderHomeScreen extends StatefulWidget {
// OPTIONAL so routes that call `const OrderHomeScreen()` compile. // OPTIONAL so routes that call `const OrderHomeScreen()` compile.
// You can wire real values later.
final int? businessId; final int? businessId;
final int? servicePointId; final int? servicePointId;
final int? userId; final int? userId;
@ -22,34 +21,85 @@ class OrderHomeScreen extends StatefulWidget {
} }
class _OrderHomeScreenState extends State<OrderHomeScreen> { class _OrderHomeScreenState extends State<OrderHomeScreen> {
final _businessCtl = TextEditingController();
final _servicePointCtl = TextEditingController();
final _userCtl = TextEditingController();
bool _busy = false; bool _busy = false;
String? _error; String? _error;
int? _businessId;
int? _servicePointId;
int? _userId;
int? _orderId; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
_businessCtl.text = (widget.businessId ?? 0).toString(); // cannot read ModalRoute here reliably; do it in didChangeDependencies.
_servicePointCtl.text = (widget.servicePointId ?? 0).toString(); _businessId = widget.businessId == 0 ? null : widget.businessId;
_userCtl.text = (widget.userId ?? 0).toString(); _servicePointId = widget.servicePointId == 0 ? null : widget.servicePointId;
_userId = widget.userId == 0 ? null : widget.userId;
} }
@override @override
void dispose() { void didChangeDependencies() {
_businessCtl.dispose(); super.didChangeDependencies();
_servicePointCtl.dispose(); _loadIdsFromWidgetAndRoute();
_userCtl.dispose();
super.dispose();
} }
int _parseInt(TextEditingController c) { @override
final s = c.text.trim(); void didUpdateWidget(covariant OrderHomeScreen oldWidget) {
return int.tryParse(s) ?? 0; 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 { Future<void> _run(Future<void> Function() fn) async {
@ -67,21 +117,26 @@ class _OrderHomeScreenState extends State<OrderHomeScreen> {
} }
} }
Future<void> _createOrLoadCart() async { bool get _hasAllIds => _businessId != null && _servicePointId != null && _userId != null;
await _run(() async {
final businessId = _parseInt(_businessCtl);
final servicePointId = _parseInt(_servicePointCtl);
final userId = _parseInt(_userCtl);
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( final cartData = await Api.getOrCreateCart(
userId: userId, userId: _userId!,
businessId: businessId, businessId: _businessId!,
servicePointId: servicePointId, servicePointId: _servicePointId!,
orderTypeId: 1, // MVP default: dine-in orderTypeId: 1, // MVP default: dine-in
); );
final oid = _asInt(cartData is Map ? cartData["OrderID"] : null); final oid = _asIntNullable(cartData is Map ? cartData["OrderID"] : null);
setState(() => _orderId = oid == 0 ? null : oid); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final orderId = _orderId; final orderId = _orderId;
@ -130,16 +205,24 @@ class _OrderHomeScreenState extends State<OrderHomeScreen> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
const Text( 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.", "No menu, no models, no polish.",
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_LabeledField(label: "BusinessID", controller: _businessCtl, enabled: !_busy), Card(
const SizedBox(height: 10), child: Padding(
_LabeledField(label: "ServicePointID", controller: _servicePointCtl, enabled: !_busy), padding: const EdgeInsets.all(12),
const SizedBox(height: 10), child: Column(
_LabeledField(label: "UserID", controller: _userCtl, enabled: !_busy), children: [
_idRow("BusinessID", _businessId),
_idRow("ServicePointID", _servicePointId),
_idRow("UserID", _userId),
],
),
),
),
const SizedBox(height: 16), 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 { 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 // Set selected business
context.read<AppState>().setBusiness(r.businessId); appState.setBusiness(r.businessId);
// Go pick service point, and WAIT for a selection. // Go pick service point, and WAIT for a selection.
final sp = await Navigator.of(context).pushNamed( final sp = await Navigator.of(context).pushNamed(
AppRoutes.servicePointSelect, AppRoutes.servicePointSelect,
arguments: {
"BusinessID": r.businessId,
"UserID": userId,
},
); );
if (!mounted) return; if (!mounted) return;
if (sp is ServicePoint) { if (sp is ServicePoint) {
// We have a service point selection. // Store selection in AppState
// TODO: If AppState has a setter for service point, set it here. appState.setServicePoint(sp.servicePointId);
// Example (only if it exists): context.read<AppState>().setServicePoint(sp);
// Navigate forward to your next screen. // Navigate to Order, passing IDs so OrderHomeScreen can DISPLAY them.
// If your router has a specific route const, use it here. Navigator.of(context).pushNamed(
// The most likely is AppRoutes.orderHome. AppRoutes.orderHome,
try { arguments: {
Navigator.of(context).pushNamed(AppRoutes.orderHome); "BusinessID": r.businessId,
} catch (_) { "ServicePointID": sp.servicePointId,
// If orderHome route doesn't exist yet, do nothing. "UserID": userId,
// (Still fixed: we no longer "just bounce back" with no forward action.) },
} );
} }
} }
@ -78,7 +93,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
if (snapshot.hasError) { if (snapshot.hasError) {
return _ErrorPane( return _ErrorPane(
title: "Businesses Load Failed", title: "Businesses Load Failed",
message: "${snapshot.error}", message: snapshot.error.toString(),
statusCode: _debugLastStatus, statusCode: _debugLastStatus,
raw: _debugLastRaw, raw: _debugLastRaw,
onRetry: () => setState(() => _future = _load()), onRetry: () => setState(() => _future = _load()),
@ -131,44 +146,36 @@ class _ErrorPane extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final rawText = raw ?? "(no body captured)"; return Center(
final showRaw = rawText.isNotEmpty; child: SingleChildScrollView(
padding: const EdgeInsets.all(18),
return SingleChildScrollView( child: ConstrainedBox(
child: Center( constraints: const BoxConstraints(maxWidth: 720),
child: Padding( child: Card(
padding: const EdgeInsets.all(18), child: Padding(
child: Column( padding: const EdgeInsets.all(18),
mainAxisSize: MainAxisSize.min, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800)), children: [
const SizedBox(height: 10), Text(title, style: Theme.of(context).textTheme.titleLarge),
Text(message, textAlign: TextAlign.center), const SizedBox(height: 10),
const SizedBox(height: 10), Text(message),
Text("HTTP Status: ${statusCode ?? "-"}", textAlign: TextAlign.center), const SizedBox(height: 14),
const SizedBox(height: 14), if (statusCode != null) Text("HTTP: $statusCode"),
if (showRaw) ...[ if (raw != null && raw!.trim().isNotEmpty) ...[
const Text("Raw Response:", style: TextStyle(fontWeight: FontWeight.w700)), const SizedBox(height: 10),
const SizedBox(height: 6), const Text("Raw response:"),
Container( const SizedBox(height: 6),
width: double.infinity, Text(raw!),
padding: const EdgeInsets.all(12), ],
decoration: BoxDecoration( const SizedBox(height: 14),
border: Border.all(color: Colors.white24), FilledButton(
borderRadius: BorderRadius.circular(10), 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 // lib/screens/service_point_select_screen.dart
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "../services/api.dart";
import "../models/service_point.dart"; import "../models/service_point.dart";
import "../services/api.dart";
class ServicePointSelectScreen extends StatefulWidget { class ServicePointSelectScreen extends StatefulWidget {
const ServicePointSelectScreen({super.key}); const ServicePointSelectScreen({super.key});
@ -12,138 +12,100 @@ class ServicePointSelectScreen extends StatefulWidget {
} }
class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> { class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
late Future<List<ServicePoint>> _future; Future<List<ServicePoint>>? _future;
int? _businessId;
int? _userId;
// MVP HARD CODE int? _asIntNullable(dynamic v) {
static const int _mvpBusinessId = 17; 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 @override
void initState() { void didChangeDependencies() {
super.initState(); super.didChangeDependencies();
_future = _load();
}
Future<List<ServicePoint>> _load() { final args = ModalRoute.of(context)?.settings.arguments;
return Api.listServicePoints(businessId: _mvpBusinessId); 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) { if (_businessId != b || _userId != u || _future == null) {
try { _businessId = b;
final v = obj == null ? null : (obj as dynamic).__getattribute__(field); _userId = u;
if (v == null) return "";
final s = v.toString().trim(); if (_businessId != null && _businessId! > 0) {
return s; _future = Api.listServicePoints(businessId: _businessId!);
} catch (_) { } else {
// Dart doesn't actually support __getattribute__; this block is never reached in that way. _future = Future.value(<ServicePoint>[]);
// We keep it here because we also attempt direct dynamic access below. }
return ""; }
} 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final businessId = _businessId;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text("Select Service Point")),
title: const Text("Select Service Point"), body: (businessId == null || businessId <= 0)
), ? const Padding(
body: FutureBuilder<List<ServicePoint>>( padding: EdgeInsets.all(16),
future: _future, child: Text("Missing route arguments: BusinessID"),
builder: (context, snapshot) { )
if (snapshot.connectionState == ConnectionState.waiting) { : FutureBuilder<List<ServicePoint>>(
return const Center(child: CircularProgressIndicator()); future: _future,
} builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) { if (snapshot.hasError) {
return Center( return Padding(
child: Padding( padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16), child: Text(snapshot.error.toString()),
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"),
),
],
),
),
);
}
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 ListView.separated(
return const Center(child: Text("No service points found.")); itemCount: items.length,
} separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
return ListView.separated( final sp = items[i];
itemCount: items.length, return ListTile(
separatorBuilder: (_, __) => const Divider(height: 1), title: Text(sp.name),
itemBuilder: (context, i) { subtitle: Text("ID: ${sp.servicePointId}"),
final sp = items[i]; trailing: const Icon(Icons.chevron_right),
final name = _nameFor(sp, i); onTap: () {
// Return selection to the caller.
return ListTile( Navigator.of(context).pop<ServicePoint>(sp);
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); },
}, ),
);
},
);
},
),
); );
} }
} }

View file

@ -1,7 +1,9 @@
import "dart:async"; import "dart:async";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../app/app_router.dart"; import "../app/app_router.dart";
import "../app/app_state.dart";
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -17,10 +19,17 @@ class _SplashScreenState extends State<SplashScreen> {
void initState() { void initState() {
super.initState(); super.initState();
// ~3.5x longer than 1200ms
_timer = Timer(const Duration(milliseconds: 2400), () { _timer = Timer(const Duration(milliseconds: 2400), () {
if (!mounted) return; 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 { class Api {
static String? _userToken; static String? _userToken;
@ -138,6 +158,48 @@ class Api {
return j; 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) // Businesses (legacy model name: Restaurant)
// ------------------------- // -------------------------