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 "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(),
|
||||||
|
|
|
||||||
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 {
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,39 +146,29 @@ 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: Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800)),
|
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(message, textAlign: TextAlign.center),
|
Text(message),
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text("HTTP Status: ${statusCode ?? "-"}", textAlign: TextAlign.center),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
if (showRaw) ...[
|
if (statusCode != null) Text("HTTP: $statusCode"),
|
||||||
const Text("Raw Response:", style: TextStyle(fontWeight: FontWeight.w700)),
|
if (raw != null && raw!.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text("Raw response:"),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Container(
|
Text(raw!),
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Colors.white24),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
rawText,
|
|
||||||
style: const TextStyle(fontFamily: "monospace", fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 14),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: onRetry,
|
onPressed: onRetry,
|
||||||
child: const Text("Retry"),
|
child: const Text("Retry"),
|
||||||
|
|
@ -172,6 +177,8 @@ class _ErrorPane extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,80 +12,61 @@ 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 {
|
||||||
String _nameFor(ServicePoint sp, int index) {
|
// No args at all
|
||||||
final d = sp as dynamic;
|
_businessId = null;
|
||||||
|
_userId = null;
|
||||||
// Try common getters without compile-time assumptions.
|
_future = Future.value(<ServicePoint>[]);
|
||||||
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),
|
||||||
|
child: Text("Missing route arguments: BusinessID"),
|
||||||
|
)
|
||||||
|
: FutureBuilder<List<ServicePoint>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
|
@ -93,36 +74,18 @@ class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
return Center(
|
return Padding(
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Text(snapshot.error.toString()),
|
||||||
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) {
|
if (items.isEmpty) {
|
||||||
return const Center(child: Text("No service points found."));
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text("No service points returned."),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
|
|
@ -130,13 +93,12 @@ class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final sp = items[i];
|
final sp = items[i];
|
||||||
final name = _nameFor(sp, i);
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(name),
|
title: Text(sp.name),
|
||||||
|
subtitle: Text("ID: ${sp.servicePointId}"),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Return the selected service point to the previous screen.
|
// Return selection to the caller.
|
||||||
Navigator.of(context).pop<ServicePoint>(sp);
|
Navigator.of(context).pop<ServicePoint>(sp);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
final appState = context.read<AppState>();
|
||||||
|
|
||||||
|
// Navigate based on authentication status
|
||||||
|
if (appState.isLoggedIn) {
|
||||||
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
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 {
|
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)
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue