368 lines
11 KiB
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')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|