diff --git a/lib/app/app_router.dart b/lib/app/app_router.dart index 83067cd..f9f1de8 100644 --- a/lib/app/app_router.dart +++ b/lib/app/app_router.dart @@ -1,11 +1,16 @@ import "package:flutter/material.dart"; +import "../screens/account_screen.dart"; +import "../screens/address_edit_screen.dart"; +import "../screens/address_list_screen.dart"; import "../screens/beacon_scan_screen.dart"; import "../screens/cart_view_screen.dart"; import "../screens/group_order_invite_screen.dart"; import "../screens/login_screen.dart"; import "../screens/menu_browse_screen.dart"; +import "../screens/order_history_screen.dart"; import "../screens/order_type_select_screen.dart"; +import "../screens/profile_settings_screen.dart"; import "../screens/restaurant_select_screen.dart"; import "../screens/service_point_select_screen.dart"; import "../screens/splash_screen.dart"; @@ -20,6 +25,11 @@ class AppRoutes { static const String servicePointSelect = "/service-points"; static const String menuBrowse = "/menu"; static const String cartView = "/cart"; + static const String account = "/account"; + static const String orderHistory = "/order-history"; + static const String profileSettings = "/profile-settings"; + static const String addressList = "/addresses"; + static const String addressEdit = "/address-edit"; static Map get routes => { splash: (_) => const SplashScreen(), @@ -32,5 +42,10 @@ class AppRoutes { menuBrowse: (_) => const MenuBrowseScreen(), "/menu_browse": (_) => const MenuBrowseScreen(), // Alias for menuBrowse cartView: (_) => const CartViewScreen(), + account: (_) => const AccountScreen(), + orderHistory: (_) => const OrderHistoryScreen(), + profileSettings: (_) => const ProfileSettingsScreen(), + addressList: (_) => const AddressListScreen(), + addressEdit: (_) => const AddressEditScreen(), }; } diff --git a/lib/models/order_history.dart b/lib/models/order_history.dart new file mode 100644 index 0000000..35f73c1 --- /dev/null +++ b/lib/models/order_history.dart @@ -0,0 +1,48 @@ +class OrderHistoryItem { + final int orderId; + final String orderUuid; + final int businessId; + final String businessName; + final double total; + final int statusId; + final String statusName; + final int orderTypeId; + final String typeName; + final int itemCount; + final DateTime createdAt; + final DateTime? completedAt; + + const OrderHistoryItem({ + required this.orderId, + required this.orderUuid, + required this.businessId, + required this.businessName, + required this.total, + required this.statusId, + required this.statusName, + required this.orderTypeId, + required this.typeName, + required this.itemCount, + required this.createdAt, + this.completedAt, + }); + + factory OrderHistoryItem.fromJson(Map json) { + return OrderHistoryItem( + orderId: (json["OrderID"] as num).toInt(), + orderUuid: json["OrderUUID"] as String? ?? "", + businessId: (json["BusinessID"] as num).toInt(), + businessName: json["BusinessName"] as String? ?? "Unknown", + total: (json["OrderTotal"] as num?)?.toDouble() ?? 0.0, + statusId: (json["OrderStatusID"] as num).toInt(), + statusName: json["StatusName"] as String? ?? "Unknown", + orderTypeId: (json["OrderTypeID"] as num?)?.toInt() ?? 0, + typeName: json["TypeName"] as String? ?? "Unknown", + itemCount: (json["ItemCount"] as num?)?.toInt() ?? 0, + createdAt: DateTime.tryParse(json["CreatedAt"] as String? ?? "") ?? DateTime.now(), + completedAt: json["CompletedAt"] != null && (json["CompletedAt"] as String).isNotEmpty + ? DateTime.tryParse(json["CompletedAt"] as String) + : null, + ); + } +} diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart new file mode 100644 index 0000000..8939b50 --- /dev/null +++ b/lib/models/user_profile.dart @@ -0,0 +1,36 @@ +class UserProfile { + final int userId; + final String firstName; + final String lastName; + final String email; + final String phone; + + const UserProfile({ + required this.userId, + required this.firstName, + required this.lastName, + required this.email, + required this.phone, + }); + + factory UserProfile.fromJson(Map json) { + return UserProfile( + userId: (json["UserID"] as num).toInt(), + firstName: json["FirstName"] as String? ?? "", + lastName: json["LastName"] as String? ?? "", + email: json["Email"] as String? ?? "", + phone: json["Phone"] as String? ?? "", + ); + } + + String get displayName { + if (firstName.isNotEmpty && lastName.isNotEmpty) { + return "$firstName $lastName"; + } else if (firstName.isNotEmpty) { + return firstName; + } else if (lastName.isNotEmpty) { + return lastName; + } + return "User #$userId"; + } +} diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart new file mode 100644 index 0000000..ccb99ac --- /dev/null +++ b/lib/screens/account_screen.dart @@ -0,0 +1,436 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../app/app_state.dart'; +import '../app/app_router.dart'; +import '../services/api.dart'; +import '../services/auth_storage.dart'; + +class AccountScreen extends StatefulWidget { + const AccountScreen({super.key}); + + @override + State createState() => _AccountScreenState(); +} + +class _AccountScreenState extends State { + bool _isLoggingOut = false; + bool _isLoadingAvatar = true; + bool _isUploadingAvatar = false; + String? _avatarUrl; + + final ImagePicker _picker = ImagePicker(); + + @override + void initState() { + super.initState(); + _loadAvatar(); + } + + Future _loadAvatar() async { + // Don't try to load avatar if not logged in + final appState = context.read(); + if (!appState.isLoggedIn) { + setState(() => _isLoadingAvatar = false); + return; + } + + try { + final avatarInfo = await Api.getAvatar(); + if (mounted) { + setState(() { + _avatarUrl = avatarInfo.hasAvatar ? avatarInfo.avatarUrl : null; + _isLoadingAvatar = false; + }); + } + } catch (e) { + debugPrint('Error loading avatar: $e'); + if (mounted) { + setState(() => _isLoadingAvatar = false); + } + } + } + + Future _pickAndUploadAvatar() async { + // Check if user is logged in first + final appState = context.read(); + if (!appState.isLoggedIn) { + final shouldLogin = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Login Required'), + content: const Text('Please login to upload a profile photo.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Login'), + ), + ], + ), + ); + + if (shouldLogin == true && mounted) { + Navigator.of(context).pushNamed(AppRoutes.login); + } + return; + } + + // Show picker options + final source = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Take Photo'), + onTap: () => Navigator.pop(context, ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Choose from Gallery'), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + ], + ), + ), + ); + + if (source == null) return; + + try { + final XFile? image = await _picker.pickImage( + source: source, + maxWidth: 500, + maxHeight: 500, + imageQuality: 85, + ); + + if (image == null) return; + + setState(() => _isUploadingAvatar = true); + + final newAvatarUrl = await Api.uploadAvatar(image.path); + + if (mounted) { + setState(() { + _avatarUrl = newAvatarUrl; + _isUploadingAvatar = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Avatar updated! Servers can now recognize you.'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + debugPrint('Error uploading avatar: $e'); + if (mounted) { + setState(() => _isUploadingAvatar = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to upload avatar: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _handleSignOut() async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Sign Out'), + content: const Text('Are you sure you want to sign out?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Sign Out'), + ), + ], + ), + ); + + if (confirm != true || !mounted) return; + + setState(() => _isLoggingOut = true); + + try { + // Clear stored auth + await AuthStorage.clearAuth(); + + // Clear API token + Api.clearAuthToken(); + + // Clear app state + if (mounted) { + final appState = context.read(); + appState.clearAll(); + + // Navigate to login + Navigator.of(context).pushNamedAndRemoveUntil( + AppRoutes.login, + (route) => false, + ); + } + } catch (e) { + if (mounted) { + setState(() => _isLoggingOut = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error signing out: $e')), + ); + } + } + } + + Widget _buildAvatar() { + if (_isLoadingAvatar) { + return CircleAvatar( + radius: 40, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + if (_isUploadingAvatar) { + return Stack( + children: [ + CircleAvatar( + radius: 40, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: _avatarUrl != null ? NetworkImage(_avatarUrl!) : null, + child: _avatarUrl == null + ? Icon( + Icons.person, + size: 40, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ) + : null, + ), + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black45, + shape: BoxShape.circle, + ), + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ), + ), + ), + ], + ); + } + + return GestureDetector( + onTap: _pickAndUploadAvatar, + child: Stack( + children: [ + CircleAvatar( + radius: 40, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: _avatarUrl != null ? NetworkImage(_avatarUrl!) : null, + child: _avatarUrl == null + ? Icon( + Icons.person, + size: 40, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ) + : null, + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 2, + ), + ), + child: Icon( + Icons.camera_alt, + size: 16, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + + // If not logged in, show login prompt + if (!appState.isLoggedIn) { + return Scaffold( + appBar: AppBar( + title: const Text('Account'), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person_outline, + size: 80, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 24), + Text( + 'Sign in to access your account', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'View order history, manage your profile, and more.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () { + Navigator.of(context).pushNamed(AppRoutes.login); + }, + icon: const Icon(Icons.login), + label: const Text('Sign In'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + ), + ), + ], + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Account'), + ), + body: ListView( + children: [ + // User info header with avatar + Container( + padding: const EdgeInsets.all(24), + color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3), + child: Column( + children: [ + _buildAvatar(), + const SizedBox(height: 16), + Text( + 'User #${appState.userId ?? "?"}', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + _avatarUrl != null + ? 'Tap photo to update' + : 'Tap to add a photo for server recognition', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + + // Order History + ListTile( + leading: const Icon(Icons.receipt_long), + title: const Text('Order History'), + subtitle: const Text('View your past orders'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.pushNamed(context, AppRoutes.orderHistory), + ), + + const Divider(height: 1), + + // Delivery Addresses + ListTile( + leading: const Icon(Icons.location_on), + title: const Text('Delivery Addresses'), + subtitle: const Text('Manage your addresses'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.pushNamed(context, AppRoutes.addressList), + ), + + const Divider(height: 1), + + // Profile Settings + ListTile( + leading: const Icon(Icons.settings), + title: const Text('Profile Settings'), + subtitle: const Text('Update your name'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.pushNamed(context, AppRoutes.profileSettings), + ), + + const Divider(height: 1), + + const SizedBox(height: 24), + + // Sign Out button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: OutlinedButton.icon( + onPressed: _isLoggingOut ? null : _handleSignOut, + icon: _isLoggingOut + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.logout), + label: Text(_isLoggingOut ? 'Signing out...' : 'Sign Out'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + side: BorderSide(color: Theme.of(context).colorScheme.error), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + + const SizedBox(height: 32), + ], + ), + ); + } +} diff --git a/lib/screens/address_edit_screen.dart b/lib/screens/address_edit_screen.dart new file mode 100644 index 0000000..030b17b --- /dev/null +++ b/lib/screens/address_edit_screen.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; + +import '../services/api.dart'; + +class AddressEditScreen extends StatefulWidget { + const AddressEditScreen({super.key}); + + @override + State createState() => _AddressEditScreenState(); +} + +class _AddressEditScreenState extends State { + final _formKey = GlobalKey(); + final _labelController = TextEditingController(); + final _line1Controller = TextEditingController(); + final _line2Controller = TextEditingController(); + final _cityController = TextEditingController(); + final _zipCodeController = TextEditingController(); + + bool _isLoading = true; + bool _isSaving = false; + bool _setAsDefault = false; + int? _selectedStateId; + + List _states = []; + DeliveryAddress? _existingAddress; + bool get _isEditing => _existingAddress != null; + + @override + void initState() { + super.initState(); + _loadStates(); + } + + Future _loadStates() async { + try { + final states = await Api.getStates(); + if (mounted) { + setState(() { + _states = states; + // Default to Texas (44) if no address is being edited + if (_selectedStateId == null && _existingAddress == null) { + _selectedStateId = 44; + } + _isLoading = false; + }); + } + } catch (e) { + debugPrint('Error loading states: $e'); + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final args = ModalRoute.of(context)?.settings.arguments; + if (args is DeliveryAddress && _existingAddress == null) { + _existingAddress = args; + _labelController.text = args.label; + _line1Controller.text = args.line1; + _line2Controller.text = args.line2; + _cityController.text = args.city; + _zipCodeController.text = args.zipCode; + _selectedStateId = args.stateId > 0 ? args.stateId : 44; + _setAsDefault = args.isDefault; + } + } + + @override + void dispose() { + _labelController.dispose(); + _line1Controller.dispose(); + _line2Controller.dispose(); + _cityController.dispose(); + _zipCodeController.dispose(); + super.dispose(); + } + + Future _saveAddress() async { + if (!_formKey.currentState!.validate()) return; + if (_selectedStateId == null) return; + + setState(() => _isSaving = true); + + try { + await Api.addDeliveryAddress( + line1: _line1Controller.text.trim(), + line2: _line2Controller.text.trim().isEmpty ? null : _line2Controller.text.trim(), + city: _cityController.text.trim(), + stateId: _selectedStateId!, + zipCode: _zipCodeController.text.trim(), + label: _labelController.text.trim().isEmpty ? null : _labelController.text.trim(), + setAsDefault: _setAsDefault, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_isEditing ? 'Address updated' : 'Address added'), + backgroundColor: Colors.green, + ), + ); + Navigator.pop(context, true); + } + } catch (e) { + debugPrint('Error saving address: $e'); + if (mounted) { + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_isEditing ? 'Edit Address' : 'Add Address'), + actions: [ + TextButton( + onPressed: _isSaving || _isLoading ? null : _saveAddress, + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _labelController, + decoration: const InputDecoration( + labelText: 'Label (optional)', + hintText: 'e.g., Home, Work, Office', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.label_outline), + ), + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 16), + TextFormField( + controller: _line1Controller, + decoration: const InputDecoration( + labelText: 'Street Address', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.location_on_outlined), + ), + textCapitalization: TextCapitalization.words, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a street address'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _line2Controller, + decoration: const InputDecoration( + labelText: 'Apt, Suite, Unit (optional)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.apartment), + ), + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 16), + TextFormField( + controller: _cityController, + decoration: const InputDecoration( + labelText: 'City', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.location_city), + ), + textCapitalization: TextCapitalization.words, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a city'; + } + return null; + }, + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: DropdownButtonFormField( + value: _selectedStateId, + decoration: const InputDecoration( + labelText: 'State', + border: OutlineInputBorder(), + ), + items: _states.map((state) { + return DropdownMenuItem( + value: state.stateId, + child: Text(state.abbr), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() => _selectedStateId = value); + } + }, + validator: (value) { + if (value == null || value <= 0) { + return 'Select a state'; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: TextFormField( + controller: _zipCodeController, + decoration: const InputDecoration( + labelText: 'ZIP Code', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Enter ZIP'; + } + if (value.trim().length < 5) { + return 'Invalid ZIP'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 24), + SwitchListTile( + title: const Text('Set as default address'), + subtitle: const Text('Use this address for deliveries by default'), + value: _setAsDefault, + onChanged: (value) => setState(() => _setAsDefault = value), + contentPadding: EdgeInsets.zero, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/address_list_screen.dart b/lib/screens/address_list_screen.dart new file mode 100644 index 0000000..667a869 --- /dev/null +++ b/lib/screens/address_list_screen.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; + +import '../services/api.dart'; + +class AddressListScreen extends StatefulWidget { + const AddressListScreen({super.key}); + + @override + State createState() => _AddressListScreenState(); +} + +class _AddressListScreenState extends State { + List _addresses = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadAddresses(); + } + + Future _loadAddresses() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final addresses = await Api.getDeliveryAddresses(); + if (mounted) { + setState(() { + _addresses = addresses; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('Error loading addresses: $e'); + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Future _deleteAddress(DeliveryAddress address) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Address'), + content: Text('Are you sure you want to delete "${address.label}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirm != true) return; + + try { + await Api.deleteDeliveryAddress(address.addressId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Address deleted'), + backgroundColor: Colors.green, + ), + ); + _loadAddresses(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to delete: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _setDefaultAddress(DeliveryAddress address) async { + try { + await Api.setDefaultDeliveryAddress(address.addressId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('"${address.label}" set as default'), + backgroundColor: Colors.green, + ), + ); + _loadAddresses(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to set default: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _navigateToAddAddress() async { + final result = await Navigator.pushNamed(context, '/address-edit'); + if (result == true && mounted) { + _loadAddresses(); + } + } + + void _navigateToEditAddress(DeliveryAddress address) async { + final result = await Navigator.pushNamed( + context, + '/address-edit', + arguments: address, + ); + if (result == true && mounted) { + _loadAddresses(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Delivery Addresses'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: _navigateToAddAddress, + tooltip: 'Add Address', + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Failed to load addresses', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _error!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _loadAddresses, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (_addresses.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.location_off_outlined, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No addresses yet', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Add a delivery address to get started', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _navigateToAddAddress, + icon: const Icon(Icons.add), + label: const Text('Add Address'), + ), + ], + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadAddresses, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _addresses.length, + itemBuilder: (context, index) { + final address = _addresses[index]; + return _AddressCard( + address: address, + onTap: () => _navigateToEditAddress(address), + onDelete: () => _deleteAddress(address), + onSetDefault: address.isDefault ? null : () => _setDefaultAddress(address), + ); + }, + ), + ); + } +} + +class _AddressCard extends StatelessWidget { + final DeliveryAddress address; + final VoidCallback onTap; + final VoidCallback onDelete; + final VoidCallback? onSetDefault; + + const _AddressCard({ + required this.address, + required this.onTap, + required this.onDelete, + this.onSetDefault, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getLabelIcon(address.label), + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + address.label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (address.isDefault) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Default', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + address.line1, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (address.line2.isNotEmpty) + Text( + address.line2, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + '${address.city}, ${address.stateAbbr} ${address.zipCode}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (onSetDefault != null) + TextButton( + onPressed: onSetDefault, + child: const Text('Set as Default'), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + Icons.delete_outline, + color: Theme.of(context).colorScheme.error, + ), + onPressed: onDelete, + tooltip: 'Delete', + ), + ], + ), + ], + ), + ), + ), + ); + } + + IconData _getLabelIcon(String label) { + final lower = label.toLowerCase(); + if (lower.contains('home')) return Icons.home; + if (lower.contains('work') || lower.contains('office')) return Icons.business; + return Icons.location_on; + } +} diff --git a/lib/screens/cart_view_screen.dart b/lib/screens/cart_view_screen.dart index 56f6977..469b48a 100644 --- a/lib/screens/cart_view_screen.dart +++ b/lib/screens/cart_view_screen.dart @@ -387,6 +387,11 @@ class _CartViewScreenState extends State { // Update app state appState.updateActiveOrderStatus(update.statusId); + // Clear active order if terminal state (4=Complete, 5=Cancelled) + if (update.statusId >= 4) { + appState.clearActiveOrder(); + } + // Show notification using global scaffold messenger key // This works even after the cart screen is popped rootScaffoldMessengerKey.currentState?.showSnackBar( diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 5da2cd8..705abb2 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -52,14 +52,18 @@ class _LoginScreenState extends State { token: result.token, ); + // Set the auth token on the API class + Api.setAuthToken(result.token); + final appState = context.read(); appState.setUserId(result.userId); - // Go back to previous screen (menu) or beacon scan if no previous route + // Go back to previous screen (menu) or splash if no previous route if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } else { - Navigator.of(context).pushReplacementNamed(AppRoutes.beaconScan); + // No previous route - go to splash which will auto-navigate based on beacon detection + Navigator.of(context).pushReplacementNamed(AppRoutes.splash); } } catch (e) { if (!mounted) return; diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index 3868211..1e7b4de 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -256,6 +256,13 @@ class _MenuBrowseScreenState extends State { Navigator.of(context).pushNamed(AppRoutes.cartView); }, ), + IconButton( + icon: const Icon(Icons.person_outline), + tooltip: "Account", + onPressed: () { + Navigator.of(context).pushNamed(AppRoutes.account); + }, + ), ], ), body: (_businessId == null || _businessId! <= 0) @@ -322,8 +329,42 @@ class _MenuBrowseScreenState extends State { // Animated expand/collapse for items AnimatedCrossFade( firstChild: const SizedBox.shrink(), - secondChild: Column( - children: items.map((item) => _buildMenuItem(item)).toList(), + secondChild: Container( + // Slightly darker background to distinguish from category bar + color: const Color(0xFFF0F0F0), + child: Column( + children: [ + // Top gradient transition from category bar + Container( + height: 12, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + const Color(0xFF1B4D3E).withAlpha(60), + const Color(0xFFF0F0F0), + ], + ), + ), + ), + ...items.map((item) => _buildMenuItem(item)), + // Bottom fade-out gradient to show end of expanded section + Container( + height: 24, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + const Color(0xFFF0F0F0), + const Color(0xFF1B4D3E).withAlpha(60), + ], + ), + ), + ), + ], + ), ), crossFadeState: isExpanded ? CrossFadeState.showSecond diff --git a/lib/screens/order_history_screen.dart b/lib/screens/order_history_screen.dart new file mode 100644 index 0000000..bea95a7 --- /dev/null +++ b/lib/screens/order_history_screen.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../models/order_history.dart'; +import '../services/api.dart'; + +class OrderHistoryScreen extends StatefulWidget { + const OrderHistoryScreen({super.key}); + + @override + State createState() => _OrderHistoryScreenState(); +} + +class _OrderHistoryScreenState extends State { + List _orders = []; + bool _isLoading = true; + String? _error; + int _totalCount = 0; + + @override + void initState() { + super.initState(); + _loadOrders(); + } + + Future _loadOrders() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final response = await Api.getOrderHistory(limit: 50); + if (mounted) { + setState(() { + _orders = response.orders; + _totalCount = response.totalCount; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('Error loading order history: $e'); + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Order History'), + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Failed to load orders', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _error!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _loadOrders, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (_orders.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.receipt_long_outlined, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No orders yet', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Your completed orders will appear here', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadOrders, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _orders.length, + itemBuilder: (context, index) { + return _OrderCard(order: _orders[index]); + }, + ), + ); + } +} + +class _OrderCard extends StatelessWidget { + final OrderHistoryItem order; + + const _OrderCard({required this.order}); + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('MMM d, yyyy'); + final timeFormat = DateFormat('h:mm a'); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: InkWell( + onTap: () { + // TODO: Navigate to order detail + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Order details coming soon')), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: Business name + total + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + order.businessName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '\$${order.total.toStringAsFixed(2)}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + // Details row: Date, type, item count + Row( + children: [ + Icon( + Icons.calendar_today, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + dateFormat.format(order.createdAt), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + Icon( + _getOrderTypeIcon(order.orderTypeId), + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + order.typeName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + Text( + '${order.itemCount} item${order.itemCount == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 8), + // Status chip + Row( + children: [ + _StatusChip(statusId: order.statusId, statusName: order.statusName), + const Spacer(), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ], + ), + ), + ), + ); + } + + IconData _getOrderTypeIcon(int typeId) { + switch (typeId) { + case 1: + return Icons.restaurant; // Dine-in + case 2: + return Icons.shopping_bag; // Takeaway + case 3: + return Icons.delivery_dining; // Delivery + default: + return Icons.receipt; + } + } +} + +class _StatusChip extends StatelessWidget { + final int statusId; + final String statusName; + + const _StatusChip({required this.statusId, required this.statusName}); + + @override + Widget build(BuildContext context) { + Color backgroundColor; + Color textColor; + IconData icon; + + switch (statusId) { + case 1: // Submitted + backgroundColor = Colors.blue.shade100; + textColor = Colors.blue.shade800; + icon = Icons.send; + break; + case 2: // In Progress + backgroundColor = Colors.orange.shade100; + textColor = Colors.orange.shade800; + icon = Icons.pending; + break; + case 3: // Ready + backgroundColor = Colors.green.shade100; + textColor = Colors.green.shade800; + icon = Icons.check_circle_outline; + break; + case 4: // Completed + backgroundColor = Colors.green.shade100; + textColor = Colors.green.shade800; + icon = Icons.check_circle; + break; + case 5: // Cancelled + backgroundColor = Colors.red.shade100; + textColor = Colors.red.shade800; + icon = Icons.cancel; + break; + default: + backgroundColor = Colors.grey.shade200; + textColor = Colors.grey.shade800; + icon = Icons.help_outline; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: textColor), + const SizedBox(width: 4), + Text( + statusName, + style: TextStyle( + color: textColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/profile_settings_screen.dart b/lib/screens/profile_settings_screen.dart new file mode 100644 index 0000000..60a078e --- /dev/null +++ b/lib/screens/profile_settings_screen.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; + +import '../models/user_profile.dart'; +import '../services/api.dart'; + +class ProfileSettingsScreen extends StatefulWidget { + const ProfileSettingsScreen({super.key}); + + @override + State createState() => _ProfileSettingsScreenState(); +} + +class _ProfileSettingsScreenState extends State { + final _formKey = GlobalKey(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + + bool _isLoading = true; + bool _isSaving = false; + String? _error; + UserProfile? _profile; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + Future _loadProfile() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final profile = await Api.getProfile(); + if (mounted) { + setState(() { + _profile = profile; + _firstNameController.text = profile.firstName; + _lastNameController.text = profile.lastName; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('Error loading profile: $e'); + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Future _saveProfile() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isSaving = true); + + try { + final updatedProfile = await Api.updateProfile( + firstName: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), + ); + + if (mounted) { + setState(() { + _profile = updatedProfile; + _isSaving = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Profile updated'), + backgroundColor: Colors.green, + ), + ); + + Navigator.of(context).pop(); + } + } catch (e) { + debugPrint('Error saving profile: $e'); + if (mounted) { + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Profile Settings'), + actions: [ + if (!_isLoading && _error == null) + TextButton( + onPressed: _isSaving ? null : _saveProfile, + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Failed to load profile', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _error!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _loadProfile, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Editable fields + TextFormField( + controller: _firstNameController, + decoration: const InputDecoration( + labelText: 'First Name', + border: OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.words, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter your first name'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _lastNameController, + decoration: const InputDecoration( + labelText: 'Last Name', + border: OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.words, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter your last name'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Read-only fields + if (_profile != null) ...[ + Text( + 'Contact Information', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + _ReadOnlyField( + label: 'Email', + value: _profile!.email.isNotEmpty ? _profile!.email : 'Not set', + icon: Icons.email_outlined, + ), + const SizedBox(height: 8), + _ReadOnlyField( + label: 'Phone', + value: _profile!.phone.isNotEmpty ? _profile!.phone : 'Not set', + icon: Icons.phone_outlined, + ), + const SizedBox(height: 16), + Text( + 'Contact info can only be changed through customer support.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _ReadOnlyField extends StatelessWidget { + final String label; + final String value; + final IconData icon; + + const _ReadOnlyField({ + required this.label, + required this.value, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/restaurant_select_screen.dart b/lib/screens/restaurant_select_screen.dart index 25be821..12c52df 100644 --- a/lib/screens/restaurant_select_screen.dart +++ b/lib/screens/restaurant_select_screen.dart @@ -257,7 +257,7 @@ class _RestaurantBar extends StatelessWidget { end: Alignment.centerRight, colors: [ isExpanded - ? Theme.of(context).colorScheme.primaryContainer + ? Colors.transparent : Colors.grey.shade900, Colors.transparent, ], @@ -281,7 +281,7 @@ class _RestaurantBar extends StatelessWidget { end: Alignment.centerLeft, colors: [ isExpanded - ? Theme.of(context).colorScheme.primaryContainer + ? Colors.transparent : Colors.grey.shade900, Colors.transparent, ], @@ -352,18 +352,6 @@ class _RestaurantBar extends StatelessWidget { ), ), ), - // Expand indicator - AnimatedRotation( - turns: isExpanded ? 0.5 : 0, - duration: const Duration(milliseconds: 300), - child: Icon( - Icons.keyboard_arrow_down, - color: isExpanded - ? Theme.of(context).colorScheme.onPrimaryContainer - : Colors.white70, - size: 28, - ), - ), ], ), ), diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 597a160..fbaa7b5 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -124,10 +124,23 @@ class _SplashScreenState extends State with TickerProviderStateMix print('[Splash] 🔐 Checking for saved auth credentials...'); final credentials = await AuthStorage.loadAuth(); if (credentials != null && mounted) { - print('[Splash] ✅ Found saved credentials: UserID=${credentials.userId}'); + print('[Splash] ✅ Found saved credentials: UserID=${credentials.userId}, token=${credentials.token.substring(0, 8)}...'); Api.setAuthToken(credentials.token); - final appState = context.read(); - appState.setUserId(credentials.userId); + + // Validate token is still valid by calling profile endpoint + print('[Splash] 🔍 Validating token with server...'); + final isValid = await _validateToken(); + if (isValid && mounted) { + print('[Splash] ✅ Token is valid'); + final appState = context.read(); + appState.setUserId(credentials.userId); + } else { + print('[Splash] ❌ Token is invalid or expired, clearing saved auth'); + await AuthStorage.clearAuth(); + Api.clearAuthToken(); + } + } else { + print('[Splash] ❌ No saved credentials found'); } // Start beacon scanning in background @@ -138,6 +151,17 @@ class _SplashScreenState extends State with TickerProviderStateMix _navigateToNextScreen(); } + /// Validates the stored token by making a profile API call + Future _validateToken() async { + try { + final profile = await Api.getProfile(); + return profile.userId > 0; + } catch (e) { + print('[Splash] Token validation failed: $e'); + return false; + } + } + Future _performBeaconScan() async { print('[Splash] 📡 Starting beacon scan...'); @@ -328,6 +352,8 @@ class _SplashScreenState extends State with TickerProviderStateMix businessName: mapping.businessName, servicePointName: mapping.servicePointName, ); + // Beacon detected = dine-in at a table + appState.setOrderType(OrderType.dineIn); Api.setBusinessId(mapping.businessId); print('[Splash] 🎉 Auto-selected: ${mapping.businessName}'); diff --git a/lib/services/api.dart b/lib/services/api.dart index a93301b..c710103 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -3,8 +3,10 @@ import "package:http/http.dart" as http; import "../models/cart.dart"; import "../models/menu_item.dart"; +import "../models/order_history.dart"; import "../models/restaurant.dart"; import "../models/service_point.dart"; +import "../models/user_profile.dart"; import "auth_storage.dart"; class ApiRawResponse { @@ -46,6 +48,7 @@ class Api { static int _mvpBusinessId = 17; static void setAuthToken(String? token) => _userToken = token; + static void clearAuthToken() => _userToken = null; static void setBusinessId(int? businessId) { if (businessId != null && businessId > 0) { @@ -544,6 +547,24 @@ class Api { }).toList(); } + /// Get list of states/provinces for address forms + static Future> getStates() async { + final raw = await _getRaw("/addresses/states.cfm"); + final j = _requireJson(raw, "GetStates"); + + if (!_ok(j)) { + return []; + } + + final arr = _pickArray(j, const ["STATES", "states"]); + if (arr == null) return []; + + return arr.map((e) { + final item = e is Map ? e : (e as Map).cast(); + return StateInfo.fromJson(item); + }).toList(); + } + /// Get user's delivery addresses static Future> getDeliveryAddresses() async { final raw = await _getRaw("/addresses/list.cfm"); @@ -591,6 +612,158 @@ class Api { final addressData = j["ADDRESS"] as Map? ?? {}; return DeliveryAddress.fromJson(addressData); } + + /// Delete a delivery address + static Future deleteDeliveryAddress(int addressId) async { + final raw = await _postRaw("/addresses/delete.cfm", { + "AddressID": addressId, + }); + + final j = _requireJson(raw, "DeleteDeliveryAddress"); + + if (!_ok(j)) { + throw StateError("DeleteDeliveryAddress failed: ${_err(j)}"); + } + } + + /// Set an address as the default delivery address + static Future setDefaultDeliveryAddress(int addressId) async { + final raw = await _postRaw("/addresses/setDefault.cfm", { + "AddressID": addressId, + }); + + final j = _requireJson(raw, "SetDefaultDeliveryAddress"); + + if (!_ok(j)) { + throw StateError("SetDefaultDeliveryAddress failed: ${_err(j)}"); + } + } + + /// Get user's avatar URL + static Future getAvatar() async { + print('[API] getAvatar: token=${_userToken != null ? "${_userToken!.substring(0, 8)}..." : "NULL"}'); + final raw = await _getRaw("/auth/avatar.cfm"); + print('[API] getAvatar response: ${raw.rawBody}'); + final j = _requireJson(raw, "GetAvatar"); + + if (!_ok(j)) { + return const AvatarInfo(hasAvatar: false, avatarUrl: null); + } + + return AvatarInfo( + hasAvatar: j["HAS_AVATAR"] == true, + avatarUrl: j["AVATAR_URL"] as String?, + ); + } + + /// Debug: Check token status with server + static Future debugCheckToken() async { + try { + final raw = await _getRaw("/debug/checkToken.cfm"); + print('[API] debugCheckToken response: ${raw.rawBody}'); + } catch (e) { + print('[API] debugCheckToken error: $e'); + } + } + + /// Upload user avatar image + static Future uploadAvatar(String filePath) async { + // First check token status + await debugCheckToken(); + + final uri = _u("/auth/avatar.cfm"); + + final request = http.MultipartRequest("POST", uri); + + // Add auth headers + final tok = _userToken; + print('[API] uploadAvatar: token=${tok != null ? "${tok.substring(0, 8)}..." : "NULL"}'); + if (tok != null && tok.isNotEmpty) { + request.headers["X-User-Token"] = tok; + } + request.headers["X-Business-ID"] = _mvpBusinessId.toString(); + + // Add the file + request.files.add(await http.MultipartFile.fromPath("avatar", filePath)); + + final streamedResponse = await request.send(); + final response = await http.Response.fromStream(streamedResponse); + + print('[API] uploadAvatar response: ${response.statusCode} - ${response.body}'); + + final j = _tryDecodeJsonMap(response.body); + if (j == null) { + throw StateError("UploadAvatar: Invalid JSON response"); + } + + if (!_ok(j)) { + throw StateError("UploadAvatar failed: ${_err(j)}"); + } + + return j["AVATAR_URL"] as String? ?? ""; + } + + /// Get order history for current user + static Future getOrderHistory({int limit = 20, int offset = 0}) async { + print('[API] getOrderHistory: token=${_userToken != null ? "${_userToken!.substring(0, 8)}..." : "NULL"}'); + final raw = await _getRaw("/orders/history.cfm?limit=$limit&offset=$offset"); + print('[API] getOrderHistory response: ${raw.rawBody.substring(0, raw.rawBody.length > 200 ? 200 : raw.rawBody.length)}'); + final j = _requireJson(raw, "GetOrderHistory"); + + if (!_ok(j)) { + throw StateError("GetOrderHistory failed: ${_err(j)}"); + } + + final ordersJson = j["ORDERS"] as List? ?? []; + final orders = ordersJson + .map((e) => OrderHistoryItem.fromJson(e as Map)) + .toList(); + + return OrderHistoryResponse( + orders: orders, + totalCount: (j["TOTAL_COUNT"] as num?)?.toInt() ?? orders.length, + ); + } + + /// Get user profile + static Future getProfile() async { + final raw = await _getRaw("/auth/profile.cfm"); + final j = _requireJson(raw, "GetProfile"); + + if (!_ok(j)) { + throw StateError("GetProfile failed: ${_err(j)}"); + } + + final userData = j["USER"] as Map? ?? {}; + return UserProfile.fromJson(userData); + } + + /// Update user profile + static Future updateProfile({String? firstName, String? lastName}) async { + final body = {}; + if (firstName != null) body["firstName"] = firstName; + if (lastName != null) body["lastName"] = lastName; + + final raw = await _postRaw("/auth/profile.cfm", body); + final j = _requireJson(raw, "UpdateProfile"); + + if (!_ok(j)) { + throw StateError("UpdateProfile failed: ${_err(j)}"); + } + + final userData = j["USER"] as Map? ?? {}; + return UserProfile.fromJson(userData); + } +} + +class OrderHistoryResponse { + final List orders; + final int totalCount; + + const OrderHistoryResponse({ + required this.orders, + required this.totalCount, + }); } class BeaconBusinessMapping { @@ -702,3 +875,33 @@ class DeliveryAddress { ); } } + +class AvatarInfo { + final bool hasAvatar; + final String? avatarUrl; + + const AvatarInfo({ + required this.hasAvatar, + required this.avatarUrl, + }); +} + +class StateInfo { + final int stateId; + final String abbr; + final String name; + + const StateInfo({ + required this.stateId, + required this.abbr, + required this.name, + }); + + factory StateInfo.fromJson(Map json) { + return StateInfo( + stateId: (json["StateID"] ?? json["STATEID"] ?? 0) as int, + abbr: (json["Abbr"] ?? json["ABBR"] ?? "") as String, + name: (json["Name"] ?? json["NAME"] ?? "") as String, + ); + } +} diff --git a/lib/services/order_polling_service.dart b/lib/services/order_polling_service.dart index fc95aff..f47d3b1 100644 --- a/lib/services/order_polling_service.dart +++ b/lib/services/order_polling_service.dart @@ -86,6 +86,13 @@ class OrderPollingService { message: message, )); } + + // Stop polling if order reached terminal state (4=Complete, 5=Cancelled) + if (newStatusId >= 4) { + print('[OrderPolling] 🏁 Order reached terminal status ($statusName), stopping polling'); + stopPolling(); + return; + } } else { print('[OrderPolling] â„šī¸ No status update'); } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..64a0ece 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..2db3c22 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..ab1fdba 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import file_selector_macos import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 8e738ac..2962830 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" crypto: dependency: transitive description: @@ -113,6 +121,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" flutter: dependency: "direct main" description: flutter @@ -134,6 +174,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_stripe: dependency: "direct main" description: @@ -184,6 +232,78 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + url: "https://pub.dev" + source: hosted + version: "0.8.13+3" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" json_annotation: dependency: transitive description: @@ -248,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 893a235..e3e2053 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,8 @@ dependencies: shared_preferences: ^2.2.3 dchs_flutter_beacon: ^0.6.6 flutter_stripe: ^11.4.0 + image_picker: ^1.0.7 + intl: ^0.19.0 dev_dependencies: flutter_test: