feat: add local database persistence
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
Reference in New Issue
Block a user