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:
parent
1d08b18568
commit
2491c961e0
20 changed files with 2207 additions and 21 deletions
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
48
lib/models/order_history.dart
Normal file
48
lib/models/order_history.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/models/user_profile.dart
Normal file
36
lib/models/user_profile.dart
Normal 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";
|
||||
}
|
||||
}
|
||||
436
lib/screens/account_screen.dart
Normal file
436
lib/screens/account_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
269
lib/screens/address_edit_screen.dart
Normal file
269
lib/screens/address_edit_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
353
lib/screens/address_list_screen.dart
Normal file
353
lib/screens/address_list_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
330
lib/screens/order_history_screen.dart
Normal file
330
lib/screens/order_history_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
288
lib/screens/profile_settings_screen.dart
Normal file
288
lib/screens/profile_settings_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// 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}');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
128
pubspec.lock
128
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue