payfrit-app/lib/screens/restaurant_select_screen.dart
John Mizerek 2491c961e0 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>
2026-01-08 20:22:37 -08:00

694 lines
23 KiB
Dart

// lib/screens/restaurant_select_screen.dart
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../app/app_state.dart";
import "../models/menu_item.dart";
import "../models/restaurant.dart";
import "../models/service_point.dart";
import "../services/api.dart";
class RestaurantSelectScreen extends StatefulWidget {
const RestaurantSelectScreen({super.key});
@override
State<RestaurantSelectScreen> createState() => _RestaurantSelectScreenState();
}
class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
late Future<List<Restaurant>> _restaurantsFuture;
String? _debugLastRaw;
int? _debugLastStatus;
// Which restaurant is currently expanded (shows menu)
int? _expandedBusinessId;
// Cache for loaded menus and service points
final Map<int, List<MenuItem>> _menuCache = {};
final Map<int, List<ServicePoint>> _servicePointCache = {};
final Map<int, bool> _loadingMenu = {};
static const String _imageBaseUrl = "https://biz.payfrit.com/uploads";
@override
void initState() {
super.initState();
_restaurantsFuture = _loadRestaurants();
}
Future<List<Restaurant>> _loadRestaurants() async {
final raw = await Api.listRestaurantsRaw();
_debugLastRaw = raw.rawBody;
_debugLastStatus = raw.statusCode;
return Api.listRestaurants();
}
Future<void> _loadMenuForBusiness(int businessId) async {
if (_menuCache.containsKey(businessId)) return;
if (_loadingMenu[businessId] == true) return;
setState(() => _loadingMenu[businessId] = true);
try {
// Load menu items and service points in parallel
final results = await Future.wait([
Api.listMenuItems(businessId: businessId),
Api.listServicePoints(businessId: businessId),
]);
if (mounted) {
setState(() {
_menuCache[businessId] = results[0] as List<MenuItem>;
_servicePointCache[businessId] = results[1] as List<ServicePoint>;
_loadingMenu[businessId] = false;
});
}
} catch (e) {
debugPrint('[RestaurantSelect] Error loading menu: $e');
if (mounted) {
setState(() => _loadingMenu[businessId] = false);
}
}
}
void _toggleExpand(Restaurant restaurant) {
// For delivery/takeaway flow (no beacon), go directly to menu
// No need to select table - just pick the first service point
_navigateToMenu(restaurant);
}
void _navigateToMenu(Restaurant restaurant) async {
// Load service points if not cached
if (!_servicePointCache.containsKey(restaurant.businessId)) {
try {
final servicePoints = await Api.listServicePoints(businessId: restaurant.businessId);
_servicePointCache[restaurant.businessId] = servicePoints;
} catch (e) {
debugPrint('[RestaurantSelect] Error loading service points: $e');
}
}
if (!mounted) return;
// Default to first service point (for delivery/takeaway, table doesn't matter)
final servicePoints = _servicePointCache[restaurant.businessId];
final servicePointId = servicePoints?.firstOrNull?.servicePointId ?? 1;
// Set app state
final appState = context.read<AppState>();
appState.setBusinessAndServicePoint(
restaurant.businessId,
servicePointId,
businessName: restaurant.name,
servicePointName: servicePoints?.firstOrNull?.name,
);
Api.setBusinessId(restaurant.businessId);
// Navigate to full menu browse screen
Navigator.of(context).pushReplacementNamed(
'/menu_browse',
arguments: {
'businessId': restaurant.businessId,
'servicePointId': servicePointId,
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: const Text("Nearby Restaurants"),
elevation: 0,
),
body: FutureBuilder<List<Restaurant>>(
future: _restaurantsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (snapshot.hasError) {
return _ErrorPane(
title: "Failed to Load",
message: snapshot.error.toString(),
statusCode: _debugLastStatus,
raw: _debugLastRaw,
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()),
);
}
final restaurants = snapshot.data ?? const <Restaurant>[];
if (restaurants.isEmpty) {
return _ErrorPane(
title: "No Restaurants Found",
message: "No Payfrit restaurants nearby.",
statusCode: _debugLastStatus,
raw: _debugLastRaw,
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: restaurants.length,
itemBuilder: (context, index) {
final restaurant = restaurants[index];
final isExpanded = _expandedBusinessId == restaurant.businessId;
return _RestaurantBar(
restaurant: restaurant,
isExpanded: isExpanded,
onTap: () => _toggleExpand(restaurant),
menuItems: _menuCache[restaurant.businessId],
servicePoints: _servicePointCache[restaurant.businessId],
isLoading: _loadingMenu[restaurant.businessId] == true,
imageBaseUrl: _imageBaseUrl,
);
},
);
},
),
);
}
}
class _RestaurantBar extends StatelessWidget {
final Restaurant restaurant;
final bool isExpanded;
final VoidCallback onTap;
final List<MenuItem>? menuItems;
final List<ServicePoint>? servicePoints;
final bool isLoading;
final String imageBaseUrl;
const _RestaurantBar({
required this.restaurant,
required this.isExpanded,
required this.onTap,
required this.menuItems,
required this.servicePoints,
required this.isLoading,
required this.imageBaseUrl,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Restaurant header bar with logo
GestureDetector(
onTap: onTap,
child: Container(
height: 80,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: isExpanded
? Theme.of(context).colorScheme.primaryContainer
: Colors.grey.shade900,
),
child: Stack(
children: [
// Background header image (subtle)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Opacity(
opacity: 0.3,
child: SizedBox(
width: double.infinity,
height: 80,
child: Image.network(
"$imageBaseUrl/headers/${restaurant.businessId}.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$imageBaseUrl/headers/${restaurant.businessId}.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const SizedBox.shrink();
},
);
},
),
),
),
),
// Sharp gradient edges
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 20,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
isExpanded
? Colors.transparent
: Colors.grey.shade900,
Colors.transparent,
],
),
),
),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 20,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(12),
bottomRight: Radius.circular(12),
),
gradient: LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [
isExpanded
? Colors.transparent
: Colors.grey.shade900,
Colors.transparent,
],
),
),
),
),
// Content
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// Logo (56x56 recommended, or 112x112 for 2x)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 56,
height: 56,
child: Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// Text-based fallback with first letter
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.tertiary,
],
),
),
alignment: Alignment.center,
child: Text(
restaurant.name.isNotEmpty
? restaurant.name[0].toUpperCase()
: "?",
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
);
},
);
},
),
),
),
const SizedBox(width: 16),
// Name
Expanded(
child: Text(
restaurant.name,
style: TextStyle(
color: isExpanded
? Theme.of(context).colorScheme.onPrimaryContainer
: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
),
),
// Expanded menu content
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: _buildExpandedContent(context),
crossFadeState: isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 300),
sizeCurve: Curves.easeInOut,
),
],
);
}
Widget _buildExpandedContent(BuildContext context) {
if (isLoading) {
return Container(
padding: const EdgeInsets.all(32),
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
);
}
if (menuItems == null || menuItems!.isEmpty) {
return Container(
padding: const EdgeInsets.all(24),
margin: const EdgeInsets.symmetric(horizontal: 12),
child: const Center(
child: Text(
"No menu available",
style: TextStyle(color: Colors.white70),
),
),
);
}
// Organize menu items by category
final itemsByCategory = <int, List<MenuItem>>{};
for (final item in menuItems!) {
if (item.isRootItem && item.isActive) {
itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item);
}
}
// Sort within categories
for (final list in itemsByCategory.values) {
list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
}
final categoryIds = itemsByCategory.keys.toList()..sort();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade900.withAlpha(200),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Service point selector (if multiple)
if (servicePoints != null && servicePoints!.length > 1)
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Select a table to order",
style: TextStyle(
color: Colors.white.withAlpha(180),
fontSize: 14,
),
),
),
// Category sections with items
...categoryIds.map((categoryId) {
final items = itemsByCategory[categoryId]!;
final categoryName = items.first.categoryName;
return _CategorySection(
categoryId: categoryId,
categoryName: categoryName,
items: items,
imageBaseUrl: imageBaseUrl,
restaurant: restaurant,
servicePoints: servicePoints,
);
}),
const SizedBox(height: 12),
],
),
);
}
}
class _CategorySection extends StatelessWidget {
final int categoryId;
final String categoryName;
final List<MenuItem> items;
final String imageBaseUrl;
final Restaurant restaurant;
final List<ServicePoint>? servicePoints;
const _CategorySection({
required this.categoryId,
required this.categoryName,
required this.items,
required this.imageBaseUrl,
required this.restaurant,
required this.servicePoints,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
categoryName,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
// Horizontal scroll of items
SizedBox(
height: 140,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return _MenuItemCard(
item: item,
imageBaseUrl: imageBaseUrl,
onTap: () => _handleItemTap(context, item),
);
},
),
),
],
);
}
void _handleItemTap(BuildContext context, MenuItem item) {
// Default to first service point if available
final servicePointId = servicePoints?.firstOrNull?.servicePointId ?? 1;
// Set app state
final appState = context.read<AppState>();
appState.setBusinessAndServicePoint(
restaurant.businessId,
servicePointId,
businessName: restaurant.name,
servicePointName: servicePoints?.firstOrNull?.name,
);
Api.setBusinessId(restaurant.businessId);
// Navigate to full menu browse screen
Navigator.of(context).pushReplacementNamed(
'/menu_browse',
arguments: {
'businessId': restaurant.businessId,
'servicePointId': servicePointId,
},
);
}
}
class _MenuItemCard extends StatelessWidget {
final MenuItem item;
final String imageBaseUrl;
final VoidCallback onTap;
const _MenuItemCard({
required this.item,
required this.imageBaseUrl,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 120,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Item image
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
),
child: SizedBox(
height: 80,
width: 120,
child: Image.network(
"$imageBaseUrl/items/${item.itemId}.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$imageBaseUrl/items/${item.itemId}.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Icon(
Icons.restaurant,
color: Theme.of(context).colorScheme.onPrimaryContainer,
size: 32,
),
),
);
},
);
},
),
),
),
// Item details
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
Text(
"\$${item.price.toStringAsFixed(2)}",
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
);
}
}
class _ErrorPane extends StatelessWidget {
final String title;
final String message;
final int? statusCode;
final String? raw;
final VoidCallback onRetry;
const _ErrorPane({
required this.title,
required this.message,
required this.statusCode,
required this.raw,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(18),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Card(
color: Colors.grey.shade900,
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
),
),
const SizedBox(height: 10),
Text(message, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 14),
if (statusCode != null)
Text("HTTP: $statusCode", style: const TextStyle(color: Colors.white70)),
if (raw != null && raw!.trim().isNotEmpty) ...[
const SizedBox(height: 10),
const Text("Raw response:", style: TextStyle(color: Colors.white70)),
const SizedBox(height: 6),
Text(raw!, style: const TextStyle(color: Colors.white54)),
],
const SizedBox(height: 14),
FilledButton(
onPressed: onRetry,
child: const Text("Retry"),
),
],
),
),
),
),
),
);
}
}