673 lines
22 KiB
Dart
673 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../../core/platform/health_gateway.dart';
|
|
import '../../../../core/utils/date_time_utils.dart';
|
|
import '../../../health_sync/presentation/controllers/biometric_controller.dart';
|
|
import '../../../health_sync/presentation/widgets/log_weight_dialog.dart';
|
|
import '../../domain/entities/food_log_entry.dart';
|
|
import '../../domain/entities/hydration_log_entry.dart';
|
|
import '../../domain/entities/meal_type.dart';
|
|
import '../controllers/nutrition_controller.dart';
|
|
import '../widgets/log_food_dialog.dart';
|
|
|
|
/// GLP-1 nutrition dashboard: daily protein and hydration progress meters,
|
|
/// quick-add water buttons, a meal-grouped food diary, and the weight
|
|
/// tracker card with native health sync / manual-entry fallback.
|
|
class NutritionScreen extends ConsumerWidget {
|
|
const NutritionScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final nutrition = ref.watch(nutritionControllerProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Nutrition & Hydration')),
|
|
body: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 720),
|
|
child: nutrition.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, _) => _ErrorView(
|
|
error: error,
|
|
onRetry: () =>
|
|
ref.read(nutritionControllerProvider.notifier).retry(),
|
|
),
|
|
data: (state) => ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_ProteinCard(state: state),
|
|
const SizedBox(height: 16),
|
|
_HydrationCard(state: state),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: () => showLogFoodDialog(context),
|
|
icon: const Icon(Icons.add_circle_outline),
|
|
label: const Text('Log Food'),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const _WeightTrackerCard(),
|
|
const SizedBox(height: 24),
|
|
_FoodDiary(state: state),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Interactive protein meter: today's total vs. the 100 g daily target plus
|
|
/// calorie/carb/fat roll-ups.
|
|
class _ProteinCard extends StatelessWidget {
|
|
const _ProteinCard({required this.state});
|
|
|
|
final NutritionState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final progress = state.proteinProgress.clamp(0.0, 1.0).toDouble();
|
|
final reachedGoal = state.totalProteinGrams >= state.proteinTargetGrams;
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
// Flexible keeps the title from overflowing narrow phones
|
|
// (and wide-glyph test fonts) when the goal chip is present.
|
|
Flexible(
|
|
child: Text(
|
|
'Daily Protein Target',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
if (reachedGoal)
|
|
Chip(
|
|
label: const Text('Goal met'),
|
|
visualDensity: VisualDensity.compact,
|
|
backgroundColor:
|
|
Theme.of(context).colorScheme.secondaryContainer,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: LinearProgressIndicator(
|
|
value: progress,
|
|
minHeight: 12,
|
|
color: reachedGoal
|
|
? Colors.green
|
|
: Theme.of(context).colorScheme.primary,
|
|
backgroundColor:
|
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${state.totalProteinGrams.toStringAsFixed(1)}g / '
|
|
'${state.proteinTargetGrams.toStringAsFixed(0)}g goal',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_macroSummary(state),
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodyMedium
|
|
?.copyWith(color: Theme.of(context).colorScheme.outline),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// `1,240 kcal · Carbs 40g · Fat 20g` (macro segments only when logged).
|
|
String _macroSummary(NutritionState state) {
|
|
final parts = <String>['${state.totalCalories} kcal'];
|
|
final carbs = state.totalCarbsGrams;
|
|
final fat = state.totalFatGrams;
|
|
if (carbs != null) parts.add('Carbs ${carbs.toStringAsFixed(0)}g');
|
|
if (fat != null) parts.add('Fat ${fat.toStringAsFixed(0)}g');
|
|
return parts.join(' · ');
|
|
}
|
|
}
|
|
|
|
/// Interactive hydration meter with quick-add buttons (+8 / +16 / +24 oz)
|
|
/// and today's fluid log (each entry deletable).
|
|
class _HydrationCard extends ConsumerWidget {
|
|
const _HydrationCard({required this.state});
|
|
|
|
final NutritionState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final progress = state.hydrationProgress.clamp(0.0, 1.0).toDouble();
|
|
final reachedGoal = state.totalWaterOz >= state.hydrationTargetOz;
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Daily Hydration',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 12),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: LinearProgressIndicator(
|
|
value: progress,
|
|
minHeight: 12,
|
|
color: reachedGoal ? Colors.teal : Colors.blue,
|
|
backgroundColor:
|
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${state.totalWaterOz.toStringAsFixed(0)} oz / '
|
|
'${state.hydrationTargetOz.toStringAsFixed(0)} oz goal',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final increment in [8.0, 16.0, 24.0])
|
|
FilledButton.tonalIcon(
|
|
onPressed: () => _quickAdd(ref, increment),
|
|
icon: const Icon(Icons.water_drop, size: 16),
|
|
label: Text('+$increment oz'),
|
|
),
|
|
],
|
|
),
|
|
if (state.hydrationLogs.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
for (final entry in state.hydrationLogs)
|
|
_HydrationTile(
|
|
entry: entry,
|
|
onDelete: () => _confirmDelete(context, ref, entry),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _quickAdd(WidgetRef ref, double ounces) =>
|
|
ref.read(nutritionControllerProvider.notifier).logWater(ounces);
|
|
|
|
Future<void> _confirmDelete(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
HydrationLogEntry entry,
|
|
) async {
|
|
final id = entry.id;
|
|
if (id == null) return;
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete water entry?'),
|
|
content: Text(
|
|
'${entry.volumeOz.toStringAsFixed(0)} oz · '
|
|
'${formatDateTime(entry.timestamp)}',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
|
|
await ref.read(nutritionControllerProvider.notifier).deleteWater(id);
|
|
}
|
|
}
|
|
|
|
/// Single fluid-log row: volume, time, and a delete action.
|
|
class _HydrationTile extends StatelessWidget {
|
|
const _HydrationTile({required this.entry, required this.onDelete});
|
|
|
|
final HydrationLogEntry entry;
|
|
final VoidCallback onDelete;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.water_drop, color: Colors.blue),
|
|
title: Text('${entry.volumeOz.toStringAsFixed(0)} oz'),
|
|
subtitle: Text(formatDateTime(entry.timestamp)),
|
|
trailing: entry.id == null
|
|
? null
|
|
: IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
tooltip: 'Delete',
|
|
onPressed: onDelete,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Weight tracker card: latest weight, 7/30-day trends, and the
|
|
/// "Log Weight / Sync" action.
|
|
///
|
|
/// On Android/iOS the button triggers the native [IHealthGateway] sync
|
|
/// (HealthKit / Health Connect); on Windows/macOS/Web it opens the manual
|
|
/// entry dialog instead, keeping feature parity without unsupported
|
|
/// hardware calls.
|
|
class _WeightTrackerCard extends ConsumerWidget {
|
|
const _WeightTrackerCard();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final biometric = ref.watch(biometricControllerProvider);
|
|
final gateway = ref.watch(healthGatewayProvider);
|
|
final supported =
|
|
gateway.supportsNativeHealth || gateway.supportsCloudSync;
|
|
|
|
// Surface sync/manual outcomes as snackbars whenever the status changes.
|
|
ref.listen(biometricControllerProvider, (previous, next) {
|
|
final message = next.valueOrNull?.statusMessage;
|
|
if (message == null || message == previous?.valueOrNull?.statusMessage) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(SnackBar(content: Text(message)));
|
|
});
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: biometric.when(
|
|
loading: () => const SizedBox(
|
|
height: 120,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (error, _) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_cardTitle(context),
|
|
const SizedBox(height: 8),
|
|
Text('Could not load weight history: $error'),
|
|
const SizedBox(height: 8),
|
|
FilledButton(
|
|
onPressed: () =>
|
|
ref.read(biometricControllerProvider.notifier).retry(),
|
|
child: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
data: (state) => _content(context, ref, state, supported),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _content(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
BiometricState state,
|
|
bool supported,
|
|
) {
|
|
final latest = state.latestWeightLbs;
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_cardTitle(context),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: [
|
|
Text(
|
|
latest == null ? '-- lb' : '${latest.toStringAsFixed(1)} lb',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.headlineSmall
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(width: 8),
|
|
if (state.latest?.timestamp != null)
|
|
Text(
|
|
'as of ${formatDateTime(state.latest!.timestamp)}',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(color: colorScheme.outline),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 16,
|
|
runSpacing: 4,
|
|
children: [
|
|
_trendChip(context, '7-day trend', state.weight7DayDelta),
|
|
_trendChip(context, '30-day trend', state.weight30DayDelta),
|
|
if (state.latestActiveCaloriesBurned != null)
|
|
Text(
|
|
'Active burn: '
|
|
'${state.latestActiveCaloriesBurned!.toStringAsFixed(0)} kcal',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Wrap (instead of Row) so the action buttons stack cleanly on very
|
|
// narrow phones instead of overflowing the card.
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: state.isSyncing
|
|
? null
|
|
: () => _logWeightOrSync(context, ref),
|
|
icon: state.isSyncing
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Icon(
|
|
supported ? Icons.sync : Icons.monitor_weight_outlined,
|
|
size: 18,
|
|
),
|
|
label: Text(state.isSyncing ? 'Syncing...' : 'Log Weight / Sync'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () => showLogWeightDialog(context),
|
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
|
label: const Text('Manual'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _cardTitle(BuildContext context) => Text(
|
|
'Weight Tracker',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
);
|
|
|
|
/// `7-day trend: ▼ 1.2 lb` — green for loss, orange for gain, neutral when
|
|
/// the history does not reach back far enough.
|
|
Widget _trendChip(BuildContext context, String label, double? delta) {
|
|
final color = delta == null
|
|
? Theme.of(context).colorScheme.outline
|
|
: delta < 0
|
|
? Colors.green
|
|
: delta > 0
|
|
? Colors.orange
|
|
: Theme.of(context).colorScheme.outline;
|
|
final text = delta == null
|
|
? '—'
|
|
: '${delta < 0 ? '▼' : delta > 0 ? '▲' : '·'} '
|
|
'${delta.abs().toStringAsFixed(1)} lb';
|
|
|
|
return Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
TextSpan(text: '$label: '),
|
|
TextSpan(
|
|
text: text,
|
|
style: TextStyle(color: color, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
);
|
|
}
|
|
|
|
/// Syncs from the first available source — HealthKit / Health Connect on
|
|
/// mobile, a connected cloud partner (Google Health) on desktop/web — and
|
|
/// surfaces the sync outcome as a snackbar. Falls back to the manual entry
|
|
/// dialog when no source is available or connected (the dedicated
|
|
/// "Manual" button always opens it regardless).
|
|
Future<void> _logWeightOrSync(BuildContext context, WidgetRef ref) async {
|
|
final gateway = ref.read(healthGatewayProvider);
|
|
final canSync =
|
|
gateway.supportsNativeHealth || await gateway.isCloudSyncReady();
|
|
if (!canSync) {
|
|
await showLogWeightDialog(context);
|
|
return;
|
|
}
|
|
await ref.read(biometricControllerProvider.notifier).syncFromHealth();
|
|
if (!context.mounted) return;
|
|
final message =
|
|
ref.read(biometricControllerProvider).valueOrNull?.statusMessage;
|
|
if (message != null) {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Today's food diary grouped by meal slot (breakfast → snack) with
|
|
/// per-entry delete actions.
|
|
class _FoodDiary extends ConsumerWidget {
|
|
const _FoodDiary({required this.state});
|
|
|
|
final NutritionState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final grouped = state.mealsByType;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Food Diary',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (grouped.isEmpty)
|
|
const Card(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Text(
|
|
'No meals logged today yet. Tap "Log Food" to add your '
|
|
'first entry.',
|
|
),
|
|
),
|
|
)
|
|
else
|
|
for (final meal in grouped.entries) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
|
child: Builder(
|
|
builder: (context) {
|
|
final mealProtein = meal.value.fold<double>(
|
|
0,
|
|
(sum, entry) => sum + entry.proteinGrams,
|
|
);
|
|
return Row(
|
|
children: [
|
|
Icon(_mealIcon(meal.key), size: 18),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
meal.key.displayName,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleSmall
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'${mealProtein.toStringAsFixed(0)}g protein',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.outline,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
for (final entry in meal.value)
|
|
_FoodTile(entry: entry, mealType: meal.key),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
IconData _mealIcon(MealType type) {
|
|
switch (type) {
|
|
case MealType.breakfast:
|
|
return Icons.free_breakfast;
|
|
case MealType.lunch:
|
|
return Icons.lunch_dining;
|
|
case MealType.dinner:
|
|
return Icons.dinner_dining;
|
|
case MealType.snack:
|
|
return Icons.cookie;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Single diary row: description, timestamp, macro summary, delete action.
|
|
class _FoodTile extends ConsumerWidget {
|
|
const _FoodTile({required this.entry, required this.mealType});
|
|
|
|
final FoodLogEntry entry;
|
|
final MealType mealType;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return ListTile(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(entry.description),
|
|
subtitle: Text(_subtitle()),
|
|
trailing: entry.id == null
|
|
? null
|
|
: PopupMenuButton<String>(
|
|
onSelected: (value) {
|
|
if (value == 'delete') _confirmDelete(context, ref);
|
|
},
|
|
itemBuilder: (context) => const [
|
|
PopupMenuItem(value: 'delete', child: Text('Delete')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// `Oct 16, 2026 · 8:05 AM · 30g protein · 320 kcal · ...`
|
|
String _subtitle() {
|
|
final parts = <String>[
|
|
formatDateTime(entry.timestamp),
|
|
'${entry.proteinGrams.toStringAsFixed(1)}g protein',
|
|
'${entry.calories} kcal',
|
|
];
|
|
final carbs = entry.carbsGrams;
|
|
final fat = entry.fatGrams;
|
|
if (carbs != null) parts.add('C ${carbs.toStringAsFixed(0)}g');
|
|
if (fat != null) parts.add('F ${fat.toStringAsFixed(0)}g');
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
|
|
final id = entry.id;
|
|
if (id == null) return;
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete meal entry?'),
|
|
content: Text('${entry.description} · ${mealType.displayName}'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
|
|
await ref.read(nutritionControllerProvider.notifier).deleteFood(id);
|
|
}
|
|
}
|
|
|
|
/// Full-screen failure state shown when today's logs could not be loaded.
|
|
class _ErrorView extends StatelessWidget {
|
|
const _ErrorView({required this.error, required this.onRetry});
|
|
|
|
final Object error;
|
|
final VoidCallback onRetry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Could not load your nutrition data.',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text('$error', textAlign: TextAlign.center),
|
|
const SizedBox(height: 16),
|
|
FilledButton.icon(
|
|
onPressed: onRetry,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|