glp-companion/lib/features/medication/presentation/controllers/medication_controller.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),
),
);