diff --git a/lib/app/app_router.dart b/lib/app/app_router.dart index 7102f0d..83067cd 100644 --- a/lib/app/app_router.dart +++ b/lib/app/app_router.dart @@ -2,8 +2,10 @@ import "package:flutter/material.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_type_select_screen.dart"; import "../screens/restaurant_select_screen.dart"; import "../screens/service_point_select_screen.dart"; import "../screens/splash_screen.dart"; @@ -12,6 +14,8 @@ class AppRoutes { static const String splash = "/"; static const String login = "/login"; static const String beaconScan = "/beacon-scan"; + static const String orderTypeSelect = "/order-type"; + static const String groupOrderInvite = "/group-invite"; static const String restaurantSelect = "/restaurants"; static const String servicePointSelect = "/service-points"; static const String menuBrowse = "/menu"; @@ -21,6 +25,8 @@ class AppRoutes { splash: (_) => const SplashScreen(), login: (_) => const LoginScreen(), beaconScan: (_) => const BeaconScanScreen(), + orderTypeSelect: (_) => const OrderTypeSelectScreen(), + groupOrderInvite: (_) => const GroupOrderInviteScreen(), restaurantSelect: (_) => const RestaurantSelectScreen(), servicePointSelect: (_) => const ServicePointSelectScreen(), menuBrowse: (_) => const MenuBrowseScreen(), diff --git a/lib/app/app_state.dart b/lib/app/app_state.dart index 731d20b..359fd9d 100644 --- a/lib/app/app_state.dart +++ b/lib/app/app_state.dart @@ -1,5 +1,11 @@ import "package:flutter/foundation.dart"; +enum OrderType { + dineIn, // Table service (beacon detected) + delivery, // Delivery to customer + takeaway, // Pickup at counter +} + class AppState extends ChangeNotifier { int? _selectedBusinessId; String? _selectedBusinessName; @@ -8,6 +14,8 @@ class AppState extends ChangeNotifier { int? _userId; + OrderType? _orderType; + int? _cartOrderId; String? _cartOrderUuid; int _cartItemCount = 0; @@ -23,13 +31,18 @@ class AppState extends ChangeNotifier { int? get userId => _userId; bool get isLoggedIn => _userId != null && _userId! > 0; + OrderType? get orderType => _orderType; + bool get isDelivery => _orderType == OrderType.delivery; + bool get isTakeaway => _orderType == OrderType.takeaway; + bool get isDineIn => _orderType == OrderType.dineIn; + int? get cartOrderId => _cartOrderId; String? get cartOrderUuid => _cartOrderUuid; int get cartItemCount => _cartItemCount; int? get activeOrderId => _activeOrderId; int? get activeOrderStatusId => _activeOrderStatusId; - + bool get hasActiveOrder => _activeOrderId != null; bool get hasLocationSelection => _selectedBusinessId != null && _selectedServicePointId != null; @@ -83,6 +96,11 @@ class AppState extends ChangeNotifier { notifyListeners(); } + void setOrderType(OrderType? type) { + _orderType = type; + notifyListeners(); + } + void setCartOrder({required int orderId, required String orderUuid, int itemCount = 0}) { _cartOrderId = orderId; _cartOrderUuid = orderUuid; @@ -122,6 +140,7 @@ class AppState extends ChangeNotifier { void clearAll() { _selectedBusinessId = null; _selectedServicePointId = null; + _orderType = null; _cartOrderId = null; _cartOrderUuid = null; diff --git a/lib/screens/beacon_scan_screen.dart b/lib/screens/beacon_scan_screen.dart index 385aaab..7874a75 100644 --- a/lib/screens/beacon_scan_screen.dart +++ b/lib/screens/beacon_scan_screen.dart @@ -93,8 +93,8 @@ class _BeaconScanScreenState extends State with SingleTickerPr } if (_uuidToBeaconId.isEmpty) { - print('[BeaconScan] ⚠️ No beacons in database, going to restaurant select'); - if (mounted) _navigateToRestaurantSelect(); + print('[BeaconScan] ⚠️ No beacons in database, going to order type select'); + if (mounted) _navigateToOrderTypeSelect(); return; } @@ -234,7 +234,7 @@ class _BeaconScanScreenState extends State with SingleTickerPr if (beaconScores.isEmpty) { setState(() => _status = 'No beacons nearby'); await Future.delayed(const Duration(milliseconds: 800)); - if (mounted) _navigateToRestaurantSelect(); + if (mounted) _navigateToOrderTypeSelect(); } else { // Find beacon with highest average RSSI and minimum detections final best = _findBestBeacon(beaconScores); @@ -246,7 +246,7 @@ class _BeaconScanScreenState extends State with SingleTickerPr print('[BeaconScan] ⚠️ No beacon met minimum confidence threshold'); setState(() => _status = 'No strong beacon signal'); await Future.delayed(const Duration(milliseconds: 800)); - if (mounted) _navigateToRestaurantSelect(); + if (mounted) _navigateToOrderTypeSelect(); } } } catch (e) { @@ -384,6 +384,10 @@ class _BeaconScanScreenState extends State with SingleTickerPr Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); } + void _navigateToOrderTypeSelect() { + Navigator.of(context).pushReplacementNamed(AppRoutes.orderTypeSelect); + } + void _retryPermissions() async { await BeaconPermissions.openSettings(); } diff --git a/lib/screens/cart_view_screen.dart b/lib/screens/cart_view_screen.dart index 311535f..24d390b 100644 --- a/lib/screens/cart_view_screen.dart +++ b/lib/screens/cart_view_screen.dart @@ -34,13 +34,19 @@ class _CartViewScreenState extends State { String? _error; Map _menuItemsById = {}; - // Tip options as percentages - static const List _tipPercentages = [0, 15, 18, 20, 25]; + // Tip options as percentages (null = custom) + static const List _tipPercentages = [0, 15, 18, 20, null]; int _selectedTipIndex = 1; // Default to 15% + int _customTipPercent = 25; // Default custom tip if selected double get _tipAmount { if (_cart == null) return 0.0; - return _cart!.subtotal * (_tipPercentages[_selectedTipIndex] / 100); + final percent = _tipPercentages[_selectedTipIndex]; + if (percent == null) { + // Custom tip + return _cart!.subtotal * (_customTipPercent / 100); + } + return _cart!.subtotal * (percent / 100); } FeeBreakdown get _feeBreakdown { @@ -175,6 +181,57 @@ class _CartViewScreenState extends State { } } + Future _showCustomTipDialog() async { + final controller = TextEditingController(text: _customTipPercent.toString()); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Custom Tip"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + labelText: "Tip Percentage", + suffixText: "%", + hintText: "0-200", + ), + ), + const SizedBox(height: 8), + Text( + "Enter a tip percentage from 0% to 200%", + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final value = int.tryParse(controller.text) ?? 0; + final clampedValue = value.clamp(0, 200); + Navigator.pop(context, clampedValue); + }, + child: const Text("Apply"), + ), + ], + ), + ); + + if (result != null) { + setState(() { + _customTipPercent = result; + _selectedTipIndex = _tipPercentages.length - 1; // Select "Custom" + }); + } + } + Future _processPaymentAndSubmit() async { if (_cart == null) return; @@ -619,6 +676,7 @@ class _CartViewScreenState extends State { children: List.generate(_tipPercentages.length, (index) { final isSelected = _selectedTipIndex == index; final percent = _tipPercentages[index]; + final isCustom = percent == null; return Expanded( child: Padding( padding: EdgeInsets.only( @@ -626,7 +684,13 @@ class _CartViewScreenState extends State { right: index == _tipPercentages.length - 1 ? 0 : 4, ), child: GestureDetector( - onTap: () => setState(() => _selectedTipIndex = index), + onTap: () { + if (isCustom) { + _showCustomTipDialog(); + } else { + setState(() => _selectedTipIndex = index); + } + }, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( @@ -638,7 +702,9 @@ class _CartViewScreenState extends State { ), child: Center( child: Text( - percent == 0 ? "No tip" : "$percent%", + isCustom + ? (isSelected ? "$_customTipPercent%" : "Custom") + : (percent == 0 ? "No tip" : "$percent%"), style: TextStyle( fontSize: 14, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, diff --git a/lib/screens/group_order_invite_screen.dart b/lib/screens/group_order_invite_screen.dart new file mode 100644 index 0000000..c1439ee --- /dev/null +++ b/lib/screens/group_order_invite_screen.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../app/app_router.dart'; +import '../app/app_state.dart'; + +/// Screen to invite additional Payfrit users to join a group order +/// Shown after selecting Delivery or Takeaway +class GroupOrderInviteScreen extends StatefulWidget { + const GroupOrderInviteScreen({super.key}); + + @override + State createState() => _GroupOrderInviteScreenState(); +} + +class _GroupOrderInviteScreenState extends State { + final List _invitedUsers = []; + final TextEditingController _searchController = TextEditingController(); + bool _isSearching = false; + + // Mock search results - in production this would come from API + final List<_UserResult> _searchResults = []; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _searchUsers(String query) { + if (query.isEmpty) { + setState(() { + _searchResults.clear(); + _isSearching = false; + }); + return; + } + + setState(() => _isSearching = true); + + // TODO: Replace with actual API call to search users by phone/email/username + // For now, show placeholder + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted && _searchController.text == query) { + setState(() { + _searchResults.clear(); + // Mock results - would come from API + if (query.length >= 3) { + _searchResults.addAll([ + _UserResult( + userId: 1, + name: 'John D.', + phone: '***-***-${query.substring(0, 4)}', + ), + ]); + } + _isSearching = false; + }); + } + }); + } + + void _inviteUser(_UserResult user) { + if (!_invitedUsers.contains(user.name)) { + setState(() { + _invitedUsers.add(user.name); + _searchResults.clear(); + _searchController.clear(); + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Invitation sent to ${user.name}'), + backgroundColor: Colors.green, + ), + ); + } + } + + void _removeInvite(String userName) { + setState(() { + _invitedUsers.remove(userName); + }); + } + + void _continueToRestaurants() { + // Store invited users in app state if needed + final appState = context.read(); + // TODO: appState.setGroupOrderInvites(_invitedUsers); + + Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); + } + + void _skipInvites() { + Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final orderTypeLabel = appState.isDelivery ? 'Delivery' : 'Takeaway'; + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: Text('$orderTypeLabel Order'), + elevation: 0, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + const Icon( + Icons.group_add, + color: Colors.white54, + size: 48, + ), + const SizedBox(height: 16), + const Text( + "Invite others to join", + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + "Add Payfrit users to share this order.\n${appState.isDelivery ? 'Split the delivery fee between everyone!' : 'Everyone pays for their own items.'}", + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 24), + + // Search field + TextField( + controller: _searchController, + onChanged: _searchUsers, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Search by phone or email...', + hintStyle: const TextStyle(color: Colors.white38), + prefixIcon: const Icon(Icons.search, color: Colors.white54), + suffixIcon: _isSearching + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white54, + ), + ), + ) + : null, + filled: true, + fillColor: Colors.grey.shade900, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + + // Search results + if (_searchResults.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: _searchResults.map((user) { + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.blue.withAlpha(50), + child: Text( + user.name[0].toUpperCase(), + style: const TextStyle(color: Colors.blue), + ), + ), + title: Text( + user.name, + style: const TextStyle(color: Colors.white), + ), + subtitle: Text( + user.phone, + style: const TextStyle(color: Colors.white54), + ), + trailing: IconButton( + icon: const Icon(Icons.person_add, color: Colors.blue), + onPressed: () => _inviteUser(user), + ), + ); + }).toList(), + ), + ), + ], + + const SizedBox(height: 16), + + // Invited users list + if (_invitedUsers.isNotEmpty) ...[ + const Text( + 'Invited:', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _invitedUsers.map((name) { + return Chip( + avatar: CircleAvatar( + backgroundColor: Colors.green.withAlpha(50), + child: const Icon( + Icons.check, + size: 16, + color: Colors.green, + ), + ), + label: Text(name), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () => _removeInvite(name), + backgroundColor: Colors.grey.shade800, + labelStyle: const TextStyle(color: Colors.white), + ); + }).toList(), + ), + ], + + const Spacer(), + + // Continue button + if (_invitedUsers.isNotEmpty) + FilledButton.icon( + onPressed: _continueToRestaurants, + icon: const Icon(Icons.group), + label: Text('Continue with ${_invitedUsers.length + 1} people'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ) + else + FilledButton( + onPressed: _continueToRestaurants, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Continue Alone'), + ), + + const SizedBox(height: 12), + + // Skip button + TextButton( + onPressed: _skipInvites, + child: const Text( + 'Skip this step', + style: TextStyle(color: Colors.white54), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _UserResult { + final int userId; + final String name; + final String phone; + + const _UserResult({ + required this.userId, + required this.name, + required this.phone, + }); +} diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index c04c502..0291028 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -469,54 +469,39 @@ class _MenuBrowseScreenState extends State { ); } - /// Builds category background - tries image first, falls back to styled text + /// Builds category background - styled text only (no images) Widget _buildCategoryBackground(int categoryId, String categoryName) { - return Image.network( - "$_imageBaseUrl/categories/$categoryId.png", - fit: BoxFit.cover, - semanticLabel: categoryName, - errorBuilder: (context, error, stackTrace) { - return Image.network( - "$_imageBaseUrl/categories/$categoryId.jpg", - fit: BoxFit.cover, - semanticLabel: categoryName, - errorBuilder: (context, error, stackTrace) { - // No image - show white background with dark forest green text - const darkForestGreen = Color(0xFF1B4D3E); - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.white, - Colors.grey.shade100, - ], - ), - ), - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - categoryName, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: darkForestGreen, - letterSpacing: 1.2, - shadows: [ - Shadow( - offset: Offset(1, 1), - blurRadius: 2, - color: Colors.black26, - ), - ], - ), - ), - ); - }, - ); - }, + const darkForestGreen = Color(0xFF1B4D3E); + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white, + Colors.grey.shade100, + ], + ), + ), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + categoryName, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: darkForestGreen, + letterSpacing: 1.2, + shadows: [ + Shadow( + offset: Offset(1, 1), + blurRadius: 2, + color: Colors.black26, + ), + ], + ), + ), ); } @@ -1006,11 +991,21 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { bool _validate() { setState(() => _validationError = null); + // Helper to check if a modifier group has any selected descendants + bool hasSelectedDescendant(int itemId) { + if (_selectedItemIds.contains(itemId)) return true; + final children = widget.itemsByParent[itemId] ?? []; + return children.any((c) => hasSelectedDescendant(c.itemId)); + } + bool validateRecursive(int parentId, MenuItem parent) { final children = widget.itemsByParent[parentId] ?? []; if (children.isEmpty) return true; - final selectedChildren = children.where((c) => _selectedItemIds.contains(c.itemId)).toList(); + // A child is "selected" if it's directly selected OR if it's a group with selected descendants + final selectedChildren = children.where((c) => + _selectedItemIds.contains(c.itemId) || hasSelectedDescendant(c.itemId) + ).toList(); // Check if child selection is required if (parent.requiresChildSelection && selectedChildren.isEmpty) { diff --git a/lib/screens/order_type_select_screen.dart b/lib/screens/order_type_select_screen.dart new file mode 100644 index 0000000..5424960 --- /dev/null +++ b/lib/screens/order_type_select_screen.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../app/app_router.dart'; +import '../app/app_state.dart'; + +/// Screen shown when no beacon is detected +/// Allows user to choose between Delivery or Takeaway +class OrderTypeSelectScreen extends StatelessWidget { + const OrderTypeSelectScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Spacer(flex: 1), + + // Header + const Icon( + Icons.bluetooth_disabled, + color: Colors.white54, + size: 64, + ), + const SizedBox(height: 24), + const Text( + "No beacons found", + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + const Text( + "Are you ordering for:", + style: TextStyle( + color: Colors.white70, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + + const Spacer(flex: 1), + + // Delivery option + _OrderTypeCard( + icon: Icons.delivery_dining, + title: "Delivery", + subtitle: "Have your order delivered to you", + color: Colors.blue, + onTap: () => _selectOrderType(context, OrderType.delivery), + ), + + const SizedBox(height: 16), + + // Takeaway option + _OrderTypeCard( + icon: Icons.shopping_bag_outlined, + title: "Takeaway", + subtitle: "Pick up your order at the counter", + color: Colors.orange, + onTap: () => _selectOrderType(context, OrderType.takeaway), + ), + + const Spacer(flex: 2), + + // Skip / manual selection + TextButton( + onPressed: () => _navigateToRestaurantSelect(context), + child: const Text( + "Select Restaurant Manually", + style: TextStyle( + color: Colors.white54, + fontSize: 14, + ), + ), + ), + + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } + + void _selectOrderType(BuildContext context, OrderType orderType) { + final appState = context.read(); + appState.setOrderType(orderType); + + // Navigate to group invite screen to optionally add other users + Navigator.of(context).pushReplacementNamed(AppRoutes.groupOrderInvite); + } + + void _navigateToRestaurantSelect(BuildContext context) { + // Clear any order type and go to restaurant selection + final appState = context.read(); + appState.setOrderType(null); + + Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); + } +} + +class _OrderTypeCard extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final Color color; + final VoidCallback onTap; + + const _OrderTypeCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: color.withAlpha(100), + width: 2, + ), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + color.withAlpha(30), + color.withAlpha(10), + ], + ), + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: color.withAlpha(50), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: color, + size: 32, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: color, + size: 28, + ), + ], + ), + ), + ), + ); + } +}