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:
parent
e5a494c435
commit
33f7128b40
7 changed files with 519 additions and 235 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
190
lib/screens/login_screen.dart
Normal file
190
lib/screens/login_screen.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// -------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue