Add address management and user account features

- Add delivery address list, add, edit, delete, set default functionality
- Add order history screen
- Add profile settings screen
- Add account screen with avatar upload
- Update restaurant select gradient direction
- Add states API endpoint for address forms
- Fix table names (tt_States)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-08 20:22:37 -08:00
parent 1d08b18568
commit 2491c961e0
20 changed files with 2207 additions and 21 deletions

View file

@ -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<String, WidgetBuilder> 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(),
};
}

View file

@ -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<String, dynamic> 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,
);
}
}

View file

@ -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<String, dynamic> 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";
}
}

View file

@ -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<AccountScreen> createState() => _AccountScreenState();
}
class _AccountScreenState extends State<AccountScreen> {
bool _isLoggingOut = false;
bool _isLoadingAvatar = true;
bool _isUploadingAvatar = false;
String? _avatarUrl;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
_loadAvatar();
}
Future<void> _loadAvatar() async {
// Don't try to load avatar if not logged in
final appState = context.read<AppState>();
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<void> _pickAndUploadAvatar() async {
// Check if user is logged in first
final appState = context.read<AppState>();
if (!appState.isLoggedIn) {
final shouldLogin = await showDialog<bool>(
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<ImageSource>(
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<void> _handleSignOut() async {
final confirm = await showDialog<bool>(
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>();
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<Color>(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<AppState>();
// 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),
],
),
);
}
}

View file

@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import '../services/api.dart';
class AddressEditScreen extends StatefulWidget {
const AddressEditScreen({super.key});
@override
State<AddressEditScreen> createState() => _AddressEditScreenState();
}
class _AddressEditScreenState extends State<AddressEditScreen> {
final _formKey = GlobalKey<FormState>();
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<StateInfo> _states = [];
DeliveryAddress? _existingAddress;
bool get _isEditing => _existingAddress != null;
@override
void initState() {
super.initState();
_loadStates();
}
Future<void> _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<void> _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<int>(
value: _selectedStateId,
decoration: const InputDecoration(
labelText: 'State',
border: OutlineInputBorder(),
),
items: _states.map((state) {
return DropdownMenuItem<int>(
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,
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import '../services/api.dart';
class AddressListScreen extends StatefulWidget {
const AddressListScreen({super.key});
@override
State<AddressListScreen> createState() => _AddressListScreenState();
}
class _AddressListScreenState extends State<AddressListScreen> {
List<DeliveryAddress> _addresses = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadAddresses();
}
Future<void> _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<void> _deleteAddress(DeliveryAddress address) async {
final confirm = await showDialog<bool>(
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<void> _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;
}
}

View file

@ -387,6 +387,11 @@ class _CartViewScreenState extends State<CartViewScreen> {
// 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(

View file

@ -52,14 +52,18 @@ class _LoginScreenState extends State<LoginScreen> {
token: result.token,
);
// Set the auth token on the API class
Api.setAuthToken(result.token);
final appState = context.read<AppState>();
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;

View file

@ -256,6 +256,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
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<MenuBrowseScreen> {
// 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

View file

@ -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<OrderHistoryScreen> createState() => _OrderHistoryScreenState();
}
class _OrderHistoryScreenState extends State<OrderHistoryScreen> {
List<OrderHistoryItem> _orders = [];
bool _isLoading = true;
String? _error;
int _totalCount = 0;
@override
void initState() {
super.initState();
_loadOrders();
}
Future<void> _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,
),
),
],
),
);
}
}

View file

@ -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<ProfileSettingsScreen> createState() => _ProfileSettingsScreenState();
}
class _ProfileSettingsScreenState extends State<ProfileSettingsScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _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,
),
],
),
),
],
),
);
}
}

View file

@ -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,
),
),
],
),
),

View file

@ -124,10 +124,23 @@ class _SplashScreenState extends State<SplashScreen> 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>();
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>();
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<SplashScreen> with TickerProviderStateMix
_navigateToNextScreen();
}
/// Validates the stored token by making a profile API call
Future<bool> _validateToken() async {
try {
final profile = await Api.getProfile();
return profile.userId > 0;
} catch (e) {
print('[Splash] Token validation failed: $e');
return false;
}
}
Future<void> _performBeaconScan() async {
print('[Splash] 📡 Starting beacon scan...');
@ -328,6 +352,8 @@ class _SplashScreenState extends State<SplashScreen> 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}');

View file

@ -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<List<StateInfo>> 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<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
return StateInfo.fromJson(item);
}).toList();
}
/// Get user's delivery addresses
static Future<List<DeliveryAddress>> getDeliveryAddresses() async {
final raw = await _getRaw("/addresses/list.cfm");
@ -591,6 +612,158 @@ class Api {
final addressData = j["ADDRESS"] as Map<String, dynamic>? ?? {};
return DeliveryAddress.fromJson(addressData);
}
/// Delete a delivery address
static Future<void> 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<void> 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<AvatarInfo> 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<void> 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<String> 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<OrderHistoryResponse> 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<dynamic>? ?? [];
final orders = ordersJson
.map((e) => OrderHistoryItem.fromJson(e as Map<String, dynamic>))
.toList();
return OrderHistoryResponse(
orders: orders,
totalCount: (j["TOTAL_COUNT"] as num?)?.toInt() ?? orders.length,
);
}
/// Get user profile
static Future<UserProfile> 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<String, dynamic>? ?? {};
return UserProfile.fromJson(userData);
}
/// Update user profile
static Future<UserProfile> updateProfile({String? firstName, String? lastName}) async {
final body = <String, dynamic>{};
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<String, dynamic>? ?? {};
return UserProfile.fromJson(userData);
}
}
class OrderHistoryResponse {
final List<OrderHistoryItem> 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<String, dynamic> 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,
);
}
}

View file

@ -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');
}

View file

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
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);
}

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -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"))
}

View file

@ -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:

View file

@ -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: