import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/date_time_utils.dart'; import '../../domain/entities/dose_entry.dart'; import '../../domain/entities/injection_site.dart'; import '../../domain/value_objects/glp1_compound.dart'; import '../controllers/medication_controller.dart'; import '../widgets/log_injection_dialog.dart'; /// GLP-1 tracking dashboard. /// /// Renders the pharmacokinetic serum estimate, the least-recently-used /// injection-site suggestion, an interactive logging flow, and the persisted /// injection history. A periodic ticker keeps the decaying serum value fresh /// while the screen is visible. class MedicationScreen extends ConsumerStatefulWidget { const MedicationScreen({super.key}); @override ConsumerState createState() => _MedicationScreenState(); } class _MedicationScreenState extends ConsumerState { Timer? _serumTicker; @override void initState() { super.initState(); // Serum concentration decays continuously; refresh the estimate once a // minute so the displayed mg value tracks the pharmacokinetic model. _serumTicker = Timer.periodic( const Duration(minutes: 1), (_) => ref.read(medicationControllerProvider.notifier).refreshSerumLevel(), ); } @override void dispose() { _serumTicker?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final state = ref.watch(medicationControllerProvider); return Scaffold( appBar: AppBar(title: const Text('Medication Tracker')), body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: _buildBody(context, state), ), ), ); } Widget _buildBody(BuildContext context, MedicationState state) { if (state.isLoading && state.entries.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (state.error != null && state.entries.isEmpty) { return _ErrorView( error: state.error!, onRetry: () => ref.read(medicationControllerProvider.notifier).retry(), ); } return ListView( padding: const EdgeInsets.all(16), children: [ _SerumLevelCard(state: state), const SizedBox(height: 16), _SiteRotationCard(state: state), const SizedBox(height: 16), if (state.error != null) ...[ _InlineErrorBanner(error: state.error!), const SizedBox(height: 16), ], FilledButton.icon( onPressed: () => _logInjection(state), icon: const Icon(Icons.add_circle_outline), label: const Text('Log Injection'), ), const SizedBox(height: 24), if (state.entries.isNotEmpty) ...[ Text( 'Recent Injections', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), for (final entry in state.entries.take(20)) _HistoryTile(entry: entry), ] else const Card( child: Padding( padding: EdgeInsets.all(16), child: Text( 'No injections logged yet. Tap "Log Injection" to start ' 'tracking your doses and site rotation.', ), ), ), ], ); } /// Opens the logging dialog pre-filled with the rotation suggestion and the /// latest compound, then surfaces the outcome. Future _logInjection(MedicationState state) async { final result = await showLogInjectionDialog( context, initialCompound: Glp1Compound.tryParse(state.latestDose?.compoundName), initialSite: state.suggestedSite, ); if (!mounted || result == null) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( content: Text( result ? 'Injection logged. Next reminder scheduled for your next dose.' : 'Could not log the injection. Please try again.', ), backgroundColor: result ? null : Theme.of(context).colorScheme.error, ), ); } } /// Displays the current estimated serum concentration with the active /// compound's half-life context and next dose due time. class _SerumLevelCard extends StatelessWidget { const _SerumLevelCard({required this.state}); final MedicationState state; @override Widget build(BuildContext context) { final latest = state.latestDose; final compound = Glp1Compound.tryParse(latest?.compoundName); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Estimated Active Serum Level', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( '${state.serumLevelMg.toStringAsFixed(3)} mg', style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 4), if (compound != null) Text( '${compound.displayName} · half-life ' '${compound.halfLifeHours.toStringAsFixed(0)} h', ) else if (latest != null) Text(latest.compoundName) else const Text('No injections logged yet.'), Text('Evaluated ${formatDateTime(state.evaluatedAt)}'), if (state.nextDoseDueAt != null) ...[ const SizedBox(height: 8), Text( 'Next dose due ${formatDateTime(state.nextDoseDueAt!)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ], ], ), ), ); } } /// Shows the least-recently-used quadrant recommended for the next /// injection, including how long ago that site was last used. class _SiteRotationCard extends StatelessWidget { const _SiteRotationCard({required this.state}); final MedicationState state; @override Widget build(BuildContext context) { final site = state.suggestedSite; final lastUsed = state.lastUsedAt(site); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Suggested Next Injection Site', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.my_location, color: Colors.teal), title: Text(site.displayName), subtitle: Text( '${site.region.displayName} · ' '${lastUsed == null ? 'never used' : 'last used ${formatRelativeTime(lastUsed)}'}', ), ), ], ), ), ); } } /// A single persisted injection row with an optional delete action. class _HistoryTile extends ConsumerWidget { const _HistoryTile({required this.entry}); final DoseEntry entry; @override Widget build(BuildContext context, WidgetRef ref) { return ListTile( leading: Icon(_regionIcon(entry.injectionSite?.region)), title: Text( '${entry.compoundName} · ${entry.doseMg.toStringAsFixed(2)} mg', ), subtitle: Text( '${formatDateTime(entry.administeredAt)} · ' '${entry.injectionSite?.displayName ?? 'Site not recorded'}', ), trailing: entry.id == null ? null : PopupMenuButton( onSelected: (value) { if (value == 'delete') _confirmDelete(context, ref); }, itemBuilder: (context) => const [ PopupMenuItem(value: 'delete', child: Text('Delete')), ], ), ); } Future _confirmDelete(BuildContext context, WidgetRef ref) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete injection?'), content: Text( '${entry.compoundName} · ${formatDateTime(entry.administeredAt)}', ), 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(medicationControllerProvider.notifier) .deleteInjection(entry.id!); } IconData _regionIcon(InjectionBodyRegion? region) { switch (region) { case InjectionBodyRegion.abdomen: return Icons.album; case InjectionBodyRegion.thigh: return Icons.directions_walk; case InjectionBodyRegion.upperArm: return Icons.accessibility_new; case null: return Icons.help_outline; } } } /// Full-screen failure state shown when no history could be loaded at all. 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 injection history.', 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'), ), ], ), ), ); } } /// Non-blocking warning shown when a write failed while history is visible. class _InlineErrorBanner extends StatelessWidget { const _InlineErrorBanner({required this.error}); final Object error; @override Widget build(BuildContext context) { return Card( color: Theme.of(context).colorScheme.errorContainer, child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ const Icon(Icons.warning_amber_rounded), const SizedBox(width: 8), Expanded(child: Text('$error')), ], ), ), ); } }