Features: - Beacon scanner service for detecting nearby beacons - Beacon cache for offline-first beacon resolution - Preload cache for instant menu display - Business selector screen for multi-location support - Rescan button widget for quick beacon refresh - Sign-in dialog for guest checkout flow - Task type model for server tasks Improvements: - Enhanced menu browsing with category filtering - Improved cart view with better modifier display - Order history with detailed order tracking - Chat screen improvements - Better error handling in API service Fixes: - CashApp payment return crash fix - Modifier nesting issues resolved - Auto-expand modifier groups Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
435 lines
14 KiB
Dart
435 lines
14 KiB
Dart
import "package:flutter/material.dart";
|
|
import "package:provider/provider.dart";
|
|
|
|
import "../app/app_router.dart";
|
|
import "../app/app_state.dart";
|
|
import "../services/api.dart";
|
|
import "../widgets/rescan_button.dart";
|
|
|
|
class BusinessSelectorScreen extends StatefulWidget {
|
|
const BusinessSelectorScreen({super.key});
|
|
|
|
@override
|
|
State<BusinessSelectorScreen> createState() => _BusinessSelectorScreenState();
|
|
}
|
|
|
|
class _BusinessSelectorScreenState extends State<BusinessSelectorScreen> {
|
|
static const String _imageBaseUrl = "https://biz.payfrit.com/uploads";
|
|
|
|
String? _parentName;
|
|
int? _parentBusinessId;
|
|
int? _servicePointId;
|
|
String? _servicePointName;
|
|
List<_BusinessItem>? _businesses;
|
|
bool _loading = false;
|
|
bool _initialized = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (!_initialized) {
|
|
_initialized = true;
|
|
_initializeData();
|
|
}
|
|
}
|
|
|
|
Future<void> _initializeData() async {
|
|
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
|
|
|
|
final BeaconBusinessMapping? mapping = args?["mapping"] as BeaconBusinessMapping?;
|
|
final List<ChildBusiness>? children = args?["children"] as List<ChildBusiness>?;
|
|
|
|
if (mapping != null && mapping.businesses.isNotEmpty) {
|
|
// From beacon mapping
|
|
setState(() {
|
|
_parentName = mapping.parent?.businessName ?? mapping.businessName;
|
|
_parentBusinessId = mapping.parent?.businessId ?? mapping.businessId;
|
|
_servicePointId = mapping.servicePointId;
|
|
_servicePointName = mapping.servicePointName;
|
|
_businesses = mapping.businesses.map((b) => _BusinessItem(
|
|
businessId: b.businessId,
|
|
businessName: b.businessName,
|
|
servicePointId: b.servicePointId,
|
|
servicePointName: b.servicePointName,
|
|
)).toList();
|
|
});
|
|
} else if (children != null && children.isNotEmpty) {
|
|
// From menu_browse_screen with children list
|
|
setState(() {
|
|
_parentName = (args?["parentBusinessName"] as String?) ?? "this location";
|
|
_parentBusinessId = args?["parentBusinessId"] as int?;
|
|
_servicePointId = args?["servicePointId"] as int?;
|
|
_servicePointName = args?["servicePointName"] as String?;
|
|
_businesses = children.map((c) => _BusinessItem(
|
|
businessId: c.businessId,
|
|
businessName: c.businessName,
|
|
servicePointId: _servicePointId ?? 0,
|
|
servicePointName: _servicePointName ?? "",
|
|
)).toList();
|
|
});
|
|
} else if (args?["parentBusinessId"] != null) {
|
|
// From menu back button - need to fetch children
|
|
_parentBusinessId = args?["parentBusinessId"] as int?;
|
|
_parentName = args?["parentBusinessName"] as String? ?? "this location";
|
|
_servicePointId = args?["servicePointId"] as int?;
|
|
_servicePointName = args?["servicePointName"] as String?;
|
|
|
|
setState(() => _loading = true);
|
|
try {
|
|
final fetchedChildren = await Api.getChildBusinesses(businessId: _parentBusinessId!);
|
|
if (mounted) {
|
|
setState(() {
|
|
_businesses = fetchedChildren.map((c) => _BusinessItem(
|
|
businessId: c.businessId,
|
|
businessName: c.businessName,
|
|
servicePointId: _servicePointId ?? 0,
|
|
servicePointName: _servicePointName ?? "",
|
|
)).toList();
|
|
_loading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() => _loading = false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_loading) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: const Center(
|
|
child: CircularProgressIndicator(color: Colors.white),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_businesses == null || _businesses!.isEmpty) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
"No businesses available",
|
|
style: TextStyle(color: Colors.white, fontSize: 18),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect),
|
|
child: const Text("Browse Restaurants"),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
// Large header banner with back button overlay
|
|
_buildHeaderBanner(context, _parentBusinessId, _parentName ?? ""),
|
|
// Message
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(24, 24, 24, 8),
|
|
child: Text(
|
|
"Please select one of the businesses below:",
|
|
style: TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 16,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Business list with header images
|
|
Expanded(
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
itemCount: _businesses!.length,
|
|
itemBuilder: (context, index) {
|
|
final business = _businesses![index];
|
|
return _BusinessCardWithHeader(
|
|
businessId: business.businessId,
|
|
businessName: business.businessName,
|
|
imageBaseUrl: _imageBaseUrl,
|
|
onTap: () => _selectBusiness(context, business, _parentBusinessId, _parentName),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeaderBanner(BuildContext context, int? parentBusinessId, String parentName) {
|
|
const imageBaseUrl = _imageBaseUrl;
|
|
return SizedBox(
|
|
height: 200,
|
|
width: double.infinity,
|
|
child: Stack(
|
|
children: [
|
|
// Background image
|
|
if (parentBusinessId != null)
|
|
Positioned.fill(
|
|
child: Image.network(
|
|
"$_imageBaseUrl/headers/$parentBusinessId.png",
|
|
fit: BoxFit.fitWidth,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Image.network(
|
|
"$_imageBaseUrl/headers/$parentBusinessId.jpg",
|
|
fit: BoxFit.fitWidth,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
Colors.grey.shade800,
|
|
Colors.grey.shade900,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
// Subtle gradient overlay at bottom for readability of text below
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 40,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.transparent,
|
|
Colors.black.withAlpha(150),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Back button
|
|
Positioned(
|
|
top: 8,
|
|
left: 8,
|
|
child: IconButton(
|
|
onPressed: () => Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect),
|
|
icon: const Icon(Icons.arrow_back),
|
|
color: Colors.white,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black54,
|
|
),
|
|
),
|
|
),
|
|
// Rescan button
|
|
Positioned(
|
|
top: 8,
|
|
right: 8,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.black54,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: const RescanButton(iconColor: Colors.white),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _selectBusiness(BuildContext context, _BusinessItem business, int? parentBusinessId, String? parentBusinessName) {
|
|
final appState = context.read<AppState>();
|
|
|
|
// Clear any existing cart
|
|
appState.clearCart();
|
|
|
|
// Set the selected business and service point (with parent info for back navigation)
|
|
appState.setBusinessAndServicePoint(
|
|
business.businessId,
|
|
business.servicePointId,
|
|
businessName: business.businessName,
|
|
servicePointName: business.servicePointName,
|
|
parentBusinessId: parentBusinessId,
|
|
parentBusinessName: parentBusinessName,
|
|
);
|
|
appState.setOrderType(OrderType.dineIn);
|
|
Api.setBusinessId(business.businessId);
|
|
|
|
// Navigate to menu
|
|
Navigator.of(context).pushReplacementNamed(
|
|
AppRoutes.menuBrowse,
|
|
arguments: {
|
|
"businessId": business.businessId,
|
|
"servicePointId": business.servicePointId,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Internal business item for unified handling
|
|
class _BusinessItem {
|
|
final int businessId;
|
|
final String businessName;
|
|
final int servicePointId;
|
|
final String servicePointName;
|
|
|
|
const _BusinessItem({
|
|
required this.businessId,
|
|
required this.businessName,
|
|
required this.servicePointId,
|
|
required this.servicePointName,
|
|
});
|
|
}
|
|
|
|
/// Business card with header image background
|
|
class _BusinessCardWithHeader extends StatelessWidget {
|
|
final int businessId;
|
|
final String businessName;
|
|
final String imageBaseUrl;
|
|
final VoidCallback onTap;
|
|
|
|
const _BusinessCardWithHeader({
|
|
required this.businessId,
|
|
required this.businessName,
|
|
required this.imageBaseUrl,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
height: 120,
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withAlpha(100),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: Stack(
|
|
children: [
|
|
// Header image background
|
|
Positioned.fill(
|
|
child: Image.network(
|
|
"$imageBaseUrl/headers/$businessId.png",
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Image.network(
|
|
"$imageBaseUrl/headers/$businessId.jpg",
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
// Fallback to logo centered
|
|
return Container(
|
|
color: Colors.grey.shade800,
|
|
child: Center(
|
|
child: Image.network(
|
|
"$imageBaseUrl/logos/$businessId.png",
|
|
width: 60,
|
|
height: 60,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Image.network(
|
|
"$imageBaseUrl/logos/$businessId.jpg",
|
|
width: 60,
|
|
height: 60,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Text(
|
|
businessName.isNotEmpty ? businessName[0].toUpperCase() : "?",
|
|
style: const TextStyle(
|
|
color: Colors.white54,
|
|
fontSize: 36,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
// Gradient overlay
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.transparent,
|
|
Colors.black.withAlpha(180),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Business name and arrow at bottom
|
|
Positioned(
|
|
bottom: 12,
|
|
left: 16,
|
|
right: 16,
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
businessName,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
shadows: [
|
|
Shadow(
|
|
offset: Offset(0, 1),
|
|
blurRadius: 3,
|
|
color: Colors.black54,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.arrow_forward_ios,
|
|
color: Colors.white70,
|
|
size: 20,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|