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( (ref) => MedicationRepository(ref.watch(appDatabaseProvider)), ); /// Cross-platform local reminder scheduler. /// /// Overridable in tests with a recording fake. final notificationServiceProvider = Provider( (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 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? 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 { 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>? _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 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 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 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 entries) { final lastUsed = {}; 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 _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( (ref) => MedicationController( ref.watch(medicationRepositoryProvider), ref.watch(notificationServiceProvider), ), );