feat: set up Flutter project with drift database, Riverpod providers, and LLM service layer

- Configure dependencies: drift 2.32.x, flutter_riverpod 3.3.1, fl_chart 1.2.x,
  flutter_local_notifications 21.x, local_auth 3.x, and supporting packages
- Drop riverpod_generator (incompatible analyzer version with drift_dev 2.32.x);
  convert all providers to manual Riverpod 3.x API using Notifier<T> and typed families
- Define drift schema: MoodEntries, Activities, EntryActivities, Tags, EntryTags,
  LlmInsights, UserSettings with four DAOs (mood, activity, insight, settings)
- Add LLM service abstraction (LlmService) and MediaPipe implementation
- Fix SettingsDao.delete → deleteByKey to avoid shadowing drift's inherited delete method
This commit is contained in:
2026-04-26 10:39:51 +10:00
parent 6e45e50f1a
commit 33d1713f51
149 changed files with 10338 additions and 1 deletions
+75
View File
@@ -0,0 +1,75 @@
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/app_database.dart';
import 'database_provider.dart';
// ── All activities stream ─────────────────────────────────────────────────────
final activitiesProvider = StreamProvider.autoDispose<List<Activity>>((ref) {
return ref.watch(activityDaoProvider).watchAllActivities();
});
// ── Activity frequency for charts ────────────────────────────────────────────
final activityFrequencyProvider = FutureProvider.autoDispose
.family<Map<int, int>, ({DateTime start, DateTime end})>(
(ref, range) =>
ref.watch(activityDaoProvider).getActivityFrequency(range.start, range.end),
);
// ── Selected activities (used by log mood screen) ─────────────────────────────
class SelectedActivities extends Notifier<Set<int>> {
@override
Set<int> build() => {};
void toggle(int activityId) {
if (state.contains(activityId)) {
state = <int>{...state}..remove(activityId);
} else {
state = {...state, activityId};
}
}
void clear() => state = {};
}
final selectedActivitiesProvider =
NotifierProvider.autoDispose<SelectedActivities, Set<int>>(
SelectedActivities.new);
// ── Activity notifier (create / delete custom activities) ─────────────────────
class ActivityNotifier extends Notifier<AsyncValue<void>> {
@override
AsyncValue<void> build() => const AsyncData(null);
Future<void> addActivity({
required String name,
required int iconCodepoint,
required String colourHex,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(activityDaoProvider).insertActivity(
ActivitiesCompanion.insert(
name: name,
iconCodepoint: iconCodepoint,
colourHex: colourHex,
isCustom: const Value(true),
),
),
);
}
Future<void> deleteActivity(int id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(activityDaoProvider).deleteActivity(id),
);
}
}
final activityNotifierProvider =
NotifierProvider.autoDispose<ActivityNotifier, AsyncValue<void>>(
ActivityNotifier.new);
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/app_database.dart';
import '../database/daos/mood_dao.dart';
import '../database/daos/activity_dao.dart';
import '../database/daos/insight_dao.dart';
import '../database/daos/settings_dao.dart';
final appDatabaseProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase();
ref.onDispose(db.close);
return db;
});
final moodDaoProvider =
Provider<MoodDao>((ref) => ref.watch(appDatabaseProvider).moodDao);
final activityDaoProvider =
Provider<ActivityDao>((ref) => ref.watch(appDatabaseProvider).activityDao);
final insightDaoProvider =
Provider<InsightDao>((ref) => ref.watch(appDatabaseProvider).insightDao);
final settingsDaoProvider =
Provider<SettingsDao>((ref) => ref.watch(appDatabaseProvider).settingsDao);
+73
View File
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/app_database.dart';
import 'database_provider.dart';
// ── Insight type enum ─────────────────────────────────────────────────────────
enum InsightType { weekly, monthly, query }
// ── Cached insight lookup ─────────────────────────────────────────────────────
final cachedInsightProvider =
FutureProvider.autoDispose.family<LlmInsight?, String>((ref, promptHash) {
return ref.watch(insightDaoProvider).getCached(promptHash);
});
// ── Insight notifier — triggers generation and caches results ─────────────────
class InsightNotifier extends Notifier<AsyncValue<String?>> {
@override
AsyncValue<String?> build() => const AsyncData(null);
String _hashPrompt(String prompt) =>
sha256.convert(utf8.encode(prompt)).toString();
Future<void> generateInsight({
required String prompt,
required InsightType type,
DateTime? rangeStart,
DateTime? rangeEnd,
}) async {
state = const AsyncLoading();
final hash = _hashPrompt(prompt);
final dao = ref.read(insightDaoProvider);
final cached = await dao.getCached(hash);
if (cached != null) {
state = AsyncData(cached.responseText);
return;
}
// Generation happens in LlmNotifier (see llm_provider.dart)
state = const AsyncData(null);
}
Future<void> saveInsight({
required String promptHash,
required String responseText,
required InsightType type,
DateTime? rangeStart,
DateTime? rangeEnd,
}) async {
await ref.read(insightDaoProvider).upsertInsight(
LlmInsightsCompanion.insert(
type: type.name,
promptHash: promptHash,
responseText: responseText,
rangeStart: Value(rangeStart),
rangeEnd: Value(rangeEnd),
),
);
state = AsyncData(responseText);
}
void clear() => state = const AsyncData(null);
}
final insightNotifierProvider =
NotifierProvider.autoDispose<InsightNotifier, AsyncValue<String?>>(
InsightNotifier.new);
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../llm/llm_service.dart';
import '../llm/mediapipe_llm_service.dart';
// ── Service singleton ─────────────────────────────────────────────────────────
final llmServiceProvider = Provider<LlmService>((ref) {
final service = MediapipeLlmService();
ref.onDispose(service.dispose);
return service;
});
// ── Model initialisation state ────────────────────────────────────────────────
final llmReadyProvider = FutureProvider<void>((ref) async {
await ref.watch(llmServiceProvider).initialise();
});
// ── Streaming response notifier ───────────────────────────────────────────────
class LlmResponseNotifier extends Notifier<AsyncValue<String>> {
bool _cancelled = false;
@override
AsyncValue<String> build() => const AsyncData('');
Future<void> generate(String prompt) async {
_cancelled = false;
state = const AsyncLoading();
final service = ref.read(llmServiceProvider);
final buffer = StringBuffer();
try {
await for (final token in service.generateStream(prompt)) {
if (_cancelled) break;
buffer.write(token);
state = AsyncData(buffer.toString());
}
} catch (e, st) {
state = AsyncError(e, st);
}
}
void cancel() {
_cancelled = true;
state = const AsyncData('');
}
void clear() => state = const AsyncData('');
}
final llmResponseNotifierProvider =
NotifierProvider.autoDispose<LlmResponseNotifier, AsyncValue<String>>(
LlmResponseNotifier.new);
+103
View File
@@ -0,0 +1,103 @@
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/app_database.dart';
import 'database_provider.dart';
// ── All entries stream ────────────────────────────────────────────────────────
final moodEntriesProvider = StreamProvider.autoDispose<List<MoodEntry>>((ref) {
return ref.watch(moodDaoProvider).watchAllEntries();
});
// ── Entries for a specific day ────────────────────────────────────────────────
final moodEntriesForDayProvider =
StreamProvider.autoDispose.family<List<MoodEntry>, DateTime>((ref, day) {
return ref.watch(moodDaoProvider).watchEntriesForDay(day);
});
// ── Daily averages for calendar heatmap ──────────────────────────────────────
final dailyAveragesProvider = FutureProvider.autoDispose
.family<Map<DateTime, double>, ({DateTime start, DateTime end})>(
(ref, range) =>
ref.watch(moodDaoProvider).getDailyAverages(range.start, range.end),
);
// ── Selected day (drives calendar + day detail) ───────────────────────────────
class SelectedDay extends Notifier<DateTime> {
@override
DateTime build() => DateTime.now();
void select(DateTime day) {
state = DateTime(day.year, day.month, day.day);
}
void selectToday() {
final now = DateTime.now();
state = DateTime(now.year, now.month, now.day);
}
}
final selectedDayProvider =
NotifierProvider.autoDispose<SelectedDay, DateTime>(SelectedDay.new);
// ── Mood entry notifier (create / update / delete) ────────────────────────────
class MoodEntryNotifier extends Notifier<AsyncValue<void>> {
@override
AsyncValue<void> build() => const AsyncData(null);
Future<void> addEntry({
required int moodLevel,
String? note,
List<int> activityIds = const [],
List<int> tagIds = const [],
DateTime? timestamp,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final dao = ref.read(moodDaoProvider);
await dao.insertEntryWithActivities(
entry: MoodEntriesCompanion.insert(
moodLevel: moodLevel,
timestamp: timestamp ?? DateTime.now(),
note: Value(note),
),
activityIds: activityIds,
tagIds: tagIds,
);
});
}
Future<void> deleteEntry(int id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(moodDaoProvider).deleteEntry(id),
);
}
Future<void> updateEntry({
required int id,
required int moodLevel,
String? note,
DateTime? timestamp,
}) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(moodDaoProvider).updateEntry(
MoodEntriesCompanion(
id: Value(id),
moodLevel: Value(moodLevel),
note: Value(note),
timestamp: Value(timestamp ?? DateTime.now()),
),
),
);
}
}
final moodEntryNotifierProvider =
NotifierProvider.autoDispose<MoodEntryNotifier, AsyncValue<void>>(
MoodEntryNotifier.new);
+69
View File
@@ -0,0 +1,69 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'database_provider.dart';
// ── Key constants ─────────────────────────────────────────────────────────────
abstract class SettingsKeys {
static const reminderEnabled = 'reminder_enabled';
static const reminderTime = 'reminder_time'; // stored as 'HH:mm'
static const biometricEnabled = 'biometric_enabled';
static const themMode = 'theme_mode'; // 'system' | 'light' | 'dark'
static const modelDownloaded = 'model_downloaded';
static const onboardingComplete = 'onboarding_complete';
}
// ── Generic setting reader ────────────────────────────────────────────────────
final settingProvider =
FutureProvider.autoDispose.family<String?, String>((ref, key) {
return ref.watch(settingsDaoProvider).get(key);
});
// ── Settings notifier ─────────────────────────────────────────────────────────
class SettingsNotifier extends Notifier<AsyncValue<void>> {
@override
AsyncValue<void> build() => const AsyncData(null);
Future<void> set(String key, String value) async {
await ref.read(settingsDaoProvider).set(key, value);
ref.invalidate(settingProvider(key));
}
Future<void> setReminderEnabled(bool enabled) =>
set(SettingsKeys.reminderEnabled, enabled.toString());
Future<void> setReminderTime(int hour, int minute) => set(
SettingsKeys.reminderTime,
'${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}');
Future<void> setBiometricEnabled(bool enabled) =>
set(SettingsKeys.biometricEnabled, enabled.toString());
Future<void> setThemeMode(String mode) => set(SettingsKeys.themMode, mode);
Future<void> markModelDownloaded() =>
set(SettingsKeys.modelDownloaded, 'true');
Future<void> markOnboardingComplete() =>
set(SettingsKeys.onboardingComplete, 'true');
}
final settingsNotifierProvider =
NotifierProvider.autoDispose<SettingsNotifier, AsyncValue<void>>(
SettingsNotifier.new);
// ── Convenience bool readers ──────────────────────────────────────────────────
final isOnboardingCompleteProvider =
FutureProvider.autoDispose<bool>((ref) async {
final val =
await ref.watch(settingProvider(SettingsKeys.onboardingComplete).future);
return val == 'true';
});
final isModelDownloadedProvider = FutureProvider.autoDispose<bool>((ref) async {
final val =
await ref.watch(settingProvider(SettingsKeys.modelDownloaded).future);
return val == 'true';
});