273 lines
8.8 KiB
Dart
273 lines
8.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../../core/database/app_database.dart';
|
|
import '../../../../core/platform/notification_service.dart';
|
|
import '../../data/repositories/medication_repository.dart';
|
|
import '../../domain/entities/dose_entry.dart';
|
|
import '../../domain/entities/injection_site.dart';
|
|
import '../../domain/repositories/medication_repository.dart';
|
|
import '../../domain/usecases/calculate_serum_level.dart';
|
|
import '../../domain/value_objects/glp1_compound.dart';
|
|
|
|
/// Application-wide injection history repository (drift-backed).
|
|
///
|
|
/// Overridable in widget/unit tests with an in-memory fake.
|
|
final medicationRepositoryProvider = Provider<IMedicationRepository>(
|
|
(ref) => MedicationRepository(ref.watch(appDatabaseProvider)),
|
|
);
|
|
|
|
/// Cross-platform local reminder scheduler.
|
|
///
|
|
/// Overridable in tests with a recording fake.
|
|
final notificationServiceProvider = Provider<INotificationService>(
|
|
(ref) => PlatformNotificationService(),
|
|
);
|
|
|
|
/// Immutable snapshot of the medication tracking state.
|
|
class MedicationState {
|
|
const MedicationState({
|
|
required this.entries,
|
|
required this.isLoading,
|
|
required this.evaluatedAt,
|
|
required this.serumLevelMg,
|
|
required this.suggestedSite,
|
|
this.error,
|
|
});
|
|
|
|
/// Initial state while the first history snapshot loads.
|
|
factory MedicationState.initial() {
|
|
final now = DateTime.now();
|
|
return MedicationState(
|
|
entries: const [],
|
|
isLoading: true,
|
|
evaluatedAt: now,
|
|
serumLevelMg: 0,
|
|
suggestedSite: InjectionSite.leastRecentlyUsed(const {}),
|
|
);
|
|
}
|
|
|
|
/// Full dose history, newest first.
|
|
final List<DoseEntry> entries;
|
|
|
|
/// Whether the initial history load is still in flight.
|
|
final bool isLoading;
|
|
|
|
/// Most recent persistence/domain failure, cleared once data arrives.
|
|
final Object? error;
|
|
|
|
/// Timestamp at which [serumLevelMg] was evaluated.
|
|
final DateTime evaluatedAt;
|
|
|
|
/// Estimated active serum concentration (mg) at [evaluatedAt].
|
|
final double serumLevelMg;
|
|
|
|
/// Least-recently-used quadrant recommended for the next injection.
|
|
final InjectionSite suggestedSite;
|
|
|
|
/// Most recent administration, if any.
|
|
DoseEntry? get latestDose => entries.isEmpty ? null : entries.first;
|
|
|
|
/// Last time [site] was actually used, or `null` when never used.
|
|
DateTime? lastUsedAt(InjectionSite site) {
|
|
DateTime? latest;
|
|
for (final entry in entries) {
|
|
if (entry.injectionSite != site) continue;
|
|
final administeredAt = entry.administeredAt;
|
|
if (latest == null || administeredAt.isAfter(latest)) {
|
|
latest = administeredAt;
|
|
}
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
/// When the next dose is due, derived from the latest administration plus
|
|
/// the compound's recommended dosing interval.
|
|
DateTime? get nextDoseDueAt {
|
|
final latest = latestDose;
|
|
if (latest == null) return null;
|
|
final interval =
|
|
Glp1Compound.tryParse(latest.compoundName)?.dosingInterval ??
|
|
const Duration(hours: 24);
|
|
return latest.administeredAt.add(interval);
|
|
}
|
|
|
|
MedicationState copyWith({
|
|
List<DoseEntry>? entries,
|
|
bool? isLoading,
|
|
Object? error,
|
|
DateTime? evaluatedAt,
|
|
double? serumLevelMg,
|
|
InjectionSite? suggestedSite,
|
|
}) {
|
|
return MedicationState(
|
|
entries: entries ?? this.entries,
|
|
isLoading: isLoading ?? this.isLoading,
|
|
error: error ?? this.error,
|
|
evaluatedAt: evaluatedAt ?? this.evaluatedAt,
|
|
serumLevelMg: serumLevelMg ?? this.serumLevelMg,
|
|
suggestedSite: suggestedSite ?? this.suggestedSite,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Orchestrates the medication feature: persists injections, derives the
|
|
/// pharmacokinetic serum level, drives the injection-site rotation policy,
|
|
/// and schedules the next local dose reminder.
|
|
class MedicationController extends StateNotifier<MedicationState> {
|
|
MedicationController(
|
|
this._repository,
|
|
this._notifications, {
|
|
CalculateSerumLevel calculator = const CalculateSerumLevel(),
|
|
}) : _calculator = calculator,
|
|
super(MedicationState.initial()) {
|
|
_subscribe();
|
|
}
|
|
|
|
final IMedicationRepository _repository;
|
|
final INotificationService _notifications;
|
|
final CalculateSerumLevel _calculator;
|
|
StreamSubscription<List<DoseEntry>>? _subscription;
|
|
|
|
/// Re-attaches the history stream (used by the error-state retry action).
|
|
void retry() {
|
|
state = state.copyWith(isLoading: true, error: null);
|
|
_subscribe();
|
|
}
|
|
|
|
/// Recomputes the serum concentration for the current history at [now];
|
|
/// the screen calls this on a periodic ticker so the decay stays fresh.
|
|
void refreshSerumLevel({DateTime? now}) =>
|
|
_reduce(state.entries, now ?? DateTime.now());
|
|
|
|
/// Persists a new injection, refreshes derived state, and schedules the
|
|
/// next dose reminder. Returns whether the injection was stored.
|
|
Future<bool> logInjection({
|
|
required String compoundName,
|
|
required double doseMg,
|
|
required InjectionSite site,
|
|
DateTime? administeredAt,
|
|
String? notes,
|
|
}) async {
|
|
final administered = administeredAt ?? DateTime.now();
|
|
final entry = DoseEntry(
|
|
compoundName: compoundName.trim(),
|
|
doseMg: doseMg,
|
|
administeredAt: administered,
|
|
injectionSite: site,
|
|
notes: notes,
|
|
);
|
|
|
|
try {
|
|
await _repository.saveDoseEntry(entry);
|
|
} catch (error, stackTrace) {
|
|
if (mounted) {
|
|
state = state.copyWith(isLoading: false, error: error);
|
|
}
|
|
debugPrintStack(stackTrace: stackTrace, label: 'saveDoseEntry failed');
|
|
return false;
|
|
}
|
|
|
|
await _scheduleNextReminder(entry);
|
|
return true;
|
|
}
|
|
|
|
/// Deletes an injection by database [id].
|
|
Future<bool> deleteInjection(int id) async {
|
|
try {
|
|
await _repository.deleteDoseEntry(id);
|
|
return true;
|
|
} catch (error, stackTrace) {
|
|
if (mounted) {
|
|
state = state.copyWith(isLoading: false, error: error);
|
|
}
|
|
debugPrintStack(stackTrace: stackTrace, label: 'deleteDoseEntry failed');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void _subscribe() {
|
|
_subscription?.cancel();
|
|
_subscription = _repository.watchDoseEntries().listen(
|
|
(entries) => _reduce(entries, DateTime.now()),
|
|
onError: (Object error, StackTrace stackTrace) {
|
|
debugPrintStack(
|
|
stackTrace: stackTrace,
|
|
label: 'watchDoseEntries failed',
|
|
);
|
|
if (mounted) {
|
|
state = state.copyWith(isLoading: false, error: error);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Folds a new history snapshot into the derived presentation state.
|
|
void _reduce(List<DoseEntry> entries, DateTime at) {
|
|
state = MedicationState(
|
|
entries: entries,
|
|
isLoading: false,
|
|
error: null,
|
|
evaluatedAt: at,
|
|
serumLevelMg: _calculator.evaluate(doses: entries, at: at),
|
|
suggestedSite: _suggestSite(entries),
|
|
);
|
|
}
|
|
|
|
/// Rotation policy wrapper: builds the per-quadrant "last used" map from
|
|
/// the history and delegates to the domain's least-recently-used rule.
|
|
InjectionSite _suggestSite(List<DoseEntry> entries) {
|
|
final lastUsed = <InjectionSite, DateTime>{};
|
|
for (final entry in entries) {
|
|
final site = entry.injectionSite;
|
|
if (site == null) continue;
|
|
final previous = lastUsed[site];
|
|
if (previous == null || entry.administeredAt.isAfter(previous)) {
|
|
lastUsed[site] = entry.administeredAt;
|
|
}
|
|
}
|
|
return InjectionSite.leastRecentlyUsed(lastUsed);
|
|
}
|
|
|
|
/// Schedules the next dose reminder one dosing interval after the entry.
|
|
Future<void> _scheduleNextReminder(DoseEntry entry) async {
|
|
final compound = Glp1Compound.tryParse(entry.compoundName);
|
|
final interval = compound?.dosingInterval ?? const Duration(hours: 24);
|
|
final displayName = compound?.displayName ?? entry.compoundName;
|
|
|
|
try {
|
|
await _notifications.requestPermissions();
|
|
await _notifications.scheduleDoseReminder(
|
|
scheduledAt: entry.administeredAt.add(interval),
|
|
title: '$displayName dose due',
|
|
body: 'Time for your next $displayName injection. '
|
|
'Rotate to a new site: ${_suggestSite(state.entries).displayName}.',
|
|
payload: 'medication:dose_reminder',
|
|
);
|
|
} catch (error, stackTrace) {
|
|
// Reminder scheduling is best-effort: the injection itself already
|
|
// persisted, so a missing notification must not surface as an error.
|
|
debugPrintStack(
|
|
stackTrace: stackTrace,
|
|
label: 'scheduleDoseReminder failed (non-fatal)',
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_subscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
/// Exposes the [MedicationController] to the widget tree.
|
|
final medicationControllerProvider =
|
|
StateNotifierProvider<MedicationController, MedicationState>(
|
|
(ref) => MedicationController(
|
|
ref.watch(medicationRepositoryProvider),
|
|
ref.watch(notificationServiceProvider),
|
|
),
|
|
); |