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:
Mike Kell 2026-10-16 06:20:23 -04:00
parent 7b6b646d2c
commit 7682d8317a
28 changed files with 2521 additions and 116 deletions

View File

@ -52,4 +52,6 @@ Each feature MUST contain:
- Check code quality:
flutter analyze
- 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.

30
BUILD_STATE.md Normal file
View File

@ -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)

View File

@ -1,4 +1,8 @@
<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
android:label="glpcompanion"
android:name="${applicationName}"
@ -30,6 +34,17 @@
<meta-data
android:name="flutterEmbedding"
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>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@ -5,6 +5,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../core/network/supabase_status.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/medication/presentation/screens/medication_screen.dart';
import '../../features/nutrition/presentation/screens/nutrition_screen.dart';
@ -18,22 +19,20 @@ const Set<String> _publicPaths = {'/login'};
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();
/// Application router with a reactive authentication guard.
///
/// 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`.
/// Application router with a reactive authentication guard and dev bypass hook.
final routerProvider = Provider<GoRouter>((ref) {
final authRefreshNotifier = ValueNotifier<int>(0);
ref.onDispose(authRefreshNotifier.dispose);
// Trigger re-evaluation on GoTrue auth transitions or dev bypass toggle
ref.listen(authStateProvider, (_, __) => authRefreshNotifier.value++);
ref.listen(devAuthBypassProvider, (_, __) => authRefreshNotifier.value++);
return GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/medication',
refreshListenable: authRefreshNotifier,
redirect: _authGuard,
redirect: (context, state) => _authGuard(context, state, ref),
routes: [
GoRoute(
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`.
/// * 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;
if (isDevBypassed) {
if (location == '/login') return '/medication';
return null;
}
final isAuthenticated = isSupabaseAvailable &&
Supabase.instance.client.auth.currentSession != null;
@ -83,4 +92,3 @@ String? _authGuard(BuildContext context, GoRouterState state) {
}
return null;
}

View File

@ -1,7 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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;
const AdaptiveHomeScaffold({
@ -36,51 +40,67 @@ class AdaptiveHomeScaffold extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final width = MediaQuery.of(context).size.width;
final isDesktop = width >= 1024;
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) {
return Scaffold(
body: Row(
body: Column(
children: [
NavigationRail(
selectedIndex: selectedIndex,
onDestinationSelected: (idx) => _onItemTapped(idx, context),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.medication_outlined),
selectedIcon: Icon(Icons.medication),
label: Text('Medication'),
),
NavigationRailDestination(
icon: Icon(Icons.restaurant_outlined),
selectedIcon: Icon(Icons.restaurant),
label: Text('Nutrition'),
),
NavigationRailDestination(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
label: Text('Meal Plan'),
),
NavigationRailDestination(
icon: Icon(Icons.kitchen_outlined),
selectedIcon: Icon(Icons.kitchen),
label: Text('Pantry'),
),
],
if (showDevBanner) const _DevAccessBanner(),
Expanded(
child: Row(
children: [
NavigationRail(
selectedIndex: selectedIndex,
onDestinationSelected: (idx) =>
_onItemTapped(idx, context),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.medication_outlined),
selectedIcon: Icon(Icons.medication),
label: Text('Medication'),
),
NavigationRailDestination(
icon: Icon(Icons.restaurant_outlined),
selectedIcon: Icon(Icons.restaurant),
label: Text('Nutrition'),
),
NavigationRailDestination(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
label: Text('Meal Plan'),
),
NavigationRailDestination(
icon: Icon(Icons.kitchen_outlined),
selectedIcon: Icon(Icons.kitchen),
label: Text('Pantry'),
),
],
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: child),
],
),
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: child),
],
),
);
}
return Scaffold(
body: child,
body: Column(
children: [
if (showDevBanner) const _DevAccessBanner(),
Expanded(child: child),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
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,
),
),
),
],
),
),
),
);
}
}

View File

@ -52,6 +52,70 @@ class AppDatabase extends _$AppDatabase {
@override
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.

View File

@ -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');
}
}
}

View File

@ -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';
}

View File

@ -31,11 +31,14 @@ String resolveAuthRedirectUrl() {
/// line with the platform's security posture.
class SupabaseAuthService implements IAuthService {
/// Creates the service. Pass [client] in tests to inject a mock; production
/// code resolves the global Supabase client lazily on first use.
SupabaseAuthService({SupabaseClient? client})
: _client = client ?? Supabase.instance.client;
/// code resolves the global Supabase client lazily on first use so merely
/// constructing the service (e.g. while building the login screen before
/// `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
Future<void> signInWithGoogle() => _signInWithOAuth(OAuthProvider.google);

View File

@ -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);

View File

@ -1,8 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart' show AuthException;
import '../controllers/auth_controller.dart';
import '../controllers/dev_auth_bypass.dart';
/// Which authentication flow is currently executing, so the relevant button
/// can render a spinner while the request is in flight.
@ -115,6 +117,36 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
)
: 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 {
final email = _emailController.text.trim();
if (!_emailPattern.hasMatch(email)) {

View File

@ -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),
);
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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),
),
);

View File

@ -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});
@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
Widget build(BuildContext context) {
final state = ref.watch(medicationControllerProvider);
return Scaffold(
appBar: AppBar(title: const Text('Medication Schedule')),
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
Card(
appBar: AppBar(title: const Text('Medication Tracker')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: _buildBody(context, state),
),
),
);
}
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),
_SiteRotationCard(state: state),
const SizedBox(height: 16),
if (state.error != null) ...[
_InlineErrorBanner(error: state.error!),
const SizedBox(height: 16),
],
FilledButton.icon(
onPressed: () => _logInjection(state),
icon: const Icon(Icons.add_circle_outline),
label: const Text('Log Injection'),
),
const SizedBox(height: 24),
if (state.entries.isNotEmpty) ...[
Text(
'Recent Injections',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
for (final entry in state.entries.take(20)) _HistoryTile(entry: entry),
] else
const Card(
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'),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.check_circle_outline),
label: const Text('Log Injection Now'),
),
],
padding: EdgeInsets.all(16),
child: Text(
'No injections logged yet. Tap "Log Injection" to start '
'tracking your doses and site rotation.',
),
),
),
const SizedBox(height: 16),
const Text(
'Recommended Injection Site Rotation',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
],
);
}
/// 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.',
),
const SizedBox(height: 8),
const ListTile(
leading: Icon(Icons.my_location, color: Colors.teal),
title: Text('Left Lower Abdomen'),
subtitle: Text('Last used 28 days ago'),
),
],
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')),
],
),
),
);
}

View File

@ -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();
}
}

View File

@ -1059,7 +1059,7 @@ packages:
source: hosted
version: "0.7.12"
timezone:
dependency: transitive
dependency: "direct main"
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"

View File

@ -43,6 +43,8 @@ dependencies:
path: ^1.9.1
path_provider: ^2.1.6
sqlite3: ^3.4.0
# Scheduling support for flutter_local_notifications (zonedSchedule).
timezone: ^0.9.4
dev_dependencies:
flutter_test:

View File

@ -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);
});
}

View File

@ -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);
});
});
}

View File

@ -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),
);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}