From 7b6b646d2c96f6e5dc8659c3756072997f671102 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Fri, 16 Oct 2026 05:01:54 -0400 Subject: [PATCH] feat(auth): complete Phase 1 passwordless identity and encrypted DB foundation --- lib/app/router/app_router.dart | 39 +- lib/core/database/app_database.dart | 62 ++ lib/core/database/app_database.g.dart | 654 ++++++++++++++++++ lib/core/database/connection/native.dart | 39 ++ lib/core/database/connection/unsupported.dart | 13 + lib/core/database/connection/web.dart | 20 + lib/core/network/supabase_status.dart | 16 + .../auth/data/supabase_auth_service.dart | 77 +++ .../controllers/auth_controller.dart | 65 ++ .../presentation/screens/login_screen.dart | 265 +++++-- lib/main.dart | 24 +- macos/Flutter/GeneratedPluginRegistrant.swift | 8 +- pubspec.lock | 408 ++++++++--- pubspec.yaml | 33 +- .../flutter/generated_plugin_registrant.cc | 9 +- windows/flutter/generated_plugins.cmake | 3 +- 16 files changed, 1546 insertions(+), 189 deletions(-) create mode 100644 lib/core/database/app_database.dart create mode 100644 lib/core/database/app_database.g.dart create mode 100644 lib/core/database/connection/native.dart create mode 100644 lib/core/database/connection/unsupported.dart create mode 100644 lib/core/database/connection/web.dart create mode 100644 lib/core/network/supabase_status.dart create mode 100644 lib/features/auth/data/supabase_auth_service.dart create mode 100644 lib/features/auth/presentation/controllers/auth_controller.dart diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index dc271cd..2bd0ad5 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -1,20 +1,39 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../scaffold/adaptive_home_scaffold.dart'; +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/screens/login_screen.dart'; import '../../features/medication/presentation/screens/medication_screen.dart'; import '../../features/nutrition/presentation/screens/nutrition_screen.dart'; import '../../features/meal_planner/presentation/screens/meal_planner_screen.dart'; import '../../features/pantry/presentation/screens/pantry_screen.dart'; +import '../scaffold/adaptive_home_scaffold.dart'; + +/// Locations that may be visited without an authenticated session. +const Set _publicPaths = {'/login'}; final _rootNavigatorKey = GlobalKey(); final _shellNavigatorKey = GlobalKey(); +/// 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`. final routerProvider = Provider((ref) { + final authRefreshNotifier = ValueNotifier(0); + ref.onDispose(authRefreshNotifier.dispose); + ref.listen(authStateProvider, (_, __) => authRefreshNotifier.value++); + return GoRouter( navigatorKey: _rootNavigatorKey, initialLocation: '/medication', + refreshListenable: authRefreshNotifier, + redirect: _authGuard, routes: [ GoRoute( path: '/login', @@ -47,3 +66,21 @@ final routerProvider = Provider((ref) { ], ); }); + +/// Redirect guard enforcing the passwordless session lifecycle: +/// * Unauthenticated sessions on a protected route -> `/login`. +/// * Authenticated sessions on `/login` -> `/medication`. +String? _authGuard(BuildContext context, GoRouterState state) { + final location = state.matchedLocation; + final isAuthenticated = isSupabaseAvailable && + Supabase.instance.client.auth.currentSession != null; + + if (!isAuthenticated && !_publicPaths.contains(location)) { + return '/login'; + } + if (isAuthenticated && location == '/login') { + return '/medication'; + } + return null; +} + diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart new file mode 100644 index 0000000..85452f6 --- /dev/null +++ b/lib/core/database/app_database.dart @@ -0,0 +1,62 @@ +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'connection/unsupported.dart' + if (dart.library.ffi) 'connection/native.dart' + if (dart.library.js_interop) 'connection/web.dart'; + +part 'app_database.g.dart'; + +/// Immutable log of GLP-1 injections and scheduled doses. +/// +/// [injectionSite] stores the anatomical rotation quadrant as a stable string +/// value (the `InjectionQuadrant.name` identifier of the medication domain +/// enum) so the persistence schema stays decoupled from domain types and the +/// `core` layer never imports feature code. +@DataClassName('MedicationDoseRecord') +class MedicationDosesTable extends Table { + IntColumn get id => integer().autoIncrement()(); + + /// Commercial or compounded name of the GLP-1/GIP agonist (e.g. Tirzepatide). + TextColumn get compoundName => text().withLength(min: 1, max: 128)(); + + /// Total injected quantity in milligrams. + RealColumn get doseMg => real()(); + + /// When the dose was prescribed to be taken (titration schedule anchor). + DateTimeColumn get scheduledTime => dateTime()(); + + /// When the dose was actually administered; `null` while still pending. + DateTimeColumn get administeredTime => dateTime().nullable()(); + + /// Rotation quadrant identifier (e.g. `abdomenLeftLower`). + TextColumn get injectionSite => text().nullable()(); + + TextColumn get notes => text().nullable()(); +} + +/// Root drift database for the GLP-1 Companion. +/// +/// On native platforms the connection is backed by SQLCipher: the 256-bit AES +/// master key provisioned by `CryptoManager` is applied via `PRAGMA key` +/// immediately after the database opens (see `connection/native.dart`). Web +/// builds transparently fall back to a WASM SQLite database, since SQLCipher +/// cannot run inside the browser. +@DriftDatabase(tables: [MedicationDosesTable]) +class AppDatabase extends _$AppDatabase { + /// Opens the platform-appropriate (encrypted on native) database connection. + AppDatabase() : super(openDatabaseConnection()); + + /// Injects a custom executor; used by unit tests with an in-memory database. + AppDatabase.forTesting(super.executor); + + @override + int get schemaVersion => 1; +} + +/// Application-wide database instance; closed when the container is disposed. +final appDatabaseProvider = Provider((ref) { + final database = AppDatabase(); + ref.onDispose(database.close); + return database; +}); diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart new file mode 100644 index 0000000..d93b592 --- /dev/null +++ b/lib/core/database/app_database.g.dart @@ -0,0 +1,654 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_database.dart'; + +// ignore_for_file: type=lint +class $MedicationDosesTableTable extends MedicationDosesTable + with TableInfo<$MedicationDosesTableTable, MedicationDoseRecord> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MedicationDosesTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _compoundNameMeta = + const VerificationMeta('compoundName'); + @override + late final GeneratedColumn compoundName = GeneratedColumn( + 'compound_name', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 128), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _doseMgMeta = const VerificationMeta('doseMg'); + @override + late final GeneratedColumn doseMg = GeneratedColumn( + 'dose_mg', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _scheduledTimeMeta = + const VerificationMeta('scheduledTime'); + @override + late final GeneratedColumn scheduledTime = + GeneratedColumn('scheduled_time', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _administeredTimeMeta = + const VerificationMeta('administeredTime'); + @override + late final GeneratedColumn administeredTime = + GeneratedColumn('administered_time', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _injectionSiteMeta = + const VerificationMeta('injectionSite'); + @override + late final GeneratedColumn injectionSite = GeneratedColumn( + 'injection_site', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _notesMeta = const VerificationMeta('notes'); + @override + late final GeneratedColumn notes = GeneratedColumn( + 'notes', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + id, + compoundName, + doseMg, + scheduledTime, + administeredTime, + injectionSite, + notes + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'medication_doses_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('compound_name')) { + context.handle( + _compoundNameMeta, + compoundName.isAcceptableOrUnknown( + data['compound_name']!, _compoundNameMeta)); + } else if (isInserting) { + context.missing(_compoundNameMeta); + } + if (data.containsKey('dose_mg')) { + context.handle(_doseMgMeta, + doseMg.isAcceptableOrUnknown(data['dose_mg']!, _doseMgMeta)); + } else if (isInserting) { + context.missing(_doseMgMeta); + } + if (data.containsKey('scheduled_time')) { + context.handle( + _scheduledTimeMeta, + scheduledTime.isAcceptableOrUnknown( + data['scheduled_time']!, _scheduledTimeMeta)); + } else if (isInserting) { + context.missing(_scheduledTimeMeta); + } + if (data.containsKey('administered_time')) { + context.handle( + _administeredTimeMeta, + administeredTime.isAcceptableOrUnknown( + data['administered_time']!, _administeredTimeMeta)); + } + if (data.containsKey('injection_site')) { + context.handle( + _injectionSiteMeta, + injectionSite.isAcceptableOrUnknown( + data['injection_site']!, _injectionSiteMeta)); + } + if (data.containsKey('notes')) { + context.handle( + _notesMeta, notes.isAcceptableOrUnknown(data['notes']!, _notesMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + MedicationDoseRecord map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MedicationDoseRecord( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + compoundName: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}compound_name'])!, + doseMg: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}dose_mg'])!, + scheduledTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}scheduled_time'])!, + administeredTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}administered_time']), + injectionSite: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}injection_site']), + notes: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}notes']), + ); + } + + @override + $MedicationDosesTableTable createAlias(String alias) { + return $MedicationDosesTableTable(attachedDatabase, alias); + } +} + +class MedicationDoseRecord extends DataClass + implements Insertable { + final int id; + + /// Commercial or compounded name of the GLP-1/GIP agonist (e.g. Tirzepatide). + final String compoundName; + + /// Total injected quantity in milligrams. + final double doseMg; + + /// When the dose was prescribed to be taken (titration schedule anchor). + final DateTime scheduledTime; + + /// When the dose was actually administered; `null` while still pending. + final DateTime? administeredTime; + + /// Rotation quadrant identifier (e.g. `abdomenLeftLower`). + final String? injectionSite; + final String? notes; + const MedicationDoseRecord( + {required this.id, + required this.compoundName, + required this.doseMg, + required this.scheduledTime, + this.administeredTime, + this.injectionSite, + this.notes}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['compound_name'] = Variable(compoundName); + map['dose_mg'] = Variable(doseMg); + map['scheduled_time'] = Variable(scheduledTime); + if (!nullToAbsent || administeredTime != null) { + map['administered_time'] = Variable(administeredTime); + } + if (!nullToAbsent || injectionSite != null) { + map['injection_site'] = Variable(injectionSite); + } + if (!nullToAbsent || notes != null) { + map['notes'] = Variable(notes); + } + return map; + } + + MedicationDosesTableCompanion toCompanion(bool nullToAbsent) { + return MedicationDosesTableCompanion( + id: Value(id), + compoundName: Value(compoundName), + doseMg: Value(doseMg), + scheduledTime: Value(scheduledTime), + administeredTime: administeredTime == null && nullToAbsent + ? const Value.absent() + : Value(administeredTime), + injectionSite: injectionSite == null && nullToAbsent + ? const Value.absent() + : Value(injectionSite), + notes: + notes == null && nullToAbsent ? const Value.absent() : Value(notes), + ); + } + + factory MedicationDoseRecord.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MedicationDoseRecord( + id: serializer.fromJson(json['id']), + compoundName: serializer.fromJson(json['compoundName']), + doseMg: serializer.fromJson(json['doseMg']), + scheduledTime: serializer.fromJson(json['scheduledTime']), + administeredTime: + serializer.fromJson(json['administeredTime']), + injectionSite: serializer.fromJson(json['injectionSite']), + notes: serializer.fromJson(json['notes']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'compoundName': serializer.toJson(compoundName), + 'doseMg': serializer.toJson(doseMg), + 'scheduledTime': serializer.toJson(scheduledTime), + 'administeredTime': serializer.toJson(administeredTime), + 'injectionSite': serializer.toJson(injectionSite), + 'notes': serializer.toJson(notes), + }; + } + + MedicationDoseRecord copyWith( + {int? id, + String? compoundName, + double? doseMg, + DateTime? scheduledTime, + Value administeredTime = const Value.absent(), + Value injectionSite = const Value.absent(), + Value notes = const Value.absent()}) => + MedicationDoseRecord( + id: id ?? this.id, + compoundName: compoundName ?? this.compoundName, + doseMg: doseMg ?? this.doseMg, + scheduledTime: scheduledTime ?? this.scheduledTime, + administeredTime: administeredTime.present + ? administeredTime.value + : this.administeredTime, + injectionSite: + injectionSite.present ? injectionSite.value : this.injectionSite, + notes: notes.present ? notes.value : this.notes, + ); + MedicationDoseRecord copyWithCompanion(MedicationDosesTableCompanion data) { + return MedicationDoseRecord( + id: data.id.present ? data.id.value : this.id, + compoundName: data.compoundName.present + ? data.compoundName.value + : this.compoundName, + doseMg: data.doseMg.present ? data.doseMg.value : this.doseMg, + scheduledTime: data.scheduledTime.present + ? data.scheduledTime.value + : this.scheduledTime, + administeredTime: data.administeredTime.present + ? data.administeredTime.value + : this.administeredTime, + injectionSite: data.injectionSite.present + ? data.injectionSite.value + : this.injectionSite, + notes: data.notes.present ? data.notes.value : this.notes, + ); + } + + @override + String toString() { + return (StringBuffer('MedicationDoseRecord(') + ..write('id: $id, ') + ..write('compoundName: $compoundName, ') + ..write('doseMg: $doseMg, ') + ..write('scheduledTime: $scheduledTime, ') + ..write('administeredTime: $administeredTime, ') + ..write('injectionSite: $injectionSite, ') + ..write('notes: $notes') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, compoundName, doseMg, scheduledTime, + administeredTime, injectionSite, notes); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MedicationDoseRecord && + other.id == this.id && + other.compoundName == this.compoundName && + other.doseMg == this.doseMg && + other.scheduledTime == this.scheduledTime && + other.administeredTime == this.administeredTime && + other.injectionSite == this.injectionSite && + other.notes == this.notes); +} + +class MedicationDosesTableCompanion + extends UpdateCompanion { + final Value id; + final Value compoundName; + final Value doseMg; + final Value scheduledTime; + final Value administeredTime; + final Value injectionSite; + final Value notes; + const MedicationDosesTableCompanion({ + this.id = const Value.absent(), + this.compoundName = const Value.absent(), + this.doseMg = const Value.absent(), + this.scheduledTime = const Value.absent(), + this.administeredTime = const Value.absent(), + this.injectionSite = const Value.absent(), + this.notes = const Value.absent(), + }); + MedicationDosesTableCompanion.insert({ + this.id = const Value.absent(), + required String compoundName, + required double doseMg, + required DateTime scheduledTime, + this.administeredTime = const Value.absent(), + this.injectionSite = const Value.absent(), + this.notes = const Value.absent(), + }) : compoundName = Value(compoundName), + doseMg = Value(doseMg), + scheduledTime = Value(scheduledTime); + static Insertable custom({ + Expression? id, + Expression? compoundName, + Expression? doseMg, + Expression? scheduledTime, + Expression? administeredTime, + Expression? injectionSite, + Expression? notes, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (compoundName != null) 'compound_name': compoundName, + if (doseMg != null) 'dose_mg': doseMg, + if (scheduledTime != null) 'scheduled_time': scheduledTime, + if (administeredTime != null) 'administered_time': administeredTime, + if (injectionSite != null) 'injection_site': injectionSite, + if (notes != null) 'notes': notes, + }); + } + + MedicationDosesTableCompanion copyWith( + {Value? id, + Value? compoundName, + Value? doseMg, + Value? scheduledTime, + Value? administeredTime, + Value? injectionSite, + Value? notes}) { + return MedicationDosesTableCompanion( + id: id ?? this.id, + compoundName: compoundName ?? this.compoundName, + doseMg: doseMg ?? this.doseMg, + scheduledTime: scheduledTime ?? this.scheduledTime, + administeredTime: administeredTime ?? this.administeredTime, + injectionSite: injectionSite ?? this.injectionSite, + notes: notes ?? this.notes, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (compoundName.present) { + map['compound_name'] = Variable(compoundName.value); + } + if (doseMg.present) { + map['dose_mg'] = Variable(doseMg.value); + } + if (scheduledTime.present) { + map['scheduled_time'] = Variable(scheduledTime.value); + } + if (administeredTime.present) { + map['administered_time'] = Variable(administeredTime.value); + } + if (injectionSite.present) { + map['injection_site'] = Variable(injectionSite.value); + } + if (notes.present) { + map['notes'] = Variable(notes.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MedicationDosesTableCompanion(') + ..write('id: $id, ') + ..write('compoundName: $compoundName, ') + ..write('doseMg: $doseMg, ') + ..write('scheduledTime: $scheduledTime, ') + ..write('administeredTime: $administeredTime, ') + ..write('injectionSite: $injectionSite, ') + ..write('notes: $notes') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $MedicationDosesTableTable medicationDosesTable = + $MedicationDosesTableTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [medicationDosesTable]; +} + +typedef $$MedicationDosesTableTableCreateCompanionBuilder + = MedicationDosesTableCompanion Function({ + Value id, + required String compoundName, + required double doseMg, + required DateTime scheduledTime, + Value administeredTime, + Value injectionSite, + Value notes, +}); +typedef $$MedicationDosesTableTableUpdateCompanionBuilder + = MedicationDosesTableCompanion Function({ + Value id, + Value compoundName, + Value doseMg, + Value scheduledTime, + Value administeredTime, + Value injectionSite, + Value notes, +}); + +class $$MedicationDosesTableTableFilterComposer + extends Composer<_$AppDatabase, $MedicationDosesTableTable> { + $$MedicationDosesTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get compoundName => $composableBuilder( + column: $table.compoundName, builder: (column) => ColumnFilters(column)); + + ColumnFilters get doseMg => $composableBuilder( + column: $table.doseMg, builder: (column) => ColumnFilters(column)); + + ColumnFilters get scheduledTime => $composableBuilder( + column: $table.scheduledTime, builder: (column) => ColumnFilters(column)); + + ColumnFilters get administeredTime => $composableBuilder( + column: $table.administeredTime, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get injectionSite => $composableBuilder( + column: $table.injectionSite, builder: (column) => ColumnFilters(column)); + + ColumnFilters get notes => $composableBuilder( + column: $table.notes, builder: (column) => ColumnFilters(column)); +} + +class $$MedicationDosesTableTableOrderingComposer + extends Composer<_$AppDatabase, $MedicationDosesTableTable> { + $$MedicationDosesTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get compoundName => $composableBuilder( + column: $table.compoundName, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get doseMg => $composableBuilder( + column: $table.doseMg, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get scheduledTime => $composableBuilder( + column: $table.scheduledTime, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get administeredTime => $composableBuilder( + column: $table.administeredTime, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get injectionSite => $composableBuilder( + column: $table.injectionSite, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get notes => $composableBuilder( + column: $table.notes, builder: (column) => ColumnOrderings(column)); +} + +class $$MedicationDosesTableTableAnnotationComposer + extends Composer<_$AppDatabase, $MedicationDosesTableTable> { + $$MedicationDosesTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get compoundName => $composableBuilder( + column: $table.compoundName, builder: (column) => column); + + GeneratedColumn get doseMg => + $composableBuilder(column: $table.doseMg, builder: (column) => column); + + GeneratedColumn get scheduledTime => $composableBuilder( + column: $table.scheduledTime, builder: (column) => column); + + GeneratedColumn get administeredTime => $composableBuilder( + column: $table.administeredTime, builder: (column) => column); + + GeneratedColumn get injectionSite => $composableBuilder( + column: $table.injectionSite, builder: (column) => column); + + GeneratedColumn get notes => + $composableBuilder(column: $table.notes, builder: (column) => column); +} + +class $$MedicationDosesTableTableTableManager extends RootTableManager< + _$AppDatabase, + $MedicationDosesTableTable, + MedicationDoseRecord, + $$MedicationDosesTableTableFilterComposer, + $$MedicationDosesTableTableOrderingComposer, + $$MedicationDosesTableTableAnnotationComposer, + $$MedicationDosesTableTableCreateCompanionBuilder, + $$MedicationDosesTableTableUpdateCompanionBuilder, + ( + MedicationDoseRecord, + BaseReferences<_$AppDatabase, $MedicationDosesTableTable, + MedicationDoseRecord> + ), + MedicationDoseRecord, + PrefetchHooks Function()> { + $$MedicationDosesTableTableTableManager( + _$AppDatabase db, $MedicationDosesTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MedicationDosesTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MedicationDosesTableTableOrderingComposer( + $db: db, $table: table), + createComputedFieldComposer: () => + $$MedicationDosesTableTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value compoundName = const Value.absent(), + Value doseMg = const Value.absent(), + Value scheduledTime = const Value.absent(), + Value administeredTime = const Value.absent(), + Value injectionSite = const Value.absent(), + Value notes = const Value.absent(), + }) => + MedicationDosesTableCompanion( + id: id, + compoundName: compoundName, + doseMg: doseMg, + scheduledTime: scheduledTime, + administeredTime: administeredTime, + injectionSite: injectionSite, + notes: notes, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String compoundName, + required double doseMg, + required DateTime scheduledTime, + Value administeredTime = const Value.absent(), + Value injectionSite = const Value.absent(), + Value notes = const Value.absent(), + }) => + MedicationDosesTableCompanion.insert( + id: id, + compoundName: compoundName, + doseMg: doseMg, + scheduledTime: scheduledTime, + administeredTime: administeredTime, + injectionSite: injectionSite, + notes: notes, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable<$MedicationDosesTableTable, + MedicationDoseRecord>(table), + BaseReferences<_$AppDatabase, $MedicationDosesTableTable, + MedicationDoseRecord>(db, table, e) + )) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$MedicationDosesTableTableProcessedTableManager + = ProcessedTableManager< + _$AppDatabase, + $MedicationDosesTableTable, + MedicationDoseRecord, + $$MedicationDosesTableTableFilterComposer, + $$MedicationDosesTableTableOrderingComposer, + $$MedicationDosesTableTableAnnotationComposer, + $$MedicationDosesTableTableCreateCompanionBuilder, + $$MedicationDosesTableTableUpdateCompanionBuilder, + ( + MedicationDoseRecord, + BaseReferences<_$AppDatabase, $MedicationDosesTableTable, + MedicationDoseRecord> + ), + MedicationDoseRecord, + PrefetchHooks Function()>; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$MedicationDosesTableTableTableManager get medicationDosesTable => + $$MedicationDosesTableTableTableManager(_db, _db.medicationDosesTable); +} diff --git a/lib/core/database/connection/native.dart b/lib/core/database/connection/native.dart new file mode 100644 index 0000000..0958936 --- /dev/null +++ b/lib/core/database/connection/native.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../security/crypto_manager.dart'; + +/// Opens the local SQLCipher-encrypted database file. +/// +/// The hardware-backed 256-bit AES master key is generated on first launch and +/// stored in the OS secure vault (Keychain / Keystore / DPAPI) by +/// [CryptoManager]; it is handed to SQLCipher through `PRAGMA key` before any +/// statement executes. +/// +/// The native SQLite engine is compiled as SQLCipher through the `sqlite3` +/// build-hook define in `pubspec.yaml` (`hooks.user_defines.sqlite3.source`), +/// which ships the encrypted engine on every native platform (Android, iOS, +/// macOS, Windows, Linux) without dynamic-library override code. +QueryExecutor openDatabaseConnection() { + return LazyDatabase(() async { + final supportDirectory = await getApplicationSupportDirectory(); + final databaseFile = File( + p.join(supportDirectory.path, 'glp1_companion.db'), + ); + + final masterKey = await CryptoManager().getOrCreateDatabaseEncryptionKey(); + + return NativeDatabase( + databaseFile, + setup: (db) { + // SQLCipher derives the AES-256 page key from the master-key + // passphrase before drift issues its first statement. + db.execute("PRAGMA key = '$masterKey';"); + }, + ); + }); +} diff --git a/lib/core/database/connection/unsupported.dart b/lib/core/database/connection/unsupported.dart new file mode 100644 index 0000000..7081245 --- /dev/null +++ b/lib/core/database/connection/unsupported.dart @@ -0,0 +1,13 @@ +import 'package:drift/drift.dart'; + +/// Fallback for platforms without a drift backend. +/// +/// This branch is never selected at runtime on the supported targets: native +/// platforms compile to `native.dart` (SQLCipher) and the web compiler picks +/// `web.dart` (WASM) through the conditional import in `app_database.dart`. +QueryExecutor openDatabaseConnection() { + throw UnimplementedError( + 'Local persistence is only implemented for native (SQLCipher) ' + 'and web (WASM SQLite) platforms.', + ); +} diff --git a/lib/core/database/connection/web.dart b/lib/core/database/connection/web.dart new file mode 100644 index 0000000..9db039b --- /dev/null +++ b/lib/core/database/connection/web.dart @@ -0,0 +1,20 @@ +import 'package:drift/drift.dart'; +import 'package:drift/wasm.dart'; + +/// Opens the browser database via SQLite compiled to WebAssembly. +/// +/// SQLCipher cannot run in the browser, so web builds persist an unencrypted +/// database in OPFS/IndexedDB. [WasmDatabase.open] expects `sqlite3.wasm` +/// (from the `sqlite3.dart` releases) and `drift_worker.js` (from the drift +/// releases) to be copied into the `web/` directory of the Flutter host +/// project. +QueryExecutor openDatabaseConnection() { + return LazyDatabase(() async { + final result = await WasmDatabase.open( + databaseName: 'glp1_companion', + sqlite3Uri: Uri.parse('sqlite3.wasm'), + driftWorkerUri: Uri.parse('drift_worker.js'), + ); + return result.resolvedExecutor; + }); +} diff --git a/lib/core/network/supabase_status.dart b/lib/core/network/supabase_status.dart new file mode 100644 index 0000000..3fd740d --- /dev/null +++ b/lib/core/network/supabase_status.dart @@ -0,0 +1,16 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Whether the global [Supabase] SDK has been initialized. +/// +/// [Supabase.instance] throws an [AssertionError] in debug builds when +/// [Supabase.initialize] has not run (for example when the `.env` file is +/// missing or incomplete during local development). Routing guards and the +/// provider graph must not depend on the SDK being configured, so this helper +/// probes the singleton defensively and reports `false` instead of throwing. +bool get isSupabaseAvailable { + try { + return Supabase.instance.isInitialized; + } on AssertionError { + return false; + } +} diff --git a/lib/features/auth/data/supabase_auth_service.dart b/lib/features/auth/data/supabase_auth_service.dart new file mode 100644 index 0000000..247730b --- /dev/null +++ b/lib/features/auth/data/supabase_auth_service.dart @@ -0,0 +1,77 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../domain/auth_service.dart'; + +/// Custom URL scheme used to bring the user back into the app after an OAuth +/// or magic-link round trip on native platforms. +/// +/// The scheme mirrors the platform bundle identifiers +/// (`com.glpcompanion.glpcompanion`) and must be registered in the iOS/macOS +/// `Info.plist`, the Android manifest intent filters, and the Windows runner +/// so the operating system can route the redirect callback back to the app. +/// [SupabaseAuth] (bundled with `supabase_flutter`) observes the deep link and +/// exchanges the returned auth code for a session automatically. +const kAuthCallbackRedirectUrl = + 'com.glpcompanion.glpcompanion://login-callback/'; + +/// Resolves the redirect URL embedded in OAuth and magic-link flows. +/// +/// On web the browser returns to the current origin; native platforms use the +/// registered deep-link scheme above. +String resolveAuthRedirectUrl() { + return kIsWeb ? Uri.base.origin : kAuthCallbackRedirectUrl; +} + +/// [IAuthService] implementation backed by Supabase GoTrue. +/// +/// Only passwordless sign-in modes are modeled: federated OAuth (Google, +/// Apple, Microsoft Identity via Supabase's `azure` provider) and email magic +/// links. Direct username/password credentials are intentionally absent in +/// 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; + + final SupabaseClient _client; + + @override + Future signInWithGoogle() => _signInWithOAuth(OAuthProvider.google); + + @override + Future signInWithApple() => _signInWithOAuth(OAuthProvider.apple); + + /// Microsoft Identity (Entra ID) is exposed by Supabase Auth as the + /// `azure` OAuth provider. + @override + Future signInWithMicrosoft() => _signInWithOAuth(OAuthProvider.azure); + + @override + Future signInWithMagicLink(String email) async { + await _client.auth.signInWithOtp( + email: email, + emailRedirectTo: resolveAuthRedirectUrl(), + ); + } + + @override + Future signOut() => _client.auth.signOut(); + + /// Emits the authenticated user's id on every auth transition and `null` + /// once the user is signed out. + @override + Stream get authStateChanges => + _client.auth.onAuthStateChange.map((state) => state.session?.user.id); + + Future _signInWithOAuth(OAuthProvider provider) async { + final launched = await _client.auth.signInWithOAuth( + provider, + redirectTo: resolveAuthRedirectUrl(), + ); + if (!launched) { + throw AuthException('Could not launch the ${provider.name} sign-in page.'); + } + } +} diff --git a/lib/features/auth/presentation/controllers/auth_controller.dart b/lib/features/auth/presentation/controllers/auth_controller.dart new file mode 100644 index 0000000..25cf54d --- /dev/null +++ b/lib/features/auth/presentation/controllers/auth_controller.dart @@ -0,0 +1,65 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../../core/network/supabase_status.dart'; +import '../../data/supabase_auth_service.dart'; +import '../../domain/auth_service.dart'; + +/// Provides the application-wide [IAuthService]. +/// +/// The concrete [SupabaseAuthService] is resolved behind the domain contract +/// so widget/unit tests can override this provider with a fake without touching +/// the presentation layer. +final authServiceProvider = Provider( + (ref) => SupabaseAuthService(), +); + +/// Raw GoTrue auth transitions (sign-in, sign-out, token refresh, ...). +/// +/// The router subscribes to this stream through `refreshListenable` so route +/// guards react immediately after an OAuth or magic-link round trip completes. +/// Falls back to an empty stream when the Supabase SDK has not been +/// initialized (e.g. missing `.env` during local development). +final authStateProvider = StreamProvider((ref) { + if (!isSupabaseAvailable) return const Stream.empty(); + return Supabase.instance.client.auth.onAuthStateChange; +}); + +/// Orchestrates asynchronous passwordless authentication flows. +/// +/// Every method transitions through [AsyncValue.loading] and records failures +/// in [state] as [AsyncValue.error], so the presentation layer only needs to +/// watch/listen to the provider to render progress and error feedback. +class AuthController extends StateNotifier> { + AuthController(this._authService) : super(const AsyncValue.data(null)); + + final IAuthService _authService; + + /// Dispatches a passwordless OTP/magic link to [email]. + Future signInWithMagicLink(String email) => + _run(() => _authService.signInWithMagicLink(email)); + + Future signInWithGoogle() => _run(_authService.signInWithGoogle); + + Future signInWithApple() => _run(_authService.signInWithApple); + + Future signInWithMicrosoft() => _run(_authService.signInWithMicrosoft); + + Future signOut() => _run(_authService.signOut); + + Future _run(Future Function() action) async { + state = const AsyncValue.loading(); + try { + await action(); + if (mounted) state = const AsyncValue.data(null); + } catch (error, stackTrace) { + if (mounted) state = AsyncValue.error(error, stackTrace); + } + } +} + +/// Exposes the [AuthController] to the widget tree. +final authControllerProvider = + StateNotifierProvider>( + (ref) => AuthController(ref.watch(authServiceProvider)), +); diff --git a/lib/features/auth/presentation/screens/login_screen.dart b/lib/features/auth/presentation/screens/login_screen.dart index a80f15b..45eaace 100644 --- a/lib/features/auth/presentation/screens/login_screen.dart +++ b/lib/features/auth/presentation/screens/login_screen.dart @@ -1,84 +1,215 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' show AuthException; -class LoginScreen extends StatelessWidget { +import '../controllers/auth_controller.dart'; + +/// Which authentication flow is currently executing, so the relevant button +/// can render a spinner while the request is in flight. +enum _AuthAction { google, apple, microsoft, magicLink } + +/// Passwordless sign-in surface: federated OAuth (Google, Apple, Microsoft) +/// and email magic links. Wired to [authControllerProvider] for execution and +/// progress/error feedback. +class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final TextEditingController _emailController = TextEditingController(); + + /// The action currently awaiting completion, or `null` when idle. + _AuthAction? _pendingAction; + + static final RegExp _emailPattern = RegExp( + r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$", + ); + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final emailController = TextEditingController(); + // The controller records failures in its AsyncValue state instead of + // throwing, so a single listener surfaces every flow's error feedback. + ref.listen>(authControllerProvider, (previous, next) { + final error = next.error; + if (error == null) return; + _showSnack(_messageFor(error), isError: true); + }); + + final isBusy = ref.watch(authControllerProvider).isLoading; return Scaffold( body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(Icons.health_and_safety, - size: 64, color: Colors.teal), - const SizedBox(height: 16), - const Text( - 'GLP-1 Companion', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - const Text( - 'Passwordless & Biometric Sign In', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), - ), - const SizedBox(height: 32), - ElevatedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.g_mobiledata, size: 28), - label: const Text('Continue with Google'), - ), - const SizedBox(height: 12), - ElevatedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.apple), - label: const Text('Continue with Apple'), - ), - const SizedBox(height: 12), - ElevatedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.window), - label: const Text('Continue with Microsoft'), - ), - const SizedBox(height: 24), - const Row( - children: [ - Expanded(child: Divider()), - Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: Text('OR'), - ), - Expanded(child: Divider()), - ], - ), - const SizedBox(height: 24), - TextField( - controller: emailController, - decoration: const InputDecoration( - labelText: 'Email Address', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.email_outlined), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.health_and_safety, + size: 64, color: Colors.teal), + const SizedBox(height: 16), + const Text( + 'GLP-1 Companion', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () {}, - child: const Text('Send Magic Link'), - ), - ], + const SizedBox(height: 8), + const Text( + 'Passwordless & Biometric Sign In', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 32), + _oauthButton(_AuthAction.google, Icons.g_mobiledata, + 'Continue with Google'), + const SizedBox(height: 12), + _oauthButton( + _AuthAction.apple, Icons.apple, 'Continue with Apple'), + const SizedBox(height: 12), + _oauthButton(_AuthAction.microsoft, Icons.window, + 'Continue with Microsoft'), + const SizedBox(height: 24), + const Row( + children: [ + Expanded(child: Divider()), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: Text('OR'), + ), + Expanded(child: Divider()), + ], + ), + const SizedBox(height: 24), + TextField( + controller: _emailController, + enabled: !isBusy, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + decoration: const InputDecoration( + labelText: 'Email Address', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.email_outlined), + ), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: isBusy ? null : _sendMagicLink, + child: _pendingAction == _AuthAction.magicLink + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Send Magic Link'), + ), + ], + ), ), ), ), ), ); } + + /// Builds an OAuth button bound to [action]; disabled while any flow runs + /// and swapped for a spinner while [action] itself is executing. + Widget _oauthButton(_AuthAction action, IconData icon, String label) { + final isBusy = ref.watch(authControllerProvider).isLoading; + return ElevatedButton.icon( + onPressed: isBusy ? null : () => _start(action, _runnerFor(action)), + icon: _pendingAction == action + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(icon), + label: Text(label), + ); + } + + /// Resolves the controller method backing [action]. + Future Function() _runnerFor(_AuthAction action) { + final notifier = ref.read(authControllerProvider.notifier); + return switch (action) { + _AuthAction.google => notifier.signInWithGoogle, + _AuthAction.apple => notifier.signInWithApple, + _AuthAction.microsoft => notifier.signInWithMicrosoft, + _AuthAction.magicLink => + () => notifier.signInWithMagicLink(_emailController.text.trim()), + }; + } + + /// Runs [execute] while marking [action] as pending. + /// + /// A completed flow without a recorded error counts as success; optional + /// [successMessage] is surfaced via SnackBar. Errors are reported by the + /// listener in [build], so they are not repeated here. + Future _start( + _AuthAction action, + Future Function() execute, { + String? successMessage, + }) async { + if (_pendingAction != null) return; + setState(() => _pendingAction = action); + try { + await execute(); + if (!mounted) return; + final result = ref.read(authControllerProvider); + if (!result.hasError && successMessage != null) { + _showSnack(successMessage); + } + } finally { + if (mounted) setState(() => _pendingAction = null); + } + } + + Future _sendMagicLink() async { + final email = _emailController.text.trim(); + if (!_emailPattern.hasMatch(email)) { + _showSnack( + 'Enter a valid email address to receive a magic link.', + isError: true, + ); + return; + } + await _start( + _AuthAction.magicLink, + _runnerFor(_AuthAction.magicLink), + successMessage: + 'Magic link sent to $email. Check your inbox to finish signing in.', + ); + } + + void _showSnack(String message, {bool isError = false}) { + if (!mounted) return; + final messenger = ScaffoldMessenger.of(context); + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Theme.of(context).colorScheme.error : null, + ), + ); + } + + String _messageFor(Object error) { + if (error is AuthException) return error.message; + return 'Authentication failed. Please try again.'; + } + } diff --git a/lib/main.dart b/lib/main.dart index 85660ef..395cb4b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,11 +2,33 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'app/router/app_router.dart'; -void main() async { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); + + // Load environment variables from .env + await dotenv.load(fileName: '.env'); + + final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; + final supabaseKey = dotenv.env['SUPABASE_PUBLISHABLE_KEY'] ?? + dotenv.env['SUPABASE_ANON_KEY'] ?? + ''; + + if (supabaseUrl.isNotEmpty && supabaseKey.isNotEmpty) { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + ); + } else { + debugPrint( + 'WARNING: SUPABASE_URL or SUPABASE_PUBLISHABLE_KEY missing in .env. Supabase client uninitialized.', + ); + } + runApp(const ProviderScope(child: GLP1CompanionApp())); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d79e9f8..b1fac11 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,14 +5,18 @@ import FlutterMacOS import Foundation +import app_links import device_info_plus import flutter_local_notifications import flutter_secure_storage_macos -import sqlite3_flutter_libs +import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) - Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 814c57d..f2aef03 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,50 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: "48e48f73a431eb269eec66d243a63a07ed90ef9417a74047a3b98d52338626ad" url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "109.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + sha256: "6d377901d198536b648172974d9d4f277910502cad92193466869cac0ff34990" url: "https://pub.dev" source: hosted - version: "7.6.0" - analyzer_plugin: + version: "14.5.0" + app_links: dependency: transitive description: - name: analyzer_plugin - sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + name: app_links + sha256: "08ba88bf19d9edea33130cb71c8ced2cb982b4e00b82efef4a434d1357bc69c3" url: "https://pub.dev" source: hosted - version: "0.13.4" + version: "7.2.2" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: "9ccb5802085a1e57bb07b6256fd1d61a78c7ec104944d4965e62938b4fefd9cb" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: "382d32c6f486f15b1d7f5d6a37a70a481a4fc08382eef419d373169921a3e765" + url: "https://pub.dev" + source: hosted + version: "1.0.5" args: dependency: transitive description: @@ -53,18 +77,18 @@ packages: dependency: transitive description: name: build - sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "4.0.11" build_config: dependency: transitive description: name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.3.3" build_daemon: dependency: transitive description: @@ -73,30 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.7" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 - url: "https://pub.dev" - source: hosted - version: "2.5.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + sha256: "6feadd32d90a68253c1f7b4fcff76adb4b41ad32a53f8bad835dc65f01ac9dc5" url: "https://pub.dev" source: hosted - version: "2.5.4" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" - url: "https://pub.dev" - source: hosted - version: "9.1.2" + version: "2.16.2" built_collection: dependency: transitive description: @@ -169,14 +177,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "811709c1badd02fc054039a13f66e0db1961865f9a314175f68c3eaeebec786f" - url: "https://pub.dev" - source: hosted - version: "4.13.0" collection: dependency: transitive description: @@ -217,30 +217,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" - custom_lint_core: + dart_jsonwebtoken: dependency: transitive description: - name: custom_lint_core - sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + name: dart_jsonwebtoken + sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe url: "https://pub.dev" source: hosted - version: "0.7.5" - custom_lint_visitor: - dependency: transitive - description: - name: custom_lint_visitor - sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" - url: "https://pub.dev" - source: hosted - version: "1.0.0+7.7.0" + version: "3.4.1" dart_style: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: a0e4f65339b31eb6a9e6a12e14b4682a48d46fbc54a82f4a702396fbdbac63a7 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.14" dbus: dependency: transitive description: @@ -269,26 +261,26 @@ packages: dependency: "direct main" description: name: drift - sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1" + sha256: ef9598e1785c18db37209e8554fc79aff94632209d384ec98984b9e3184af0f7 url: "https://pub.dev" source: hosted - version: "2.28.2" + version: "2.35.2" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4" + sha256: e0d16aaa21f3408cbe2dd4bfc56c36cea43e53a537e6613b91380c0924b3bd30 url: "https://pub.dev" source: hosted - version: "2.28.0" + version: "2.35.1" drift_flutter: dependency: "direct main" description: name: drift_flutter - sha256: "0bc2f1dde59e59cedde0df67a4ff7bd8f0d42274f18b50bf7e7dae7ca3d77801" + sha256: "91acf4bee7c3c84467cba46455aa70e5292a3b889f4582645d74f2e5a8c106f2" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.3.1" fake_async: dependency: transitive description: @@ -326,6 +318,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_lints: dependency: "direct dev" description: @@ -429,22 +429,14 @@ packages: description: flutter source: sdk version: "0.0.0" - freezed_annotation: + functions_client: dependency: transitive description: - name: freezed_annotation - sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + name: functions_client + sha256: "931e822688d2e97867b2300e2fdbb08c9db73f8653fc83efc7a259d2e0cca769" url: "https://pub.dev" source: hosted - version: "3.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: "404dbf9bce29beafbe9dcb19219447b6f7d49900cd42b8d3b7af102b24508e25" - url: "https://pub.dev" - source: hosted - version: "4.1.0" + version: "2.7.1" glob: dependency: transitive description: @@ -461,6 +453,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: cca816226824160e40d149816fd89740cc64dda1eaa3d9161e334330b18bba89 + url: "https://pub.dev" + source: hosted + version: "2.27.3" graphs: dependency: transitive description: @@ -645,6 +645,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31" + url: "https://pub.dev" + source: hosted + version: "0.19.5" objective_c: dependency: transitive description: @@ -661,8 +669,16 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - path: + passkeys_platform_interface: dependency: transitive + description: + name: passkeys_platform_interface + sha256: "5fca044a6f1a3c5abff645ca5a5417b613c130d1c4e08513ba9182c7f4834619" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + path: + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -670,7 +686,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 @@ -741,6 +757,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: @@ -749,6 +773,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.3" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: b7f957273ba6a1c26cefa987ac524f4138e9ab7542fbb9e6abdfddacb9895a21 + url: "https://pub.dev" + source: hosted + version: "2.9.1" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: transitive description: @@ -765,6 +805,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "2ef680d83ba2c0f76019d16194ca7067fa2458b2d4091b52323a9ca4a964fd40" + url: "https://pub.dev" + source: hosted + version: "2.13.2" recase: dependency: transitive description: @@ -789,14 +837,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" - riverpod_analyzer_utils: - dependency: transitive - description: - name: riverpod_analyzer_utils - sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611" - url: "https://pub.dev" - source: hosted - version: "0.5.10" riverpod_annotation: dependency: "direct main" description: @@ -805,14 +845,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" - riverpod_generator: - dependency: "direct dev" + shared_preferences: + dependency: transitive description: - name: riverpod_generator - sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36" + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" url: "https://pub.dev" source: hosted - version: "2.6.5" + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -838,10 +926,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + sha256: "833ea7d15b5be79949d3d6c077da0267a0bd93c16897b36a771395a91e63086e" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "4.3.1" source_span: dependency: transitive description: @@ -850,30 +938,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" - sqlite3: + sqlcipher_flutter_libs: dependency: transitive description: - name: sqlite3 - sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + name: sqlcipher_flutter_libs + sha256: "38d62d659d2fb8739bf25a42c9a350d1fdd6c29a5a61f13a946778ec75d27929" url: "https://pub.dev" source: hosted - version: "2.9.4" + version: "0.7.0+eol" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: "144132790ec1947fad9f3ddceffd0af2ab80a1f0d1e81a4d094ee6707ec3df0b" + url: "https://pub.dev" + source: hosted + version: "3.7.1" sqlite3_flutter_libs: dependency: transitive description: name: sqlite3_flutter_libs - sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" url: "https://pub.dev" source: hosted - version: "0.5.42" + version: "0.6.0+eol" sqlparser: dependency: transitive description: name: sqlparser - sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67" + sha256: "772bb2f6f5bce0631a60f26b57d6e8882e1105c22345b05c163ee6ee5d7ba32d" url: "https://pub.dev" source: hosted - version: "0.41.2" + version: "0.45.0" stack_trace: dependency: transitive description: @@ -890,6 +986,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: b951281b8e2b293460f0e6cc4e866f2546681390f0e37d24fbbd7b4a0deb27a3 + url: "https://pub.dev" + source: hosted + version: "2.8.1" stream_channel: dependency: transitive description: @@ -914,6 +1018,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: "123fe08792b24e5e7c4b9a01145de8e95ebaa9183cd5f3c9d48dd2ff890f28a6" + url: "https://pub.dev" + source: hosted + version: "2.16.5" + supabase_common: + dependency: transitive + description: + name: supabase_common + sha256: a7961a44953cf8d9da049381fc3377516348255042fbb12b9d4f8540816f2fe9 + url: "https://pub.dev" + source: hosted + version: "0.1.2" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "48bbde226e68bdb293020859a1bfa42d0f3bd2239992fba0ec4f9194cbb842e6" + url: "https://pub.dev" + source: hosted + version: "2.18.3" term_glyph: dependency: transitive description: @@ -938,14 +1066,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.4" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: @@ -954,14 +1074,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - uuid: + url_launcher: dependency: transitive description: - name: uuid - sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda url: "https://pub.dev" source: hosted - version: "4.6.0" + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" vector_math: dependency: transitive description: @@ -1050,6 +1226,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" sdks: dart: ">=3.13.0 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index a5c9d5f..5b40c45 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,21 +28,42 @@ dependencies: html: ^0.15.4 # Persistence & Secure Storage - drift: ^2.16.0 - drift_flutter: ^0.1.0 + # drift 2.35+ pairs with package:sqlite3 3.x, which bundles SQLite through + # build hooks (see `hooks:` below) instead of the retired platform plugins. + drift: ^2.35.2 + drift_flutter: ^0.3.1 flutter_secure_storage: ^9.0.0 # Multi-Platform Health & Background Tasks health: 13.3.1 flutter_local_notifications: ^17.1.0 + flutter_dotenv: ^5.1.0 + supabase_flutter: ^2.5.0 + path: ^1.9.1 + path_provider: ^2.1.6 + sqlite3: ^3.4.0 + dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.0 - build_runner: ^2.4.9 - riverpod_generator: ^2.4.0 - drift_dev: ^2.16.0 + # NOTE: riverpod_generator was removed because the 2.x code-generation line + # caps `analyzer <8.0.0`, which cannot parse the Dart 3.13 SDK and crashes + # build_runner. All providers in this codebase are declared manually with + # `Provider`/`StateNotifierProvider`; re-adding codegen requires the + # Riverpod 3 migration. + build_runner: ^2.16.0 + drift_dev: ^2.35.1 + +# Compile SQLCipher into the SQLite engine bundled by package:sqlite3's build +# hooks (the sqlcipher_flutter_libs plugin is retired on sqlite3 3.x). +hooks: + user_defines: + sqlite3: + source: sqlcipher flutter: - uses-material-design: true \ No newline at end of file + uses-material-design: true + assets: + - .env \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 1c88680..563050d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include -#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); - Sqlite3FlutterLibsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 91482b2..889e4c3 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,8 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links flutter_secure_storage_windows - sqlite3_flutter_libs + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST