feat: add local database persistence

This commit is contained in:
2026-06-12 01:01:50 +08:00
parent 9ba681e3c7
commit b88b947e66
10 changed files with 4322 additions and 28 deletions
+4 -1
View File
@@ -6,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import '../core/ambience.dart';
import '../data/app_preferences_store.dart';
import '../data/focus_seed_store.dart';
import '../features/shell/focus_seed_home.dart';
import '../l10n/generated/app_localizations.dart';
import '../models/appearance_preference.dart';
@@ -18,9 +19,10 @@ Locale focusSeedDefaultLocale({bool isRelease = kReleaseMode}) {
}
class FocusSeedApp extends StatefulWidget {
const FocusSeedApp({this.locale, super.key});
const FocusSeedApp({this.locale, this.focusSeedStore, super.key});
final Locale? locale;
final FocusSeedStore? focusSeedStore;
@override
State<FocusSeedApp> createState() => _FocusSeedAppState();
@@ -114,6 +116,7 @@ class _FocusSeedAppState extends State<FocusSeedApp>
],
supportedLocales: AppLocalizations.supportedLocales,
home: FocusSeedHome(
focusSeedStore: widget.focusSeedStore,
appearance: _appearance,
onAppearanceChanged: _setAppearance,
language: _language,
+227 -15
View File
@@ -1,33 +1,245 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/json_utils.dart';
import '../core/date_keys.dart' as date_keys;
import '../models/app_state.dart';
import '../models/focus_session.dart';
import '../models/habit.dart';
import '../models/mood.dart';
import '../models/reflection_entry.dart';
import 'local/focus_seed_database.dart';
class FocusSeedStore {
static const _storageKey = 'focus_seed_state_v1';
FocusSeedStore({FocusSeedDatabase? database})
: _database = database ?? FocusSeedDatabase();
static const _legacyStorageKey = 'focus_seed_state_v1';
static const _legacyMigrationKey = 'focus_seed_state_v1_migrated_to_sqlite';
final FocusSeedDatabase _database;
Future<AppState> load({List<String>? defaultHabitTitles}) async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) {
return AppState.starter(defaultHabitTitles: defaultHabitTitles);
}
try {
return AppState.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} on Object {
return AppState.starter(defaultHabitTitles: defaultHabitTitles);
await _migrateLegacyJsonIfNeeded();
final snapshot = await _readSnapshot();
if (!snapshot.isFreshStart) {
return snapshot;
}
final starter = AppState.starter(defaultHabitTitles: defaultHabitTitles);
await save(starter);
return starter;
}
Future<void> save(AppState state) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_storageKey, prettyJson(state.toJson()));
await _database.transaction(() async {
await _database.delete(_database.habitLogRows).go();
await _database.delete(_database.reflectionRows).go();
await _database.delete(_database.focusSessionRows).go();
await _database.delete(_database.habitRows).go();
await _database.delete(_database.dailyIntentionRows).go();
if (state.anchor.trim().isNotEmpty) {
final now = DateTime.now();
await _database
.into(_database.dailyIntentionRows)
.insert(
DailyIntentionRowsCompanion.insert(
dateKey: state.anchorDateKey,
intentionText: state.anchor,
createdAt: now,
updatedAt: now,
),
);
}
for (final (index, habit) in state.habits.indexed) {
await _database
.into(_database.habitRows)
.insert(
HabitRowsCompanion.insert(
id: habit.id,
title: habit.title,
sortOrder: index,
createdAt: habit.createdAt,
),
);
for (final completedDate in habit.completedDates) {
await _database
.into(_database.habitLogRows)
.insert(
HabitLogRowsCompanion.insert(
id: '${habit.id}-$completedDate',
habitId: habit.id,
dateKey: completedDate,
completedAt: DateTime.parse('$completedDate 12:00:00'),
),
);
}
}
for (final session in state.sessions) {
await _database
.into(_database.focusSessionRows)
.insert(_focusSessionCompanion(session));
}
for (final reflection in state.reflections) {
await _database
.into(_database.reflectionRows)
.insert(
ReflectionRowsCompanion.insert(
id: reflection.id,
dateKey: reflection.dateKey,
mood: reflection.mood.name,
note: reflection.note,
createdAt: reflection.createdAt,
updatedAt: reflection.createdAt,
),
);
}
});
final preferences = await SharedPreferences.getInstance();
await preferences.setBool(_legacyMigrationKey, true);
await preferences.remove(_legacyStorageKey);
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_storageKey);
await _database.clearProductData();
final preferences = await SharedPreferences.getInstance();
await preferences.remove(_legacyStorageKey);
await preferences.setBool(_legacyMigrationKey, true);
}
Future<AppState> _readSnapshot() async {
final intentionRows =
await (_database.select(_database.dailyIntentionRows)
..orderBy([(row) => OrderingTerm.desc(row.dateKey)])
..limit(1))
.get();
final latestIntention = intentionRows.firstOrNull;
final habitRows =
await (_database.select(_database.habitRows)
..where((row) => row.archivedAt.isNull())
..orderBy([
(row) => OrderingTerm.asc(row.sortOrder),
(row) => OrderingTerm.asc(row.createdAt),
]))
.get();
final habitLogRows =
await (_database.select(_database.habitLogRows)
..orderBy([(row) => OrderingTerm.asc(row.dateKey)])).get();
final focusSessionRows =
await (_database.select(_database.focusSessionRows)
..orderBy([(row) => OrderingTerm.asc(row.endedAt)])).get();
final reflectionRows =
await (_database.select(_database.reflectionRows)
..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get();
final completedDatesByHabit = <String, Set<String>>{};
for (final log in habitLogRows) {
completedDatesByHabit.putIfAbsent(log.habitId, () => {}).add(log.dateKey);
}
return AppState(
anchor: latestIntention?.intentionText ?? '',
anchorDateKey: latestIntention?.dateKey ?? date_keys.todayKey(),
habits: [
for (final habit in habitRows)
Habit(
id: habit.id,
title: habit.title,
createdAt: habit.createdAt,
completedDates: completedDatesByHabit[habit.id] ?? const {},
),
],
sessions: [
for (final session in focusSessionRows)
FocusSession(
id: session.id,
minutes: session.plannedMinutes,
completedAt: session.endedAt,
),
],
reflections: [
for (final reflection in reflectionRows)
ReflectionEntry(
id: reflection.id,
dateKey: reflection.dateKey,
note: reflection.note,
mood: Mood.values.firstWhere(
(mood) => mood.name == reflection.mood,
orElse: () => Mood.clear,
),
createdAt: reflection.createdAt,
),
],
);
}
Future<void> _migrateLegacyJsonIfNeeded() async {
final preferences = await SharedPreferences.getInstance();
if (preferences.getBool(_legacyMigrationKey) ?? false) {
return;
}
if (await _hasDatabaseContent()) {
await preferences.setBool(_legacyMigrationKey, true);
return;
}
final raw = preferences.getString(_legacyStorageKey);
if (raw == null || raw.isEmpty) {
await preferences.setBool(_legacyMigrationKey, true);
return;
}
try {
final legacyState = AppState.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
await save(legacyState);
} on Object {
await preferences.setBool(_legacyMigrationKey, true);
}
}
Future<bool> _hasDatabaseContent() async {
final intention =
await (_database.select(_database.dailyIntentionRows)
..limit(1)).getSingleOrNull();
if (intention != null) {
return true;
}
final habit =
await (_database.select(_database.habitRows)
..limit(1)).getSingleOrNull();
if (habit != null) {
return true;
}
final session =
await (_database.select(_database.focusSessionRows)
..limit(1)).getSingleOrNull();
if (session != null) {
return true;
}
final reflection =
await (_database.select(_database.reflectionRows)
..limit(1)).getSingleOrNull();
return reflection != null;
}
FocusSessionRowsCompanion _focusSessionCompanion(FocusSession session) {
final completedAt = session.completedAt;
return FocusSessionRowsCompanion.insert(
id: session.id,
dateKey: date_keys.dateKey(completedAt),
plannedMinutes: session.minutes,
actualSeconds: session.minutes * 60,
startedAt: completedAt.subtract(Duration(minutes: session.minutes)),
endedAt: completedAt,
status: 'completed',
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
part 'focus_seed_database.g.dart';
class DailyIntentionRows extends Table {
@override
String get tableName => 'daily_intentions';
TextColumn get dateKey => text()();
TextColumn get intentionText => text().named('text')();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column<Object>> get primaryKey => {dateKey};
}
class FocusSessionRows extends Table {
@override
String get tableName => 'focus_sessions';
TextColumn get id => text()();
TextColumn get dateKey => text()();
IntColumn get plannedMinutes => integer()();
IntColumn get actualSeconds => integer()();
DateTimeColumn get startedAt => dateTime()();
DateTimeColumn get endedAt => dateTime()();
TextColumn get status => text()();
@override
Set<Column<Object>> get primaryKey => {id};
}
class HabitRows extends Table {
@override
String get tableName => 'habits';
TextColumn get id => text()();
TextColumn get title => text()();
IntColumn get sortOrder => integer()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get archivedAt => dateTime().nullable()();
@override
Set<Column<Object>> get primaryKey => {id};
}
class HabitLogRows extends Table {
@override
String get tableName => 'habit_logs';
TextColumn get id => text()();
TextColumn get habitId => text()();
TextColumn get dateKey => text()();
DateTimeColumn get completedAt => dateTime()();
@override
Set<Column<Object>> get primaryKey => {id};
}
class ReflectionRows extends Table {
@override
String get tableName => 'reflections';
TextColumn get id => text()();
TextColumn get dateKey => text()();
TextColumn get mood => text()();
TextColumn get note => text()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column<Object>> get primaryKey => {id};
}
class AppSettingRows extends Table {
@override
String get tableName => 'app_settings';
TextColumn get key => text()();
TextColumn get value => text()();
@override
Set<Column<Object>> get primaryKey => {key};
}
@DriftDatabase(
tables: [
DailyIntentionRows,
FocusSessionRows,
HabitRows,
HabitLogRows,
ReflectionRows,
AppSettingRows,
],
)
class FocusSeedDatabase extends _$FocusSeedDatabase {
FocusSeedDatabase([QueryExecutor? executor])
: super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
Future<void> clearProductData() async {
await transaction(() async {
await delete(habitLogRows).go();
await delete(reflectionRows).go();
await delete(focusSessionRows).go();
await delete(habitRows).go();
await delete(dailyIntentionRows).go();
});
}
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final directory = await getApplicationDocumentsDirectory();
final file = File(path.join(directory.path, 'focus_seed.sqlite'));
return NativeDatabase.createInBackground(file);
});
}
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import '../../application/focus_seed_controller.dart';
import '../../application/focus_timer_controller.dart';
import '../../data/focus_seed_store.dart';
import '../../l10n/l10n.dart';
import '../../models/appearance_preference.dart';
import '../../models/language_preference.dart';
@@ -18,6 +19,7 @@ class FocusSeedHome extends StatefulWidget {
required this.language,
required this.showLanguageTools,
required this.onLanguageChanged,
this.focusSeedStore,
super.key,
});
@@ -26,6 +28,7 @@ class FocusSeedHome extends StatefulWidget {
final LanguagePreference language;
final bool showLanguageTools;
final ValueChanged<LanguagePreference> onLanguageChanged;
final FocusSeedStore? focusSeedStore;
@override
State<FocusSeedHome> createState() => _FocusSeedHomeState();
@@ -43,6 +46,7 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
return;
}
_controller = FocusSeedController(
store: widget.focusSeedStore,
defaultHabitTitles: localizedStarterHabits(context.l10n),
);
_timerController = FocusTimerController(
+5 -1
View File
@@ -17,7 +17,11 @@ class AppState {
final List<FocusSession> sessions;
final List<ReflectionEntry> reflections;
bool get isFreshStart => habits.isEmpty && sessions.isEmpty && anchor.isEmpty;
bool get isFreshStart =>
habits.isEmpty &&
sessions.isEmpty &&
reflections.isEmpty &&
anchor.isEmpty;
String activeAnchor(String dateKey) {
return anchorDateKey == dateKey ? anchor : '';