glp-companion/lib/features/medication/presentation/screens/medication_screen.dart

368 lines
11 KiB
Dart

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<MedicationScreen> createState() => _MedicationScreenState();
}
class _MedicationScreenState extends ConsumerState<MedicationScreen> {
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<void> _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<String>(
onSelected: (value) {
if (value == 'delete') _confirmDelete(context, ref);
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
);
}
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
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')),
],
),
),
);
}
}