glp-companion/lib/features/medication/presentation/widgets/log_injection_dialog.dart

237 lines
8.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/date_time_utils.dart';
import '../../domain/entities/injection_site.dart';
import '../../domain/value_objects/glp1_compound.dart';
import '../controllers/medication_controller.dart';
/// Opens the interactive injection logging dialog.
///
/// Resolves to `true` when the injection was persisted, `false` when saving
/// failed, and `null` when the user dismissed the dialog.
Future<bool?> showLogInjectionDialog(
BuildContext context, {
Glp1Compound? initialCompound,
InjectionSite? initialSite,
}) {
return showDialog<bool>(
context: context,
builder: (context) => LogInjectionDialog(
initialCompound: initialCompound,
initialSite: initialSite,
),
);
}
/// Form for logging a GLP-1 injection: compound, dosage, administration
/// timestamp, and the 12-quadrant rotation site selector.
class LogInjectionDialog extends ConsumerStatefulWidget {
const LogInjectionDialog({
super.key,
this.initialCompound,
this.initialSite,
});
/// Compound pre-selected from the latest logged dose.
final Glp1Compound? initialCompound;
/// Quadrant pre-selected from the rotation policy's suggestion.
final InjectionSite? initialSite;
@override
ConsumerState<LogInjectionDialog> createState() => _LogInjectionDialogState();
}
class _LogInjectionDialogState extends ConsumerState<LogInjectionDialog> {
late Glp1Compound _compound =
widget.initialCompound ?? Glp1Compound.semaglutide;
late InjectionSite _site =
widget.initialSite ?? InjectionSite.abdomenRightUpper;
late DateTime _administeredAt = DateTime.now();
late final TextEditingController _doseController = TextEditingController(
text: (widget.initialCompound ?? Glp1Compound.semaglutide)
.defaultDoseMg
.toString(),
);
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
bool _isSaving = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Log Injection'),
content: SizedBox(
width: 440,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<Glp1Compound>(
initialValue: _compound,
// Long labels ("Tirzepatide (Mounjaro / Zepbound)") must
// ellipsize in the narrow mobile dialog instead of
// overflowing the selected-item row.
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Compound',
border: OutlineInputBorder(),
),
items: [
for (final compound in Glp1Compound.values)
DropdownMenuItem(
value: compound,
child: Text(
'${compound.displayName} (${compound.brandNames})',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (compound) {
if (compound == null) return;
setState(() {
_compound = compound;
_doseController.text = compound.defaultDoseMg.toString();
});
},
),
const SizedBox(height: 16),
TextFormField(
controller: _doseController,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Dosage (mg)',
border: OutlineInputBorder(),
suffixText: 'mg',
),
validator: (value) {
final dose = double.tryParse(value?.trim() ?? '');
if (dose == null || dose <= 0) {
return 'Enter a positive dose in mg';
}
return null;
},
),
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.schedule),
title: const Text('Administered at'),
subtitle: Text(formatDateTime(_administeredAt)),
trailing: const Icon(Icons.edit_calendar_outlined),
onTap: _isSaving ? null : _pickAdministeredAt,
),
const SizedBox(height: 8),
Text(
'Injection site',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
for (final region in InjectionBodyRegion.values) ...[
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
region.displayName,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Colors.grey),
),
),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final site in InjectionSite.values
.where((site) => site.region == region))
ChoiceChip(
label: Text(site.shortLabel),
selected: _site == site,
onSelected: _isSaving
? null
: (_) => setState(() => _site = site),
),
],
),
],
],
),
),
),
),
actions: [
TextButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
onPressed: _isSaving ? null : _save,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check_circle_outline),
label: const Text('Log Injection'),
),
],
);
}
/// Lets the user backdate the injection via sequential date/time pickers.
Future<void> _pickAdministeredAt() async {
final now = DateTime.now();
final pickedDate = await showDatePicker(
context: context,
initialDate: _administeredAt,
firstDate: DateTime(now.year - 1),
lastDate: now,
);
if (pickedDate == null || !mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_administeredAt),
);
if (!mounted) return;
setState(() {
_administeredAt = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime?.hour ?? _administeredAt.hour,
pickedTime?.minute ?? _administeredAt.minute,
);
});
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final doseMg = double.parse(_doseController.text.trim());
setState(() => _isSaving = true);
final saved =
await ref.read(medicationControllerProvider.notifier).logInjection(
compoundName: _compound.displayName,
doseMg: doseMg,
site: _site,
administeredAt: _administeredAt,
);
if (!mounted) return;
Navigator.of(context).pop(saved);
}
@override
void dispose() {
_doseController.dispose();
super.dispose();
}
}