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>
718 lines
23 KiB
Dart
718 lines
23 KiB
Dart
// lib/screens/restaurant_select_screen.dart
|
|
|
|
import "package:flutter/material.dart";
|
|
import "package:provider/provider.dart";
|
|
import "package:geolocator/geolocator.dart";
|
|
|
|
import "../app/app_state.dart";
|
|
import "../models/menu_item.dart";
|
|
import "../models/restaurant.dart";
|
|
import "../models/service_point.dart";
|
|
import "../services/api.dart";
|
|
import "../widgets/rescan_button.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 = _loadRestaurantsWithLocation();
|
|
|
|
// Clear order type when arriving at restaurant select (no beacon = not dine-in)
|
|
// This ensures the table change icon doesn't appear for delivery/takeaway orders
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final appState = context.read<AppState>();
|
|
appState.setOrderType(null);
|
|
});
|
|
}
|
|
|
|
Future<List<Restaurant>> _loadRestaurantsWithLocation() async {
|
|
double? lat;
|
|
double? lng;
|
|
|
|
// Try to get user location for distance-based sorting
|
|
try {
|
|
final permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.always || permission == LocationPermission.whileInUse) {
|
|
final position = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.low,
|
|
timeLimit: Duration(seconds: 5),
|
|
),
|
|
);
|
|
lat = position.latitude;
|
|
lng = position.longitude;
|
|
debugPrint('[RestaurantSelect] Got location: $lat, $lng');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[RestaurantSelect] Location error (continuing without): $e');
|
|
}
|
|
|
|
final raw = await Api.listRestaurantsRaw(lat: lat, lng: lng);
|
|
_debugLastRaw = raw.rawBody;
|
|
_debugLastStatus = raw.statusCode;
|
|
return Api.listRestaurants(lat: lat, lng: lng);
|
|
}
|
|
|
|
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).pushNamed(
|
|
'/menu_browse',
|
|
arguments: {
|
|
'businessId': restaurant.businessId,
|
|
'servicePointId': servicePointId,
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<bool> _onWillPop() async {
|
|
final shouldExit = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text("Exit App?"),
|
|
content: const Text("Are you sure you want to exit?"),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text("Cancel"),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text("Exit"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return shouldExit ?? false;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
final shouldExit = await _onWillPop();
|
|
if (shouldExit && context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.black,
|
|
foregroundColor: Colors.white,
|
|
title: const Text("Nearby Restaurants"),
|
|
elevation: 0,
|
|
actions: const [
|
|
RescanButton(iconColor: Colors.white),
|
|
],
|
|
),
|
|
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 = _loadRestaurantsWithLocation()),
|
|
);
|
|
}
|
|
|
|
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 = _loadRestaurantsWithLocation()),
|
|
);
|
|
}
|
|
|
|
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 card with header image (matches business selector style)
|
|
GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
height: 120,
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
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/${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) {
|
|
// Fallback to logo centered
|
|
return Container(
|
|
color: Colors.grey.shade800,
|
|
child: Center(
|
|
child: Image.network(
|
|
"$imageBaseUrl/logos/${restaurant.businessId}.png",
|
|
width: 60,
|
|
height: 60,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Image.network(
|
|
"$imageBaseUrl/logos/${restaurant.businessId}.jpg",
|
|
width: 60,
|
|
height: 60,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Text(
|
|
restaurant.name.isNotEmpty ? restaurant.name[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(
|
|
restaurant.name,
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// 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).pushNamed(
|
|
'/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"),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|