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 "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/beacon_scan_screen.dart";
|
||||||
import "../screens/cart_view_screen.dart";
|
import "../screens/cart_view_screen.dart";
|
||||||
import "../screens/group_order_invite_screen.dart";
|
import "../screens/group_order_invite_screen.dart";
|
||||||
import "../screens/login_screen.dart";
|
import "../screens/login_screen.dart";
|
||||||
import "../screens/menu_browse_screen.dart";
|
import "../screens/menu_browse_screen.dart";
|
||||||
|
import "../screens/order_history_screen.dart";
|
||||||
import "../screens/order_type_select_screen.dart";
|
import "../screens/order_type_select_screen.dart";
|
||||||
|
import "../screens/profile_settings_screen.dart";
|
||||||
import "../screens/restaurant_select_screen.dart";
|
import "../screens/restaurant_select_screen.dart";
|
||||||
import "../screens/service_point_select_screen.dart";
|
import "../screens/service_point_select_screen.dart";
|
||||||
import "../screens/splash_screen.dart";
|
import "../screens/splash_screen.dart";
|
||||||
|
|
@ -20,6 +25,11 @@ class AppRoutes {
|
||||||
static const String servicePointSelect = "/service-points";
|
static const String servicePointSelect = "/service-points";
|
||||||
static const String menuBrowse = "/menu";
|
static const String menuBrowse = "/menu";
|
||||||
static const String cartView = "/cart";
|
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 => {
|
static Map<String, WidgetBuilder> get routes => {
|
||||||
splash: (_) => const SplashScreen(),
|
splash: (_) => const SplashScreen(),
|
||||||
|
|
@ -32,5 +42,10 @@ class AppRoutes {
|
||||||
menuBrowse: (_) => const MenuBrowseScreen(),
|
menuBrowse: (_) => const MenuBrowseScreen(),
|
||||||
"/menu_browse": (_) => const MenuBrowseScreen(), // Alias for menuBrowse
|
"/menu_browse": (_) => const MenuBrowseScreen(), // Alias for menuBrowse
|
||||||
cartView: (_) => const CartViewScreen(),
|
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
|
// Update app state
|
||||||
appState.updateActiveOrderStatus(update.statusId);
|
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
|
// Show notification using global scaffold messenger key
|
||||||
// This works even after the cart screen is popped
|
// This works even after the cart screen is popped
|
||||||
rootScaffoldMessengerKey.currentState?.showSnackBar(
|
rootScaffoldMessengerKey.currentState?.showSnackBar(
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,18 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
token: result.token,
|
token: result.token,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Set the auth token on the API class
|
||||||
|
Api.setAuthToken(result.token);
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
appState.setUserId(result.userId);
|
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()) {
|
if (Navigator.of(context).canPop()) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} else {
|
} 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) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
Navigator.of(context).pushNamed(AppRoutes.cartView);
|
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)
|
body: (_businessId == null || _businessId! <= 0)
|
||||||
|
|
@ -322,8 +329,42 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
// Animated expand/collapse for items
|
// Animated expand/collapse for items
|
||||||
AnimatedCrossFade(
|
AnimatedCrossFade(
|
||||||
firstChild: const SizedBox.shrink(),
|
firstChild: const SizedBox.shrink(),
|
||||||
secondChild: Column(
|
secondChild: Container(
|
||||||
children: items.map((item) => _buildMenuItem(item)).toList(),
|
// 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: isExpanded
|
||||||
? CrossFadeState.showSecond
|
? 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,
|
end: Alignment.centerRight,
|
||||||
colors: [
|
colors: [
|
||||||
isExpanded
|
isExpanded
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
? Colors.transparent
|
||||||
: Colors.grey.shade900,
|
: Colors.grey.shade900,
|
||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
],
|
],
|
||||||
|
|
@ -281,7 +281,7 @@ class _RestaurantBar extends StatelessWidget {
|
||||||
end: Alignment.centerLeft,
|
end: Alignment.centerLeft,
|
||||||
colors: [
|
colors: [
|
||||||
isExpanded
|
isExpanded
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
? Colors.transparent
|
||||||
: Colors.grey.shade900,
|
: Colors.grey.shade900,
|
||||||
Colors.transparent,
|
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...');
|
print('[Splash] 🔐 Checking for saved auth credentials...');
|
||||||
final credentials = await AuthStorage.loadAuth();
|
final credentials = await AuthStorage.loadAuth();
|
||||||
if (credentials != null && mounted) {
|
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);
|
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
|
// Start beacon scanning in background
|
||||||
|
|
@ -138,6 +151,17 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||||
_navigateToNextScreen();
|
_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 {
|
Future<void> _performBeaconScan() async {
|
||||||
print('[Splash] 📡 Starting beacon scan...');
|
print('[Splash] 📡 Starting beacon scan...');
|
||||||
|
|
||||||
|
|
@ -328,6 +352,8 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||||
businessName: mapping.businessName,
|
businessName: mapping.businessName,
|
||||||
servicePointName: mapping.servicePointName,
|
servicePointName: mapping.servicePointName,
|
||||||
);
|
);
|
||||||
|
// Beacon detected = dine-in at a table
|
||||||
|
appState.setOrderType(OrderType.dineIn);
|
||||||
Api.setBusinessId(mapping.businessId);
|
Api.setBusinessId(mapping.businessId);
|
||||||
|
|
||||||
print('[Splash] 🎉 Auto-selected: ${mapping.businessName}');
|
print('[Splash] 🎉 Auto-selected: ${mapping.businessName}');
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import "package:http/http.dart" as http;
|
||||||
|
|
||||||
import "../models/cart.dart";
|
import "../models/cart.dart";
|
||||||
import "../models/menu_item.dart";
|
import "../models/menu_item.dart";
|
||||||
|
import "../models/order_history.dart";
|
||||||
import "../models/restaurant.dart";
|
import "../models/restaurant.dart";
|
||||||
import "../models/service_point.dart";
|
import "../models/service_point.dart";
|
||||||
|
import "../models/user_profile.dart";
|
||||||
import "auth_storage.dart";
|
import "auth_storage.dart";
|
||||||
|
|
||||||
class ApiRawResponse {
|
class ApiRawResponse {
|
||||||
|
|
@ -46,6 +48,7 @@ class Api {
|
||||||
static int _mvpBusinessId = 17;
|
static int _mvpBusinessId = 17;
|
||||||
|
|
||||||
static void setAuthToken(String? token) => _userToken = token;
|
static void setAuthToken(String? token) => _userToken = token;
|
||||||
|
static void clearAuthToken() => _userToken = null;
|
||||||
|
|
||||||
static void setBusinessId(int? businessId) {
|
static void setBusinessId(int? businessId) {
|
||||||
if (businessId != null && businessId > 0) {
|
if (businessId != null && businessId > 0) {
|
||||||
|
|
@ -544,6 +547,24 @@ class Api {
|
||||||
}).toList();
|
}).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
|
/// Get user's delivery addresses
|
||||||
static Future<List<DeliveryAddress>> getDeliveryAddresses() async {
|
static Future<List<DeliveryAddress>> getDeliveryAddresses() async {
|
||||||
final raw = await _getRaw("/addresses/list.cfm");
|
final raw = await _getRaw("/addresses/list.cfm");
|
||||||
|
|
@ -591,6 +612,158 @@ class Api {
|
||||||
final addressData = j["ADDRESS"] as Map<String, dynamic>? ?? {};
|
final addressData = j["ADDRESS"] as Map<String, dynamic>? ?? {};
|
||||||
return DeliveryAddress.fromJson(addressData);
|
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 {
|
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,
|
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 {
|
} else {
|
||||||
print('[OrderPolling] ℹ️ No status update');
|
print('[OrderPolling] ℹ️ No status update');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import file_selector_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
128
pubspec.lock
128
pubspec.lock
|
|
@ -73,6 +73,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -113,6 +121,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -134,6 +174,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
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:
|
flutter_stripe:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -184,6 +232,78 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.7.2"
|
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:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -248,6 +368,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.17.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ dependencies:
|
||||||
shared_preferences: ^2.2.3
|
shared_preferences: ^2.2.3
|
||||||
dchs_flutter_beacon: ^0.6.6
|
dchs_flutter_beacon: ^0.6.6
|
||||||
flutter_stripe: ^11.4.0
|
flutter_stripe: ^11.4.0
|
||||||
|
image_picker: ^1.0.7
|
||||||
|
intl: ^0.19.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue