feat(medication): complete Phase 2 GLP-1 tracking, reminders, and dev bypass
Domain (pure Dart): - Add 12-quadrant InjectionSite anatomical model with a least-recently-used rotation policy, DoseEntry entity, and Glp1Compound value object (Semaglutide 168h, Tirzepatide 120h, Liraglutide 13h) - Add CalculateSerumLevel use case implementing C(t) = sum(D_i * 0.5^((t - t_i) / t_half)) - Define IMedicationRepository contract; retire superseded MedicationDose Data: - Add injection-history CRUD to AppDatabase (watch/get/insert/update/delete/ latest-administered), ordered newest first - Implement drift-backed MedicationRepository with record-to-entity mapping Presentation and local alerts: - Add MedicationController (StateNotifier) managing serum estimation, site rotation suggestions, and best-effort reminder scheduling - Rework MedicationScreen: serum card, suggested-site card, interactive log dialog (compound, dosage, timestamp, quadrant), history with delete, and a 60s decay ticker - Add INotificationService / PlatformNotificationService wrapping flutter_local_notifications (one-shot scheduling on Android/iOS/macOS, graceful no-op on Web/Windows/Linux), Android boot receivers for reminder restoration, and the timezone dependency Development tooling: - Add debug-only "Continue Without Signing In" bypass on the login screen, backed by an in-memory devAuthBypassProvider; the router guard honors the flag and the shell shows a dev-mode banner while active Fixes: - Resolve the Supabase client lazily in SupabaseAuthService so widget tests and pre-initialization builds no longer throw Testing: - 36 new tests covering PK decay math, rotation determinism, repository CRUD on an in-memory drift database, controller behavior with fakes, and the end-to-end dev bypass flow; full suite green (37/37) - BUILD_STATE.md: mark Phase 2 complete and record all verification gates
This commit is contained in:
parent
7b6b646d2c
commit
7682d8317a
|
|
@ -53,3 +53,5 @@ Each feature MUST contain:
|
||||||
flutter analyze
|
flutter analyze
|
||||||
- Run test suites:
|
- Run test suites:
|
||||||
flutter test
|
flutter test
|
||||||
|
|
||||||
|
Before starting any task, read BUILD_STATE.md to determine the current milestone. Upon satisfying all implementation steps and verification commands, update the checkboxes in BUILD_STATE.md before prompting the user.
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Build Execution Ledger
|
||||||
|
|
||||||
|
## Active Target
|
||||||
|
- **Current Milestone:** Phase 3: Nutrition, Protein Target Tracking & HealthGateway Sync
|
||||||
|
- **Status:** Phase 2 Complete
|
||||||
|
|
||||||
|
## Phase Checklist
|
||||||
|
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
||||||
|
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
|
||||||
|
- [ ] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
|
||||||
|
- [ ] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||||||
|
- [ ] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
||||||
|
- [ ] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
||||||
|
|
||||||
|
## Phase 2 Deliverables (2026-10-16)
|
||||||
|
- [x] Domain: `DoseEntry` entity, 12-quadrant `InjectionSite` rotation model, `Glp1Compound` value object (Sema 168h / Tirze 120h / Lira 13h), `IMedicationRepository` contract.
|
||||||
|
- [x] Domain: `CalculateSerumLevel` use case implementing `C(t) = Σ D_i · 0.5^((t − t_i)/t_half)`.
|
||||||
|
- [x] Data: injection-history CRUD on `AppDatabase` + drift-backed `MedicationRepository` (record ↔ entity mapping).
|
||||||
|
- [x] Presentation: `MedicationController` (Riverpod StateNotifier) with serum estimation, least-recently-used site rotation, and reminder scheduling; refactored `MedicationScreen` (serum card, rotation suggestion, interactive log dialog, history with delete).
|
||||||
|
- [x] Platform: `INotificationService` / `PlatformNotificationService` (flutter_local_notifications 17.x; Android/iOS/macOS scheduled reminders, graceful no-op on Web/Windows/Linux); Android boot-receiver manifest entries; `timezone` dependency added.
|
||||||
|
- [x] Regression fix: `SupabaseAuthService` now resolves its client lazily (baseline widget test no longer throws when Supabase is uninitialized).
|
||||||
|
- [x] Tests: 33 new unit/widget-fake tests (PK calculator, rotation policy, repository w/ in-memory drift, controller); baseline smoke test green.
|
||||||
|
|
||||||
|
## Post-Phase-2 Additions
|
||||||
|
- [x] Development auth bypass (2026-10-16): debug-only "Continue Without Signing In" button on the login screen (`kDebugMode`-gated, in-memory `devAuthBypassProvider`) lets reviewers enter the app without a Supabase session; the router guard honors the flag and the shell shows an orange "Development mode - not signed in" banner while active. Covered by `test/features/auth/dev_auth_bypass_test.dart` (3 tests).
|
||||||
|
|
||||||
|
## Active Verification Gates
|
||||||
|
- [x] `flutter pub run build_runner build --delete-conflicting-outputs`
|
||||||
|
- [x] `flutter analyze` (Zero issues)
|
||||||
|
- [x] `flutter test` (37/37 passing)
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Local dose reminders (flutter_local_notifications): allow reboot-time
|
||||||
|
re-scheduling of pending injection reminders and notification display. -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||||
<application
|
<application
|
||||||
android:label="glpcompanion"
|
android:label="glpcompanion"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
@ -30,6 +34,17 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<!-- Restores pending dose reminders after a device reboot. -->
|
||||||
|
<receiver android:exported="false"
|
||||||
|
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
|
<receiver android:exported="false"
|
||||||
|
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
|
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||||
|
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
import '../../../core/network/supabase_status.dart';
|
import '../../../core/network/supabase_status.dart';
|
||||||
import '../../features/auth/presentation/controllers/auth_controller.dart';
|
import '../../features/auth/presentation/controllers/auth_controller.dart';
|
||||||
|
import '../../features/auth/presentation/controllers/dev_auth_bypass.dart';
|
||||||
import '../../features/auth/presentation/screens/login_screen.dart';
|
import '../../features/auth/presentation/screens/login_screen.dart';
|
||||||
import '../../features/medication/presentation/screens/medication_screen.dart';
|
import '../../features/medication/presentation/screens/medication_screen.dart';
|
||||||
import '../../features/nutrition/presentation/screens/nutrition_screen.dart';
|
import '../../features/nutrition/presentation/screens/nutrition_screen.dart';
|
||||||
|
|
@ -18,22 +19,20 @@ const Set<String> _publicPaths = {'/login'};
|
||||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
/// Application router with a reactive authentication guard.
|
/// Application router with a reactive authentication guard and dev bypass hook.
|
||||||
///
|
|
||||||
/// The guard re-evaluates on every GoTrue auth transition (bridged into
|
|
||||||
/// [refreshListenable] below), so a successful magic-link or OAuth round trip
|
|
||||||
/// immediately navigates into the protected shell, and signing out bounces the
|
|
||||||
/// user back to `/login`.
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
final authRefreshNotifier = ValueNotifier<int>(0);
|
final authRefreshNotifier = ValueNotifier<int>(0);
|
||||||
ref.onDispose(authRefreshNotifier.dispose);
|
ref.onDispose(authRefreshNotifier.dispose);
|
||||||
|
|
||||||
|
// Trigger re-evaluation on GoTrue auth transitions or dev bypass toggle
|
||||||
ref.listen(authStateProvider, (_, __) => authRefreshNotifier.value++);
|
ref.listen(authStateProvider, (_, __) => authRefreshNotifier.value++);
|
||||||
|
ref.listen(devAuthBypassProvider, (_, __) => authRefreshNotifier.value++);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
navigatorKey: _rootNavigatorKey,
|
navigatorKey: _rootNavigatorKey,
|
||||||
initialLocation: '/medication',
|
initialLocation: '/medication',
|
||||||
refreshListenable: authRefreshNotifier,
|
refreshListenable: authRefreshNotifier,
|
||||||
redirect: _authGuard,
|
redirect: (context, state) => _authGuard(context, state, ref),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|
@ -67,11 +66,21 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Redirect guard enforcing the passwordless session lifecycle:
|
/// Redirect guard enforcing the passwordless session lifecycle with dev bypass.
|
||||||
|
///
|
||||||
|
/// * Development bypass (debug builds only): `/login` -> `/medication`, all
|
||||||
|
/// other routes pass without a session.
|
||||||
/// * Unauthenticated sessions on a protected route -> `/login`.
|
/// * Unauthenticated sessions on a protected route -> `/login`.
|
||||||
/// * Authenticated sessions on `/login` -> `/medication`.
|
/// * Authenticated sessions on `/login` -> `/medication`.
|
||||||
String? _authGuard(BuildContext context, GoRouterState state) {
|
String? _authGuard(BuildContext context, GoRouterState state, Ref ref) {
|
||||||
|
final isDevBypassed = ref.read(devAuthBypassProvider);
|
||||||
final location = state.matchedLocation;
|
final location = state.matchedLocation;
|
||||||
|
|
||||||
|
if (isDevBypassed) {
|
||||||
|
if (location == '/login') return '/medication';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final isAuthenticated = isSupabaseAvailable &&
|
final isAuthenticated = isSupabaseAvailable &&
|
||||||
Supabase.instance.client.auth.currentSession != null;
|
Supabase.instance.client.auth.currentSession != null;
|
||||||
|
|
||||||
|
|
@ -83,4 +92,3 @@ String? _authGuard(BuildContext context, GoRouterState state) {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class AdaptiveHomeScaffold extends StatelessWidget {
|
import '../../features/auth/presentation/controllers/dev_auth_bypass.dart';
|
||||||
|
|
||||||
|
class AdaptiveHomeScaffold extends ConsumerWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const AdaptiveHomeScaffold({
|
const AdaptiveHomeScaffold({
|
||||||
|
|
@ -36,18 +40,26 @@ class AdaptiveHomeScaffold extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final width = MediaQuery.of(context).size.width;
|
final width = MediaQuery.of(context).size.width;
|
||||||
final isDesktop = width >= 1024;
|
final isDesktop = width >= 1024;
|
||||||
final selectedIndex = _calculateSelectedIndex(context);
|
final selectedIndex = _calculateSelectedIndex(context);
|
||||||
|
// Visual marker while the debug auth bypass is active, so a review
|
||||||
|
// session can never be mistaken for a signed-in one.
|
||||||
|
final showDevBanner = kDebugMode && ref.watch(devAuthBypassProvider);
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Row(
|
body: Column(
|
||||||
|
children: [
|
||||||
|
if (showDevBanner) const _DevAccessBanner(),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
NavigationRail(
|
NavigationRail(
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
onDestinationSelected: (idx) => _onItemTapped(idx, context),
|
onDestinationSelected: (idx) =>
|
||||||
|
_onItemTapped(idx, context),
|
||||||
labelType: NavigationRailLabelType.all,
|
labelType: NavigationRailLabelType.all,
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
|
|
@ -76,11 +88,19 @@ class AdaptiveHomeScaffold extends StatelessWidget {
|
||||||
Expanded(child: child),
|
Expanded(child: child),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: child,
|
body: Column(
|
||||||
|
children: [
|
||||||
|
if (showDevBanner) const _DevAccessBanner(),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
onDestinationSelected: (idx) => _onItemTapped(idx, context),
|
onDestinationSelected: (idx) => _onItemTapped(idx, context),
|
||||||
|
|
@ -110,3 +130,36 @@ class AdaptiveHomeScaffold extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slim status strip shown while the development auth bypass is active.
|
||||||
|
class _DevAccessBanner extends StatelessWidget {
|
||||||
|
const _DevAccessBanner();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.orange,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.bug_report_outlined,
|
||||||
|
size: 16, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Development mode - not signed in',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,70 @@ class AppDatabase extends _$AppDatabase {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 1;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Injection history CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// These helpers intentionally operate on drift records/companions only:
|
||||||
|
// the `core` layer never imports feature domain types, so mapping between
|
||||||
|
// `MedicationDoseRecord` and the medication feature's `DoseEntry` entity
|
||||||
|
// happens in `features/medication/data/repositories/medication_repository.dart`.
|
||||||
|
|
||||||
|
/// Emits the stored dose history, newest first, and re-emits on every write.
|
||||||
|
///
|
||||||
|
/// Ordering uses the administered time (falling back to the scheduled time
|
||||||
|
/// for pending rows) so backdated entries sort chronologically.
|
||||||
|
Stream<List<MedicationDoseRecord>> watchDoseEntries({int limit = 500}) =>
|
||||||
|
(_selectDoseEntries()..limit(limit)).watch();
|
||||||
|
|
||||||
|
/// One-shot snapshot of the dose history, newest first.
|
||||||
|
Future<List<MedicationDoseRecord>> getDoseEntries({int limit = 500}) =>
|
||||||
|
(_selectDoseEntries()..limit(limit)).get();
|
||||||
|
|
||||||
|
/// Inserts a new row and returns the persisted record including its
|
||||||
|
/// auto-incremented primary key.
|
||||||
|
Future<MedicationDoseRecord> insertDoseEntry(
|
||||||
|
MedicationDosesTableCompanion entry,
|
||||||
|
) async {
|
||||||
|
final id = await into(medicationDosesTable).insert(entry);
|
||||||
|
return (select(medicationDosesTable)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partially updates the row identified by [id] (only non-absent companion
|
||||||
|
/// columns are written).
|
||||||
|
Future<void> updateDoseEntry(
|
||||||
|
int id,
|
||||||
|
MedicationDosesTableCompanion updates,
|
||||||
|
) =>
|
||||||
|
(update(medicationDosesTable)..where((t) => t.id.equals(id))).write(
|
||||||
|
updates,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Deletes the row identified by [id]; returns the number of removed rows.
|
||||||
|
Future<int> deleteDoseEntry(int id) =>
|
||||||
|
(delete(medicationDosesTable)..where((t) => t.id.equals(id))).go();
|
||||||
|
|
||||||
|
/// Most recent row that was actually administered, or `null` when no dose
|
||||||
|
/// has been logged yet.
|
||||||
|
Future<MedicationDoseRecord?> latestAdministeredDose() =>
|
||||||
|
(select(medicationDosesTable)
|
||||||
|
..where((t) => t.administeredTime.isNotNull())
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.desc(t.administeredTime),
|
||||||
|
(t) => OrderingTerm.desc(t.id),
|
||||||
|
])
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
/// Shared newest-first ordering for [watchDoseEntries] and [getDoseEntries].
|
||||||
|
SimpleSelectStatement<$MedicationDosesTableTable, MedicationDoseRecord>
|
||||||
|
_selectDoseEntries() => select(medicationDosesTable)
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.desc(t.administeredTime),
|
||||||
|
(t) => OrderingTerm.desc(t.id),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Application-wide database instance; closed when the container is disposed.
|
/// Application-wide database instance; closed when the container is disposed.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tz_data;
|
||||||
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
|
||||||
|
/// Platform-agnostic contract for scheduling local (offline) reminders.
|
||||||
|
///
|
||||||
|
/// Presentation code depends only on this interface; the concrete
|
||||||
|
/// [PlatformNotificationService] wraps `flutter_local_notifications` so the
|
||||||
|
/// package never leaks into feature layers (architecture rule 1/2).
|
||||||
|
abstract class INotificationService {
|
||||||
|
/// Prepares the underlying plugin. Safe to call repeatedly.
|
||||||
|
Future<void> initialize();
|
||||||
|
|
||||||
|
/// Requests OS-level notification permission.
|
||||||
|
///
|
||||||
|
/// Returns `true` when alerts may be shown, `false` when the platform is
|
||||||
|
/// unsupported or the user declined.
|
||||||
|
Future<bool> requestPermissions();
|
||||||
|
|
||||||
|
/// Schedules a one-shot reminder at [scheduledAt] and returns whether the
|
||||||
|
/// platform accepted it. Times in the past are rejected.
|
||||||
|
Future<bool> scheduleDoseReminder({
|
||||||
|
required DateTime scheduledAt,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
int id = kDoseReminderNotificationId,
|
||||||
|
String? payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Cancels the reminder registered under [id].
|
||||||
|
Future<void> cancelReminder(int id);
|
||||||
|
|
||||||
|
/// Cancels every pending reminder.
|
||||||
|
Future<void> cancelAllReminders();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable notification id for the recurring dose reminder slot; re-using one
|
||||||
|
/// id replaces the previous scheduling instead of stacking duplicates.
|
||||||
|
const int kDoseReminderNotificationId = 7401;
|
||||||
|
|
||||||
|
/// `flutter_local_notifications`-backed implementation.
|
||||||
|
///
|
||||||
|
/// Support matrix (graceful degradation per architecture rule 3):
|
||||||
|
/// * **Android / iOS / macOS:** full one-shot scheduling via `zonedSchedule`.
|
||||||
|
/// * **Windows / Linux / Web:** scheduling is a logged no-op - the plugin has
|
||||||
|
/// no scheduler on those targets yet, and the domain keeps functioning
|
||||||
|
/// (in-app schedule view) without notifications.
|
||||||
|
class PlatformNotificationService implements INotificationService {
|
||||||
|
PlatformNotificationService({FlutterLocalNotificationsPlugin? plugin})
|
||||||
|
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
static const String _channelId = 'medication_reminders';
|
||||||
|
static const String _channelName = 'Medication Reminders';
|
||||||
|
static const String _channelDescription =
|
||||||
|
'Dose reminders for GLP-1 injections and site rotation.';
|
||||||
|
|
||||||
|
static const NotificationDetails _notificationDetails = NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
_channelId,
|
||||||
|
_channelName,
|
||||||
|
channelDescription: _channelDescription,
|
||||||
|
importance: Importance.max,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
iOS: DarwinNotificationDetails(),
|
||||||
|
macOS: DarwinNotificationDetails(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final FlutterLocalNotificationsPlugin _plugin;
|
||||||
|
Future<void>? _initialization;
|
||||||
|
|
||||||
|
/// Targets where the plugin can schedule future notifications.
|
||||||
|
bool get _supportsScheduling =>
|
||||||
|
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize() => _initialization ??= _initialize();
|
||||||
|
|
||||||
|
Future<void> _initialize() async {
|
||||||
|
if (!_supportsScheduling) return;
|
||||||
|
|
||||||
|
// Time zones are initialized once; scheduling uses absolute UTC instants,
|
||||||
|
// which the OS converts to the device's local time zone for display.
|
||||||
|
tz_data.initializeTimeZones();
|
||||||
|
|
||||||
|
// Permissions are requested explicitly via [requestPermissions] instead
|
||||||
|
// of implicitly during initialize().
|
||||||
|
const settings = InitializationSettings(
|
||||||
|
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||||
|
iOS: DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
|
requestSoundPermission: false,
|
||||||
|
),
|
||||||
|
macOS: DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
|
requestSoundPermission: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _plugin.initialize(
|
||||||
|
settings,
|
||||||
|
onDidReceiveNotificationResponse: (response) {
|
||||||
|
debugPrint('Notification tapped: ${response.payload}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Notification initialization failed: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestPermissions() async {
|
||||||
|
if (!_supportsScheduling) return false;
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return await _plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestNotificationsPermission() ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
// iOS and macOS share the Darwin permission surface.
|
||||||
|
return await _plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestPermissions(alert: true, badge: true, sound: true) ??
|
||||||
|
false;
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Notification permission request failed: $error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> scheduleDoseReminder({
|
||||||
|
required DateTime scheduledAt,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
int id = kDoseReminderNotificationId,
|
||||||
|
String? payload,
|
||||||
|
}) async {
|
||||||
|
if (!_supportsScheduling) return false;
|
||||||
|
if (!scheduledAt.isAfter(DateTime.now())) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
await _plugin.zonedSchedule(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
// Absolute instants; the tray renders them in device-local time.
|
||||||
|
tz.TZDateTime.from(scheduledAt.toUtc(), tz.UTC),
|
||||||
|
_notificationDetails,
|
||||||
|
uiLocalNotificationDateInterpretation:
|
||||||
|
UILocalNotificationDateInterpretation.absoluteTime,
|
||||||
|
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Dose reminder scheduling failed: $error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelReminder(int id) async {
|
||||||
|
if (!_supportsScheduling) return;
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
await _plugin.cancel(id);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Notification cancel failed: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelAllReminders() async {
|
||||||
|
if (!_supportsScheduling) return;
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
await _plugin.cancelAll();
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Notification cancel-all failed: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Shared, framework-free date helpers.
|
||||||
|
//
|
||||||
|
// The project intentionally avoids `intl` to keep core utilities usable from
|
||||||
|
// pure Dart domain code and tests; these formatters cover the app's display
|
||||||
|
// needs with explicit, stable output.
|
||||||
|
|
||||||
|
const List<String> _monthNames = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Formats [time] as e.g. `Oct 16, 2026 · 2:05 PM`.
|
||||||
|
String formatDateTime(DateTime time) {
|
||||||
|
final local = time.toLocal();
|
||||||
|
final hour12 = local.hour % 12 == 0 ? 12 : local.hour % 12;
|
||||||
|
final minute = local.minute.toString().padLeft(2, '0');
|
||||||
|
final period = local.hour < 12 ? 'AM' : 'PM';
|
||||||
|
return '${_monthNames[local.month - 1]} ${local.day}, ${local.year}'
|
||||||
|
' · $hour12:$minute $period';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a coarse elapsed time relative to [now], e.g. `never`, `3 hours
|
||||||
|
/// ago`, `5 days ago`.
|
||||||
|
String formatRelativeTime(DateTime time, {DateTime? now}) {
|
||||||
|
final reference = now ?? DateTime.now();
|
||||||
|
final elapsed = reference.difference(time);
|
||||||
|
|
||||||
|
if (elapsed.isNegative) {
|
||||||
|
final ahead = time.difference(reference);
|
||||||
|
if (ahead.inDays >= 1) return 'in ${ahead.inDays} day(s)';
|
||||||
|
if (ahead.inHours >= 1) return 'in ${ahead.inHours} hour(s)';
|
||||||
|
return 'in a moment';
|
||||||
|
}
|
||||||
|
if (elapsed.inMinutes < 1) return 'just now';
|
||||||
|
if (elapsed.inHours < 1) return '${elapsed.inMinutes} minute(s) ago';
|
||||||
|
if (elapsed.inDays < 1) return '${elapsed.inHours} hour(s) ago';
|
||||||
|
return '${elapsed.inDays} day(s) ago';
|
||||||
|
}
|
||||||
|
|
@ -31,11 +31,14 @@ String resolveAuthRedirectUrl() {
|
||||||
/// line with the platform's security posture.
|
/// line with the platform's security posture.
|
||||||
class SupabaseAuthService implements IAuthService {
|
class SupabaseAuthService implements IAuthService {
|
||||||
/// Creates the service. Pass [client] in tests to inject a mock; production
|
/// Creates the service. Pass [client] in tests to inject a mock; production
|
||||||
/// code resolves the global Supabase client lazily on first use.
|
/// code resolves the global Supabase client lazily on first use so merely
|
||||||
SupabaseAuthService({SupabaseClient? client})
|
/// constructing the service (e.g. while building the login screen before
|
||||||
: _client = client ?? Supabase.instance.client;
|
/// `Supabase.initialize` completed, or inside widget tests) never throws.
|
||||||
|
SupabaseAuthService({SupabaseClient? client}) : _clientOverride = client;
|
||||||
|
|
||||||
final SupabaseClient _client;
|
final SupabaseClient? _clientOverride;
|
||||||
|
|
||||||
|
SupabaseClient get _client => _clientOverride ?? Supabase.instance.client;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> signInWithGoogle() => _signInWithOAuth(OAuthProvider.google);
|
Future<void> signInWithGoogle() => _signInWithOAuth(OAuthProvider.google);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
/// Development-only authentication bypass flag.
|
||||||
|
///
|
||||||
|
/// When `true`, the router's auth guard treats the session as authenticated,
|
||||||
|
/// letting reviewers exercise the app without a configured Supabase project
|
||||||
|
/// or a federated sign-in round trip.
|
||||||
|
///
|
||||||
|
/// Safety properties:
|
||||||
|
/// * The toggle button is only rendered in debug builds (`kDebugMode`), so
|
||||||
|
/// release/store builds never expose it.
|
||||||
|
/// * State is in-memory only: every cold start re-enables the guard, so a
|
||||||
|
/// bypassed session can never persist into a fresh launch.
|
||||||
|
final devAuthBypassProvider = StateProvider<bool>((ref) => false);
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart' show AuthException;
|
import 'package:supabase_flutter/supabase_flutter.dart' show AuthException;
|
||||||
|
|
||||||
import '../controllers/auth_controller.dart';
|
import '../controllers/auth_controller.dart';
|
||||||
|
import '../controllers/dev_auth_bypass.dart';
|
||||||
|
|
||||||
/// Which authentication flow is currently executing, so the relevant button
|
/// Which authentication flow is currently executing, so the relevant button
|
||||||
/// can render a spinner while the request is in flight.
|
/// can render a spinner while the request is in flight.
|
||||||
|
|
@ -115,6 +117,36 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
)
|
)
|
||||||
: const Text('Send Magic Link'),
|
: const Text('Send Magic Link'),
|
||||||
),
|
),
|
||||||
|
if (kDebugMode) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Divider()),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: Text('DEV'),
|
||||||
|
),
|
||||||
|
Expanded(child: Divider()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _continueInDevMode,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
|
label: const Text('Continue Without Signing In'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Development build only: skips authentication so the '
|
||||||
|
'app can be reviewed without a Supabase project. Not '
|
||||||
|
'available in release builds.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -177,6 +209,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dev-only escape hatch: flips the bypass flag so the router's auth guard
|
||||||
|
/// admits the session without a Supabase round trip. The guard re-evaluates
|
||||||
|
/// through `refreshListenable`, which navigates away from `/login`.
|
||||||
|
void _continueInDevMode() {
|
||||||
|
ref.read(devAuthBypassProvider.notifier).state = true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendMagicLink() async {
|
Future<void> _sendMagicLink() async {
|
||||||
final email = _emailController.text.trim();
|
final email = _emailController.text.trim();
|
||||||
if (!_emailPattern.hasMatch(email)) {
|
if (!_emailPattern.hasMatch(email)) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../../../../core/database/app_database.dart';
|
||||||
|
import '../../domain/entities/dose_entry.dart';
|
||||||
|
import '../../domain/entities/injection_site.dart';
|
||||||
|
import '../../domain/repositories/medication_repository.dart';
|
||||||
|
|
||||||
|
/// Drift-backed implementation of [IMedicationRepository].
|
||||||
|
///
|
||||||
|
/// Owns the translation between persistence records
|
||||||
|
/// ([MedicationDoseRecord]) and the pure-Dart [DoseEntry] domain entity. The
|
||||||
|
/// rotation quadrant is persisted as its stable enum name string so the
|
||||||
|
/// database schema stays decoupled from domain types.
|
||||||
|
class MedicationRepository implements IMedicationRepository {
|
||||||
|
MedicationRepository(this._database);
|
||||||
|
|
||||||
|
final AppDatabase _database;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<DoseEntry>> watchDoseEntries({int limit = 500}) => _database
|
||||||
|
.watchDoseEntries(limit: limit)
|
||||||
|
.map((records) => records.map(_mapRecord).toList(growable: false));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DoseEntry>> getDoseEntries({int limit = 500}) async =>
|
||||||
|
(await _database.getDoseEntries(limit: limit))
|
||||||
|
.map(_mapRecord)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DoseEntry> saveDoseEntry(DoseEntry entry) async {
|
||||||
|
final companion = _toCompanion(entry);
|
||||||
|
|
||||||
|
final MedicationDoseRecord record;
|
||||||
|
if (entry.id == null) {
|
||||||
|
record = await _database.insertDoseEntry(companion);
|
||||||
|
} else {
|
||||||
|
await _database.updateDoseEntry(entry.id!, companion);
|
||||||
|
record = await (_database.select(_database.medicationDosesTable)
|
||||||
|
..where((t) => t.id.equals(entry.id!)))
|
||||||
|
.getSingle();
|
||||||
|
}
|
||||||
|
return _mapRecord(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteDoseEntry(int id) => _database.deleteDoseEntry(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DoseEntry?> getLatestAdministeredDose() async {
|
||||||
|
final record = await _database.latestAdministeredDose();
|
||||||
|
return record == null ? null : _mapRecord(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a persistence record into the domain entity.
|
||||||
|
DoseEntry _mapRecord(MedicationDoseRecord record) => DoseEntry(
|
||||||
|
id: record.id,
|
||||||
|
compoundName: record.compoundName,
|
||||||
|
doseMg: record.doseMg,
|
||||||
|
administeredAt: record.administeredTime ?? record.scheduledTime,
|
||||||
|
scheduledAt: record.scheduledTime,
|
||||||
|
injectionSite: InjectionSite.tryParse(record.injectionSite),
|
||||||
|
notes: record.notes,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Converts a domain entity into an insert/update companion.
|
||||||
|
MedicationDosesTableCompanion _toCompanion(DoseEntry entry) =>
|
||||||
|
MedicationDosesTableCompanion(
|
||||||
|
id: entry.id == null ? const Value.absent() : Value(entry.id!),
|
||||||
|
compoundName: Value(entry.compoundName),
|
||||||
|
doseMg: Value(entry.doseMg),
|
||||||
|
scheduledTime: Value(entry.scheduledAt),
|
||||||
|
administeredTime: Value(entry.administeredAt),
|
||||||
|
injectionSite: Value(entry.injectionSite?.persistenceKey),
|
||||||
|
notes: Value(entry.notes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'injection_site.dart';
|
||||||
|
|
||||||
|
/// Immutable record of a single GLP-1 administration event.
|
||||||
|
///
|
||||||
|
/// The entity is pure Dart (no framework imports) so it can be shared by the
|
||||||
|
/// pharmacokinetic use cases, the drift-backed repository, and the Riverpod
|
||||||
|
/// presentation layer without leaking infrastructure concerns into the
|
||||||
|
/// domain.
|
||||||
|
class DoseEntry {
|
||||||
|
DoseEntry({
|
||||||
|
this.id,
|
||||||
|
required this.compoundName,
|
||||||
|
required this.doseMg,
|
||||||
|
required this.administeredAt,
|
||||||
|
DateTime? scheduledAt,
|
||||||
|
this.injectionSite,
|
||||||
|
this.notes,
|
||||||
|
}) : scheduledAt = scheduledAt ?? administeredAt;
|
||||||
|
|
||||||
|
/// Database primary key; `null` until the entry has been persisted.
|
||||||
|
final int? id;
|
||||||
|
|
||||||
|
/// Commercial or compounded agonist name (e.g. `Tirzepatide`).
|
||||||
|
final String compoundName;
|
||||||
|
|
||||||
|
/// Total injected quantity in milligrams.
|
||||||
|
final double doseMg;
|
||||||
|
|
||||||
|
/// When the dose was actually administered. Drives the pharmacokinetic
|
||||||
|
/// decay calculation (`t_i` in C(t)).
|
||||||
|
final DateTime administeredAt;
|
||||||
|
|
||||||
|
/// Titration-schedule anchor the dose was prescribed for. Defaults to
|
||||||
|
/// [administeredAt] for immediately logged injections.
|
||||||
|
final DateTime scheduledAt;
|
||||||
|
|
||||||
|
/// Rotation quadrant used for this injection; `null` for legacy rows that
|
||||||
|
/// predate site tracking.
|
||||||
|
final InjectionSite? injectionSite;
|
||||||
|
|
||||||
|
/// Free-form clinician/patient notes.
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
/// Whether a rotation quadrant was recorded.
|
||||||
|
bool get hasInjectionSite => injectionSite != null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
/// Anatomical regions targeted by subcutaneous GLP-1/GIP injections.
|
||||||
|
enum InjectionBodyRegion {
|
||||||
|
abdomen('Abdomen'),
|
||||||
|
thigh('Thigh'),
|
||||||
|
upperArm('Upper Arm');
|
||||||
|
|
||||||
|
const InjectionBodyRegion(this.displayName);
|
||||||
|
|
||||||
|
final String displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 12-quadrant anatomical rotation matrix used to prevent
|
||||||
|
/// lipohypertrophy and bruising at injection sites.
|
||||||
|
///
|
||||||
|
/// Quadrants are grouped into three regions of four sites each:
|
||||||
|
/// * **Abdomen (4):** Right/Left x Upper/Lower
|
||||||
|
/// * **Thighs (4):** Right/Left x Anterior/Lateral
|
||||||
|
/// * **Upper arms (4):** Right/Left x Tricep/Lateral
|
||||||
|
///
|
||||||
|
/// Enum value names double as stable persistence identifiers (stored as text
|
||||||
|
/// in the drift schema), so renaming a value is a breaking migration - add
|
||||||
|
/// new quadrants instead of editing existing ones.
|
||||||
|
enum InjectionSite {
|
||||||
|
abdomenRightUpper(
|
||||||
|
'Right Upper Abdomen',
|
||||||
|
'Right Upper',
|
||||||
|
InjectionBodyRegion.abdomen,
|
||||||
|
),
|
||||||
|
abdomenRightLower(
|
||||||
|
'Right Lower Abdomen',
|
||||||
|
'Right Lower',
|
||||||
|
InjectionBodyRegion.abdomen,
|
||||||
|
),
|
||||||
|
abdomenLeftUpper(
|
||||||
|
'Left Upper Abdomen',
|
||||||
|
'Left Upper',
|
||||||
|
InjectionBodyRegion.abdomen,
|
||||||
|
),
|
||||||
|
abdomenLeftLower(
|
||||||
|
'Left Lower Abdomen',
|
||||||
|
'Left Lower',
|
||||||
|
InjectionBodyRegion.abdomen,
|
||||||
|
),
|
||||||
|
thighRightAnterior(
|
||||||
|
'Right Anterior Thigh',
|
||||||
|
'Right Anterior',
|
||||||
|
InjectionBodyRegion.thigh,
|
||||||
|
),
|
||||||
|
thighRightLateral(
|
||||||
|
'Right Lateral Thigh',
|
||||||
|
'Right Lateral',
|
||||||
|
InjectionBodyRegion.thigh,
|
||||||
|
),
|
||||||
|
thighLeftAnterior(
|
||||||
|
'Left Anterior Thigh',
|
||||||
|
'Left Anterior',
|
||||||
|
InjectionBodyRegion.thigh,
|
||||||
|
),
|
||||||
|
thighLeftLateral(
|
||||||
|
'Left Lateral Thigh',
|
||||||
|
'Left Lateral',
|
||||||
|
InjectionBodyRegion.thigh,
|
||||||
|
),
|
||||||
|
armRightTricep('Right Tricep', 'Right Tricep', InjectionBodyRegion.upperArm),
|
||||||
|
armRightLateral(
|
||||||
|
'Right Lateral Arm',
|
||||||
|
'Right Lateral',
|
||||||
|
InjectionBodyRegion.upperArm,
|
||||||
|
),
|
||||||
|
armLeftTricep('Left Tricep', 'Left Tricep', InjectionBodyRegion.upperArm),
|
||||||
|
armLeftLateral(
|
||||||
|
'Left Lateral Arm',
|
||||||
|
'Left Lateral',
|
||||||
|
InjectionBodyRegion.upperArm,
|
||||||
|
);
|
||||||
|
|
||||||
|
const InjectionSite(this.displayName, this.shortLabel, this.region);
|
||||||
|
|
||||||
|
/// Human-readable anatomical name (e.g. `Right Upper Abdomen`).
|
||||||
|
final String displayName;
|
||||||
|
|
||||||
|
/// Abbreviated label used inside compact pickers and chips.
|
||||||
|
final String shortLabel;
|
||||||
|
|
||||||
|
/// Region this quadrant belongs to.
|
||||||
|
final InjectionBodyRegion region;
|
||||||
|
|
||||||
|
/// Stable string persisted to (and parsed from) the database.
|
||||||
|
String get persistenceKey => name;
|
||||||
|
|
||||||
|
/// Parses a persisted identifier or display name back into a quadrant.
|
||||||
|
///
|
||||||
|
/// Returns `null` for absent or unrecognized values so legacy rows with an
|
||||||
|
/// unknown site never crash the presentation layer.
|
||||||
|
static InjectionSite? tryParse(String? raw) {
|
||||||
|
if (raw == null || raw.trim().isEmpty) return null;
|
||||||
|
final normalized = raw.trim();
|
||||||
|
for (final site in values) {
|
||||||
|
if (site.name == normalized || site.displayName == normalized) {
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotation policy: returns the least-recently-used quadrant.
|
||||||
|
///
|
||||||
|
/// [lastUsedAt] maps a quadrant to the timestamp of its most recent use;
|
||||||
|
/// quadrants missing from the map (or mapped to `null`) count as never used
|
||||||
|
/// and therefore win over any previously used site. Ties - including ties
|
||||||
|
/// between never-used quadrants - resolve to the earliest entry in the
|
||||||
|
/// enum's declaration order, which keeps suggestions deterministic.
|
||||||
|
static InjectionSite leastRecentlyUsed(
|
||||||
|
Map<InjectionSite, DateTime?> lastUsedAt,
|
||||||
|
) {
|
||||||
|
final never = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
var best = values.first;
|
||||||
|
var bestTime = lastUsedAt[best] ?? never;
|
||||||
|
|
||||||
|
for (final site in values.skip(1)) {
|
||||||
|
final siteTime = lastUsedAt[site] ?? never;
|
||||||
|
// Strict comparison preserves declaration order on ties.
|
||||||
|
if (siteTime.isBefore(bestTime)) {
|
||||||
|
best = site;
|
||||||
|
bestTime = siteTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
enum InjectionQuadrant {
|
|
||||||
abdomenRightUpper,
|
|
||||||
abdomenRightLower,
|
|
||||||
abdomenLeftUpper,
|
|
||||||
abdomenLeftLower,
|
|
||||||
thighRight,
|
|
||||||
thighLeft,
|
|
||||||
armRight,
|
|
||||||
armLeft,
|
|
||||||
}
|
|
||||||
|
|
||||||
class MedicationDose {
|
|
||||||
final String id;
|
|
||||||
final String compoundName;
|
|
||||||
final double doseMg;
|
|
||||||
final DateTime scheduledTime;
|
|
||||||
final DateTime? administeredTime;
|
|
||||||
final InjectionQuadrant? injectionSite;
|
|
||||||
final String? notes;
|
|
||||||
|
|
||||||
const MedicationDose({
|
|
||||||
required this.id,
|
|
||||||
required this.compoundName,
|
|
||||||
required this.doseMg,
|
|
||||||
required this.scheduledTime,
|
|
||||||
this.administeredTime,
|
|
||||||
this.injectionSite,
|
|
||||||
this.notes,
|
|
||||||
});
|
|
||||||
|
|
||||||
bool get isCompleted => administeredTime != null;
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import '../entities/dose_entry.dart';
|
||||||
|
|
||||||
|
/// Persistence contract for the medication bounded context.
|
||||||
|
///
|
||||||
|
/// The presentation layer depends only on this interface; the drift-backed
|
||||||
|
/// implementation lives in `data/repositories/` so widgets and controllers
|
||||||
|
/// remain testable with in-memory fakes.
|
||||||
|
abstract class IMedicationRepository {
|
||||||
|
/// Emits the full dose history (newest first) whenever it changes.
|
||||||
|
Stream<List<DoseEntry>> watchDoseEntries({int limit = 500});
|
||||||
|
|
||||||
|
/// One-shot snapshot of the dose history (newest first).
|
||||||
|
Future<List<DoseEntry>> getDoseEntries({int limit = 500});
|
||||||
|
|
||||||
|
/// Persists [entry]; when [DoseEntry.id] is `null` a new row is inserted,
|
||||||
|
/// otherwise the existing row is updated. Returns the stored entry with its
|
||||||
|
/// database-assigned identifier.
|
||||||
|
Future<DoseEntry> saveDoseEntry(DoseEntry entry);
|
||||||
|
|
||||||
|
/// Removes the entry with [id], if present.
|
||||||
|
Future<void> deleteDoseEntry(int id);
|
||||||
|
|
||||||
|
/// Most recent actually-administered dose, or `null` on an empty history.
|
||||||
|
Future<DoseEntry?> getLatestAdministeredDose();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import '../entities/dose_entry.dart';
|
||||||
|
import '../value_objects/glp1_compound.dart';
|
||||||
|
|
||||||
|
/// Evaluates the accumulated pharmacokinetic serum concentration of GLP-1
|
||||||
|
/// receptor agonists using the standard single-compartment open decay model:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// C(t) = sum over i of D_i * (0.5) ^ ((t - t_i) / t_half)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Where `D_i` is a dose administered at time `t_i` and `t_half` is the
|
||||||
|
/// compound's elimination half-life (Semaglutide 168h, Tirzepatide 120h,
|
||||||
|
/// Liraglutide 13h). Doses recorded with a future [DoseEntry.administeredAt]
|
||||||
|
/// are ignored, and unrecognized compounds fall back to
|
||||||
|
/// [defaultHalfLifeHours].
|
||||||
|
///
|
||||||
|
/// The use case is pure Dart with no dependencies, so it runs identically in
|
||||||
|
/// the domain layer, widget tests, and unit tests.
|
||||||
|
class CalculateSerumLevel {
|
||||||
|
const CalculateSerumLevel({this.defaultHalfLifeHours = 168});
|
||||||
|
|
||||||
|
/// Half-life assumed for compounds that are not in [Glp1Compound].
|
||||||
|
final double defaultHalfLifeHours;
|
||||||
|
|
||||||
|
/// Convenience entry point mirroring the mathematical notation
|
||||||
|
/// `C(t = [at])`.
|
||||||
|
double call({
|
||||||
|
required Iterable<DoseEntry> doses,
|
||||||
|
required DateTime at,
|
||||||
|
double? halfLifeHours,
|
||||||
|
}) =>
|
||||||
|
evaluate(doses: doses, at: at, halfLifeHours: halfLifeHours);
|
||||||
|
|
||||||
|
/// Computes the total active serum concentration (mg) at [at].
|
||||||
|
///
|
||||||
|
/// [halfLifeHours] optionally forces a single half-life for every dose,
|
||||||
|
/// bypassing per-compound resolution (useful for custom titration studies).
|
||||||
|
double evaluate({
|
||||||
|
required Iterable<DoseEntry> doses,
|
||||||
|
required DateTime at,
|
||||||
|
double? halfLifeHours,
|
||||||
|
}) {
|
||||||
|
var totalMg = 0.0;
|
||||||
|
|
||||||
|
for (final dose in doses) {
|
||||||
|
if (dose.doseMg <= 0) continue;
|
||||||
|
|
||||||
|
final elapsed = at.difference(dose.administeredAt);
|
||||||
|
// Doses scheduled in the future have not entered circulation yet.
|
||||||
|
if (elapsed.isNegative) continue;
|
||||||
|
|
||||||
|
final halfLife = halfLifeHours ?? _halfLifeFor(dose.compoundName);
|
||||||
|
if (halfLife <= 0) continue;
|
||||||
|
|
||||||
|
final elapsedHours = elapsed.inMilliseconds / Duration.millisecondsPerHour;
|
||||||
|
totalMg += dose.doseMg * math.pow(0.5, elapsedHours / halfLife).toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalMg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the elimination half-life for a compound label, falling back to
|
||||||
|
/// [defaultHalfLifeHours] when the name is unknown.
|
||||||
|
double _halfLifeFor(String compoundName) =>
|
||||||
|
Glp1Compound.tryParse(compoundName)?.halfLifeHours ??
|
||||||
|
defaultHalfLifeHours;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/// Supported GLP-1/GIP receptor agonists and their pharmacokinetic constants.
|
||||||
|
///
|
||||||
|
/// Half-lives follow the single-compartment decay values published in the
|
||||||
|
/// prescribing information:
|
||||||
|
/// * Semaglutide (Ozempic / Wegovy): ~168 hours (7 days)
|
||||||
|
/// * Tirzepatide (Mounjaro / Zepbound): ~120 hours (5 days)
|
||||||
|
/// * Liraglutide (Saxenda / Victoza): ~13 hours
|
||||||
|
enum Glp1Compound {
|
||||||
|
semaglutide(
|
||||||
|
displayName: 'Semaglutide',
|
||||||
|
brandNames: 'Ozempic / Wegovy / Rybelsus',
|
||||||
|
halfLifeHours: 168,
|
||||||
|
dosingInterval: Duration(hours: 168),
|
||||||
|
defaultDoseMg: 0.25,
|
||||||
|
aliases: ['semaglutide', 'ozempic', 'wegovy', 'rybelsus'],
|
||||||
|
),
|
||||||
|
tirzepatide(
|
||||||
|
displayName: 'Tirzepatide',
|
||||||
|
brandNames: 'Mounjaro / Zepbound',
|
||||||
|
halfLifeHours: 120,
|
||||||
|
dosingInterval: Duration(hours: 168),
|
||||||
|
defaultDoseMg: 2.5,
|
||||||
|
aliases: ['tirzepatide', 'mounjaro', 'zepbound'],
|
||||||
|
),
|
||||||
|
liraglutide(
|
||||||
|
displayName: 'Liraglutide',
|
||||||
|
brandNames: 'Saxenda / Victoza',
|
||||||
|
halfLifeHours: 13,
|
||||||
|
dosingInterval: Duration(hours: 24),
|
||||||
|
defaultDoseMg: 0.6,
|
||||||
|
aliases: ['liraglutide', 'saxenda', 'victoza'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const Glp1Compound({
|
||||||
|
required this.displayName,
|
||||||
|
required this.brandNames,
|
||||||
|
required this.halfLifeHours,
|
||||||
|
required this.dosingInterval,
|
||||||
|
required this.defaultDoseMg,
|
||||||
|
required this.aliases,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Generic compound name (e.g. `Tirzepatide`).
|
||||||
|
final String displayName;
|
||||||
|
|
||||||
|
/// Commercial brand names surfaced in the logging UI.
|
||||||
|
final String brandNames;
|
||||||
|
|
||||||
|
/// Elimination half-life `t_1/2` in hours, used by the pharmacokinetic
|
||||||
|
/// serum-level calculator.
|
||||||
|
final double halfLifeHours;
|
||||||
|
|
||||||
|
/// Recommended interval between administrations (dose reminder cadence).
|
||||||
|
final Duration dosingInterval;
|
||||||
|
|
||||||
|
/// Titration starting dose used to pre-fill the logging form.
|
||||||
|
final double defaultDoseMg;
|
||||||
|
|
||||||
|
/// Lowercase names recognized when parsing free-text compound labels.
|
||||||
|
final List<String> aliases;
|
||||||
|
|
||||||
|
/// Resolves a compound from a user- or database-supplied name.
|
||||||
|
///
|
||||||
|
/// Matching is case-insensitive and accepts generic names, brand names, and
|
||||||
|
/// compound strings that merely contain an alias (e.g. `Semaglutide
|
||||||
|
/// (compounded)`). Returns `null` for unrecognized compounds so callers can
|
||||||
|
/// fall back to a default half-life.
|
||||||
|
static Glp1Compound? tryParse(String? raw) {
|
||||||
|
if (raw == null || raw.trim().isEmpty) return null;
|
||||||
|
final normalized = raw.toLowerCase();
|
||||||
|
|
||||||
|
for (final compound in values) {
|
||||||
|
if (compound.aliases.contains(normalized)) return compound;
|
||||||
|
}
|
||||||
|
for (final compound in values) {
|
||||||
|
if (compound.aliases.any(normalized.contains)) return compound;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -1,50 +1,366 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'dart:async';
|
||||||
|
|
||||||
class MedicationScreen extends StatelessWidget {
|
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});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final state = ref.watch(medicationControllerProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Medication Schedule')),
|
appBar: AppBar(title: const Text('Medication Tracker')),
|
||||||
body: ListView(
|
body: Center(
|
||||||
padding: const EdgeInsets.all(16.0),
|
child: ConstrainedBox(
|
||||||
children: [
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
Card(
|
child: _buildBody(context, state),
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Next Scheduled Dose',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
const Text('Tirzepatide - 5.0 mg'),
|
);
|
||||||
const Text('Scheduled: Monday at 08:00 AM'),
|
}
|
||||||
|
|
||||||
|
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),
|
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(
|
FilledButton.icon(
|
||||||
onPressed: () {},
|
onPressed: () => _logInjection(state),
|
||||||
icon: const Icon(Icons.check_circle_outline),
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
label: const Text('Log Injection Now'),
|
label: const Text('Log Injection'),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 24),
|
||||||
),
|
if (state.entries.isNotEmpty) ...[
|
||||||
),
|
Text(
|
||||||
),
|
'Recent Injections',
|
||||||
const SizedBox(height: 16),
|
style: Theme.of(context)
|
||||||
const Text(
|
.textTheme
|
||||||
'Recommended Injection Site Rotation',
|
.titleMedium
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const ListTile(
|
for (final entry in state.entries.take(20)) _HistoryTile(entry: entry),
|
||||||
leading: Icon(Icons.my_location, color: Colors.teal),
|
] else
|
||||||
title: Text('Left Lower Abdomen'),
|
const Card(
|
||||||
subtitle: Text('Last used 28 days ago'),
|
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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
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,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Compound',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
for (final compound in Glp1Compound.values)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: compound,
|
||||||
|
child: Text(
|
||||||
|
'${compound.displayName} (${compound.brandNames})',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1059,7 +1059,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.12"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: timezone
|
name: timezone
|
||||||
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ dependencies:
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
path_provider: ^2.1.6
|
path_provider: ^2.1.6
|
||||||
sqlite3: ^3.4.0
|
sqlite3: ^3.4.0
|
||||||
|
# Scheduling support for flutter_local_notifications (zonedSchedule).
|
||||||
|
timezone: ^0.9.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:glp1_companion/core/platform/notification_service.dart';
|
||||||
|
import 'package:glp1_companion/features/auth/presentation/controllers/dev_auth_bypass.dart';
|
||||||
|
import 'package:glp1_companion/features/auth/presentation/screens/login_screen.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/entities/dose_entry.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/repositories/medication_repository.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/presentation/controllers/medication_controller.dart';
|
||||||
|
import 'package:glp1_companion/main.dart';
|
||||||
|
|
||||||
|
/// Empty history so the medication shell renders without touching drift.
|
||||||
|
class _EmptyMedicationRepository implements IMedicationRepository {
|
||||||
|
@override
|
||||||
|
Stream<List<DoseEntry>> watchDoseEntries({int limit = 500}) =>
|
||||||
|
Stream.value(const []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DoseEntry>> getDoseEntries({int limit = 500}) async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DoseEntry> saveDoseEntry(DoseEntry entry) async => entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteDoseEntry(int id) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DoseEntry?> getLatestAdministeredDose() async => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoopNotificationService implements INotificationService {
|
||||||
|
@override
|
||||||
|
Future<void> initialize() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestPermissions() async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> scheduleDoseReminder({
|
||||||
|
required DateTime scheduledAt,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
int id = kDoseReminderNotificationId,
|
||||||
|
String? payload,
|
||||||
|
}) async =>
|
||||||
|
false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelReminder(int id) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelAllReminders() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildApp() => ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
medicationRepositoryProvider
|
||||||
|
.overrideWithValue(_EmptyMedicationRepository()),
|
||||||
|
notificationServiceProvider
|
||||||
|
.overrideWithValue(_NoopNotificationService()),
|
||||||
|
],
|
||||||
|
child: const GLP1CompanionApp(),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('unauthenticated sessions land on the login screen', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(LoginScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dev bypass button skips sign-in and opens the medication shell',
|
||||||
|
(tester) async {
|
||||||
|
// A tall phone-like viewport keeps the whole login column on-screen.
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Debug builds expose the bypass affordance on the login screen.
|
||||||
|
final bypassButton = find.text('Continue Without Signing In');
|
||||||
|
expect(bypassButton, findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(bypassButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The guard admits the session and routes into the protected shell.
|
||||||
|
expect(find.byType(LoginScreen), findsNothing);
|
||||||
|
expect(find.text('Medication Tracker'), findsOneWidget);
|
||||||
|
// The dev banner marks the session as not signed in.
|
||||||
|
expect(find.text('Development mode - not signed in'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('bypass resets to disabled on every cold start', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const ProviderScope(child: SizedBox()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(SizedBox)),
|
||||||
|
);
|
||||||
|
expect(container.read(devAuthBypassProvider), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:glp1_companion/core/database/app_database.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/data/repositories/medication_repository.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/entities/dose_entry.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/entities/injection_site.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase database;
|
||||||
|
late MedicationRepository repository;
|
||||||
|
|
||||||
|
final administeredAt = DateTime(2026, 10, 16, 8);
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
database = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
repository = MedicationRepository(database);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => database.close());
|
||||||
|
|
||||||
|
DoseEntry makeEntry({
|
||||||
|
int? id,
|
||||||
|
String compound = 'Tirzepatide',
|
||||||
|
double doseMg = 5,
|
||||||
|
DateTime? administered,
|
||||||
|
InjectionSite? site,
|
||||||
|
}) =>
|
||||||
|
DoseEntry(
|
||||||
|
id: id,
|
||||||
|
compoundName: compound,
|
||||||
|
doseMg: doseMg,
|
||||||
|
administeredAt: administered ?? administeredAt,
|
||||||
|
injectionSite: site,
|
||||||
|
);
|
||||||
|
|
||||||
|
group('MedicationRepository', () {
|
||||||
|
test('saveDoseEntry assigns an id and persists all fields', () async {
|
||||||
|
final saved = await repository.saveDoseEntry(
|
||||||
|
makeEntry(site: InjectionSite.abdomenLeftLower, doseMg: 4.5),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saved.id, isNotNull);
|
||||||
|
|
||||||
|
final fetched = await repository.getDoseEntries();
|
||||||
|
expect(fetched, hasLength(1));
|
||||||
|
expect(fetched.single.id, saved.id);
|
||||||
|
expect(fetched.single.compoundName, 'Tirzepatide');
|
||||||
|
expect(fetched.single.doseMg, 4.5);
|
||||||
|
expect(fetched.single.administeredAt, administeredAt);
|
||||||
|
expect(fetched.single.injectionSite, InjectionSite.abdomenLeftLower);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDoseEntries returns entries newest first', () async {
|
||||||
|
await repository.saveDoseEntry(
|
||||||
|
makeEntry(administered: administeredAt.subtract(const Duration(days: 7))),
|
||||||
|
);
|
||||||
|
await repository.saveDoseEntry(
|
||||||
|
makeEntry(administered: administeredAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
final entries = await repository.getDoseEntries();
|
||||||
|
expect(entries, hasLength(2));
|
||||||
|
expect(entries.first.administeredAt, administeredAt);
|
||||||
|
expect(entries.last.administeredAt,
|
||||||
|
administeredAt.subtract(const Duration(days: 7)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchDoseEntries emits the current history on subscription',
|
||||||
|
() async {
|
||||||
|
await repository.saveDoseEntry(makeEntry());
|
||||||
|
|
||||||
|
final first = await repository.watchDoseEntries().first;
|
||||||
|
expect(first, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveDoseEntry updates an existing row when id is present', () async {
|
||||||
|
final saved = await repository.saveDoseEntry(makeEntry());
|
||||||
|
final updated = await repository.saveDoseEntry(
|
||||||
|
makeEntry(id: saved.id, doseMg: 9.5, site: InjectionSite.thighLeftAnterior),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.id, saved.id);
|
||||||
|
final entries = await repository.getDoseEntries();
|
||||||
|
expect(entries, hasLength(1));
|
||||||
|
expect(entries.single.doseMg, 9.5);
|
||||||
|
expect(entries.single.injectionSite, InjectionSite.thighLeftAnterior);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteDoseEntry removes the row', () async {
|
||||||
|
final saved = await repository.saveDoseEntry(makeEntry());
|
||||||
|
await repository.deleteDoseEntry(saved.id!);
|
||||||
|
|
||||||
|
expect(await repository.getDoseEntries(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLatestAdministeredDose ignores an empty history', () async {
|
||||||
|
expect(await repository.getLatestAdministeredDose(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLatestAdministeredDose returns the most recent administration',
|
||||||
|
() async {
|
||||||
|
final older = await repository.saveDoseEntry(
|
||||||
|
makeEntry(administered: administeredAt.subtract(const Duration(days: 7))),
|
||||||
|
);
|
||||||
|
await repository.saveDoseEntry(makeEntry(administered: administeredAt));
|
||||||
|
|
||||||
|
final latest = await repository.getLatestAdministeredDose();
|
||||||
|
expect(latest?.id, isNot(older.id));
|
||||||
|
expect(latest?.administeredAt, administeredAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/entities/dose_entry.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/usecases/calculate_serum_level.dart';
|
||||||
|
|
||||||
|
DoseEntry _dose({
|
||||||
|
required String compound,
|
||||||
|
required double mg,
|
||||||
|
required DateTime administeredAt,
|
||||||
|
}) =>
|
||||||
|
DoseEntry(
|
||||||
|
compoundName: compound,
|
||||||
|
doseMg: mg,
|
||||||
|
administeredAt: administeredAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const calculator = CalculateSerumLevel();
|
||||||
|
final now = DateTime(2026, 10, 16, 12);
|
||||||
|
|
||||||
|
group('CalculateSerumLevel', () {
|
||||||
|
test('empty history yields zero concentration', () {
|
||||||
|
expect(calculator.evaluate(doses: const [], at: now), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fresh dose contributes its full amount', () {
|
||||||
|
final doses = [
|
||||||
|
_dose(compound: 'Semaglutide', mg: 1, administeredAt: now),
|
||||||
|
];
|
||||||
|
expect(calculator.evaluate(doses: doses, at: now), closeTo(1, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dose halves exactly after one half-life', () {
|
||||||
|
final doses = [
|
||||||
|
_dose(
|
||||||
|
compound: 'Semaglutide',
|
||||||
|
mg: 2,
|
||||||
|
administeredAt: now.subtract(const Duration(hours: 168)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(calculator.evaluate(doses: doses, at: now), closeTo(1, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('follows the published half-life per compound', () {
|
||||||
|
// Tirzepatide: 2.5 mg after 120 h (one half-life) -> 1.25 mg.
|
||||||
|
final tirzepatide = [
|
||||||
|
_dose(
|
||||||
|
compound: 'Tirzepatide',
|
||||||
|
mg: 2.5,
|
||||||
|
administeredAt: now.subtract(const Duration(hours: 120)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(calculator.evaluate(doses: tirzepatide, at: now), closeTo(1.25, 1e-9));
|
||||||
|
|
||||||
|
// Liraglutide: 1.8 mg after 13 h (one half-life) -> 0.9 mg.
|
||||||
|
final liraglutide = [
|
||||||
|
_dose(
|
||||||
|
compound: 'Liraglutide',
|
||||||
|
mg: 1.8,
|
||||||
|
administeredAt: now.subtract(const Duration(hours: 13)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(calculator.evaluate(doses: liraglutide, at: now), closeTo(0.9, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accumulates contributions across multiple doses', () {
|
||||||
|
final doses = [
|
||||||
|
_dose(
|
||||||
|
compound: 'Semaglutide',
|
||||||
|
mg: 1,
|
||||||
|
administeredAt: now.subtract(const Duration(hours: 168)),
|
||||||
|
),
|
||||||
|
_dose(
|
||||||
|
compound: 'Semaglutide',
|
||||||
|
mg: 1,
|
||||||
|
administeredAt: now.subtract(const Duration(hours: 336)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
// 1 * 0.5 + 1 * 0.25 = 0.75 mg.
|
||||||
|
expect(calculator.evaluate(doses: doses, at: now), closeTo(0.75, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores doses timestamped in the future', () {
|
||||||
|
final doses = [
|
||||||
|
_dose(compound: 'Semaglutide', mg: 1, administeredAt: now),
|
||||||
|
_dose(
|
||||||
|
compound: 'Semaglutide',
|
||||||
|
mg: 5,
|
||||||
|
administeredAt: now.add(const Duration(hours: 24)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(calculator.evaluate(doses: doses, at: now), closeTo(1, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown compounds fall back to the default half-life', () {
|
||||||
|
final doses = [
|
||||||
|
_dose(
|
||||||
|
compound: 'Compounded GLP-1',
|
||||||
|
mg: 4,
|
||||||
|
administeredAt: now.subtract(const Duration(hours: 168)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(calculator.evaluate(doses: doses, at: now), closeTo(2, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('brand names resolve to their compound half-life', () {
|
||||||
|
// "Ozempic" is semaglutide (168 h): 4 mg after 168 h -> 2 mg.
|
||||||
|
final doses = [
|
||||||
|
_dose(
|
||||||
|
compound: 'Ozempic',
|
||||||
|
mg: 4,
|
||||||
|
administeredAt: now.subtract(const Duration(hours: 168)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(calculator.evaluate(doses: doses, at: now), closeTo(2, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('call() mirrors evaluate()', () {
|
||||||
|
final doses = [
|
||||||
|
_dose(compound: 'Tirzepatide', mg: 3, administeredAt: now),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
calculator(doses: doses, at: now),
|
||||||
|
calculator.evaluate(doses: doses, at: now),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('halfLifeHours override forces a single half-life', () {
|
||||||
|
final doses = [
|
||||||
|
_dose(compound: 'Tirzepatide', mg: 2, administeredAt: now),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
calculator.evaluate(
|
||||||
|
doses: doses,
|
||||||
|
at: now.add(const Duration(hours: 20)),
|
||||||
|
halfLifeHours: 20,
|
||||||
|
),
|
||||||
|
closeTo(1, 1e-9),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/entities/injection_site.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/value_objects/glp1_compound.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('InjectionSite model', () {
|
||||||
|
test('exposes exactly twelve anatomical quadrants', () {
|
||||||
|
expect(InjectionSite.values, hasLength(12));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quadrants span three regions with four sites each', () {
|
||||||
|
for (final region in InjectionBodyRegion.values) {
|
||||||
|
expect(
|
||||||
|
InjectionSite.values.where((site) => site.region == region),
|
||||||
|
hasLength(4),
|
||||||
|
reason: 'region ${region.name} should own four quadrants',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tryParse round-trips the persistence key', () {
|
||||||
|
for (final site in InjectionSite.values) {
|
||||||
|
expect(InjectionSite.tryParse(site.persistenceKey), site);
|
||||||
|
expect(InjectionSite.tryParse(site.displayName), site);
|
||||||
|
}
|
||||||
|
expect(InjectionSite.tryParse(null), isNull);
|
||||||
|
expect(InjectionSite.tryParse(' '), isNull);
|
||||||
|
expect(InjectionSite.tryParse('unknown-quadrant'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('InjectionSite.leastRecentlyUsed', () {
|
||||||
|
final now = DateTime(2026, 10, 16);
|
||||||
|
|
||||||
|
test('never-used quadrants win over used ones', () {
|
||||||
|
final lastUsed = {
|
||||||
|
InjectionSite.abdomenRightUpper: now,
|
||||||
|
InjectionSite.thighLeftLateral: now.subtract(const Duration(days: 90)),
|
||||||
|
};
|
||||||
|
final suggestion = InjectionSite.leastRecentlyUsed(lastUsed);
|
||||||
|
expect(
|
||||||
|
lastUsed.containsKey(suggestion),
|
||||||
|
isFalse,
|
||||||
|
reason: 'suggested site must be one that was never used',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with full history the oldest site is chosen', () {
|
||||||
|
final lastUsed = {
|
||||||
|
for (var i = 0; i < InjectionSite.values.length; i++)
|
||||||
|
InjectionSite.values[i]: now.subtract(Duration(days: i)),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
InjectionSite.leastRecentlyUsed(lastUsed),
|
||||||
|
InjectionSite.values.last,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ties resolve deterministically to declaration order', () {
|
||||||
|
final lastUsed = {
|
||||||
|
for (final site in InjectionSite.values) site: now,
|
||||||
|
};
|
||||||
|
expect(InjectionSite.leastRecentlyUsed(lastUsed), InjectionSite.values.first);
|
||||||
|
|
||||||
|
expect(InjectionSite.leastRecentlyUsed(const {}), InjectionSite.values.first);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Glp1Compound', () {
|
||||||
|
test('models the three supported agonists with published half-lives', () {
|
||||||
|
expect(Glp1Compound.semaglutide.halfLifeHours, 168);
|
||||||
|
expect(Glp1Compound.tirzepatide.halfLifeHours, 120);
|
||||||
|
expect(Glp1Compound.liraglutide.halfLifeHours, 13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tryParse resolves generic names, brands, and free text', () {
|
||||||
|
expect(Glp1Compound.tryParse('semaglutide'), Glp1Compound.semaglutide);
|
||||||
|
expect(Glp1Compound.tryParse('OZEMPIC'), Glp1Compound.semaglutide);
|
||||||
|
expect(Glp1Compound.tryParse('Mounjaro'), Glp1Compound.tirzepatide);
|
||||||
|
expect(Glp1Compound.tryParse('Zepbound'), Glp1Compound.tirzepatide);
|
||||||
|
expect(Glp1Compound.tryParse('Saxenda'), Glp1Compound.liraglutide);
|
||||||
|
expect(
|
||||||
|
Glp1Compound.tryParse('Semaglutide (compounded)'),
|
||||||
|
Glp1Compound.semaglutide,
|
||||||
|
);
|
||||||
|
expect(Glp1Compound.tryParse('unknown'), isNull);
|
||||||
|
expect(Glp1Compound.tryParse(null), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:glp1_companion/core/platform/notification_service.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/entities/dose_entry.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/entities/injection_site.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/repositories/medication_repository.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/domain/value_objects/glp1_compound.dart';
|
||||||
|
import 'package:glp1_companion/features/medication/presentation/controllers/medication_controller.dart';
|
||||||
|
|
||||||
|
/// In-memory [IMedicationRepository] that broadcasts every mutation.
|
||||||
|
class _FakeMedicationRepository implements IMedicationRepository {
|
||||||
|
final List<DoseEntry> entries = [];
|
||||||
|
final StreamController<List<DoseEntry>> _changes =
|
||||||
|
StreamController<List<DoseEntry>>.broadcast(sync: true);
|
||||||
|
int _nextId = 1;
|
||||||
|
bool failNextSave = false;
|
||||||
|
|
||||||
|
void seed(List<DoseEntry> seeded) {
|
||||||
|
entries.addAll(seeded);
|
||||||
|
_emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emit() => _changes.add(List.unmodifiable(entries));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<DoseEntry>> watchDoseEntries({int limit = 500}) => _changes.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DoseEntry>> getDoseEntries({int limit = 500}) async =>
|
||||||
|
List.unmodifiable(entries);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DoseEntry> saveDoseEntry(DoseEntry entry) async {
|
||||||
|
if (failNextSave) {
|
||||||
|
failNextSave = false;
|
||||||
|
throw StateError('write failed');
|
||||||
|
}
|
||||||
|
final stored = DoseEntry(
|
||||||
|
id: entry.id ?? _nextId++,
|
||||||
|
compoundName: entry.compoundName,
|
||||||
|
doseMg: entry.doseMg,
|
||||||
|
administeredAt: entry.administeredAt,
|
||||||
|
scheduledAt: entry.scheduledAt,
|
||||||
|
injectionSite: entry.injectionSite,
|
||||||
|
notes: entry.notes,
|
||||||
|
);
|
||||||
|
final index = entries.indexWhere((e) => e.id == stored.id);
|
||||||
|
if (index == -1) {
|
||||||
|
entries.insert(0, stored);
|
||||||
|
} else {
|
||||||
|
entries[index] = stored;
|
||||||
|
}
|
||||||
|
_emit();
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteDoseEntry(int id) async {
|
||||||
|
entries.removeWhere((entry) => entry.id == id);
|
||||||
|
_emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DoseEntry?> getLatestAdministeredDose() async =>
|
||||||
|
entries.isEmpty ? null : entries.first;
|
||||||
|
|
||||||
|
Future<void> close() => _changes.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records scheduling calls instead of touching platform channels.
|
||||||
|
class _RecordingNotificationService implements INotificationService {
|
||||||
|
final List<DateTime> scheduledTimes = [];
|
||||||
|
int permissionRequests = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestPermissions() async {
|
||||||
|
permissionRequests++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> scheduleDoseReminder({
|
||||||
|
required DateTime scheduledAt,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
int id = kDoseReminderNotificationId,
|
||||||
|
String? payload,
|
||||||
|
}) async {
|
||||||
|
scheduledTimes.add(scheduledAt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelReminder(int id) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelAllReminders() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
DoseEntry _dose({
|
||||||
|
int? id,
|
||||||
|
String compound = 'Semaglutide',
|
||||||
|
double doseMg = 1,
|
||||||
|
required DateTime administeredAt,
|
||||||
|
InjectionSite? site,
|
||||||
|
}) =>
|
||||||
|
DoseEntry(
|
||||||
|
id: id,
|
||||||
|
compoundName: compound,
|
||||||
|
doseMg: doseMg,
|
||||||
|
administeredAt: administeredAt,
|
||||||
|
injectionSite: site,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late _FakeMedicationRepository repository;
|
||||||
|
late _RecordingNotificationService notifications;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = _FakeMedicationRepository();
|
||||||
|
notifications = _RecordingNotificationService();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => repository.close());
|
||||||
|
|
||||||
|
MedicationController buildController() {
|
||||||
|
final controller = MedicationController(repository, notifications);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
group('MedicationController', () {
|
||||||
|
test('starts in a loading state before history arrives', () {
|
||||||
|
final controller = buildController();
|
||||||
|
expect(controller.state.isLoading, isTrue);
|
||||||
|
expect(controller.state.entries, isEmpty);
|
||||||
|
expect(controller.state.suggestedSite, InjectionSite.values.first);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads history, computes serum level, and suggests a rotation site',
|
||||||
|
() async {
|
||||||
|
final controller = buildController();
|
||||||
|
final oneHalfLifeAgo =
|
||||||
|
DateTime.now().subtract(Glp1Compound.semaglutide.dosingInterval);
|
||||||
|
|
||||||
|
repository.seed([
|
||||||
|
_dose(
|
||||||
|
administeredAt: oneHalfLifeAgo,
|
||||||
|
site: InjectionSite.abdomenRightUpper,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(controller.state.isLoading, isFalse);
|
||||||
|
expect(controller.state.entries, hasLength(1));
|
||||||
|
// 1 mg after one semaglutide half-life decays to ~0.5 mg.
|
||||||
|
expect(controller.state.serumLevelMg, closeTo(0.5, 0.01));
|
||||||
|
expect(
|
||||||
|
controller.state.suggestedSite,
|
||||||
|
isNot(InjectionSite.abdomenRightUpper),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
controller.state.suggestedSite,
|
||||||
|
InjectionSite.leastRecentlyUsed({
|
||||||
|
InjectionSite.abdomenRightUpper: oneHalfLifeAgo,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextDoseDueAt follows the compound dosing interval', () async {
|
||||||
|
final controller = buildController();
|
||||||
|
final administeredAt = DateTime.now().subtract(const Duration(days: 1));
|
||||||
|
|
||||||
|
repository.seed([
|
||||||
|
_dose(
|
||||||
|
compound: 'Liraglutide',
|
||||||
|
administeredAt: administeredAt,
|
||||||
|
site: InjectionSite.thighRightLateral,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
controller.state.nextDoseDueAt,
|
||||||
|
administeredAt.add(Glp1Compound.liraglutide.dosingInterval),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logInjection persists the entry and schedules the next reminder',
|
||||||
|
() async {
|
||||||
|
final controller = buildController();
|
||||||
|
final administeredAt = DateTime(2026, 10, 16, 8);
|
||||||
|
|
||||||
|
final saved = await controller.logInjection(
|
||||||
|
compoundName: 'Tirzepatide',
|
||||||
|
doseMg: 2.5,
|
||||||
|
site: InjectionSite.thighRightAnterior,
|
||||||
|
administeredAt: administeredAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saved, isTrue);
|
||||||
|
expect(repository.entries, hasLength(1));
|
||||||
|
expect(
|
||||||
|
repository.entries.single.injectionSite,
|
||||||
|
InjectionSite.thighRightAnterior,
|
||||||
|
);
|
||||||
|
expect(notifications.permissionRequests, 1);
|
||||||
|
expect(notifications.scheduledTimes, [
|
||||||
|
administeredAt.add(Glp1Compound.tirzepatide.dosingInterval),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotation moves away from the most recently used quadrant', () async {
|
||||||
|
final controller = buildController();
|
||||||
|
|
||||||
|
await controller.logInjection(
|
||||||
|
compoundName: 'Semaglutide',
|
||||||
|
doseMg: 0.25,
|
||||||
|
site: InjectionSite.abdomenRightUpper,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(controller.state.suggestedSite, InjectionSite.abdomenRightLower);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logInjection failure returns false and records the error', () async {
|
||||||
|
final controller = buildController();
|
||||||
|
repository.failNextSave = true;
|
||||||
|
|
||||||
|
final saved = await controller.logInjection(
|
||||||
|
compoundName: 'Semaglutide',
|
||||||
|
doseMg: 0.25,
|
||||||
|
site: InjectionSite.armLeftTricep,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saved, isFalse);
|
||||||
|
expect(controller.state.error, isA<StateError>());
|
||||||
|
expect(notifications.scheduledTimes, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteInjection removes an entry from the stream', () async {
|
||||||
|
final controller = buildController();
|
||||||
|
final stored = await repository.saveDoseEntry(
|
||||||
|
_dose(
|
||||||
|
administeredAt: DateTime.now(),
|
||||||
|
site: InjectionSite.armRightLateral,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(controller.state.entries, hasLength(1));
|
||||||
|
final removed = await controller.deleteInjection(stored.id!);
|
||||||
|
|
||||||
|
expect(removed, isTrue);
|
||||||
|
expect(controller.state.entries, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshSerumLevel re-evaluates decay at the requested time', () async {
|
||||||
|
final controller = buildController();
|
||||||
|
final administeredAt =
|
||||||
|
DateTime.now().subtract(const Duration(hours: 168));
|
||||||
|
repository.seed([
|
||||||
|
_dose(doseMg: 2, administeredAt: administeredAt),
|
||||||
|
]);
|
||||||
|
final seededLevel = controller.state.serumLevelMg;
|
||||||
|
|
||||||
|
final later = administeredAt.add(const Duration(hours: 336));
|
||||||
|
controller.refreshSerumLevel(now: later);
|
||||||
|
|
||||||
|
// Two half-lives after administration: 2 mg -> 0.5 mg, below the
|
||||||
|
// freshly seeded estimate.
|
||||||
|
expect(controller.state.serumLevelMg, closeTo(0.5, 1e-9));
|
||||||
|
expect(controller.state.serumLevelMg, lessThan(seededLevel));
|
||||||
|
expect(controller.state.evaluatedAt, later);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue