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 : '';
+369 -1
View File
@@ -1,6 +1,30 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
url: "https://pub.flutter-io.cn"
source: hosted
version: "88.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.1.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@@ -17,6 +41,54 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.6"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.15.0"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.12.6"
characters:
dependency: transitive
description:
@@ -25,6 +97,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.3"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -41,6 +137,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@@ -49,6 +161,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.8"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.2"
drift:
dependency: "direct main"
description:
name: drift
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.31.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.31.0"
fake_async:
dependency: transitive
description:
@@ -73,6 +209,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -101,6 +245,38 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
@@ -109,6 +285,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.19.0"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.5"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
@@ -141,6 +333,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -165,14 +365,54 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.16.0"
path:
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.19"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@@ -213,6 +453,38 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.0"
shared_preferences:
dependency: "direct main"
description:
@@ -269,11 +541,35 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.2.0"
source_span:
dependency: transitive
description:
@@ -282,6 +578,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.1"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.9.4"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.5.42"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.43.1"
stack_trace:
dependency: transitive
description:
@@ -298,6 +618,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@@ -322,6 +650,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.4"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
@@ -338,6 +674,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "14.3.1"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
@@ -346,6 +690,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
@@ -354,6 +714,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.7.2 <4.0.0"
flutter: ">=3.29.0"
+6
View File
@@ -38,10 +38,16 @@ dependencies:
cupertino_icons: ^1.0.8
intl: any
shared_preferences: ^2.5.3
drift: ^2.31.0
sqlite3_flutter_libs: ^0.5.42
path_provider: ^2.1.5
path: ^1.9.1
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.10.4
drift_dev: ^2.31.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
+115
View File
@@ -0,0 +1,115 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_seed/src/data/focus_seed_store.dart';
import 'package:focus_seed/src/data/local/focus_seed_database.dart';
import 'package:focus_seed/src/models/app_state.dart';
import 'package:focus_seed/src/models/focus_session.dart';
import 'package:focus_seed/src/models/habit.dart';
import 'package:focus_seed/src/models/mood.dart';
import 'package:focus_seed/src/models/reflection_entry.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('new installs seed localized starter habits into sqlite', () async {
final database = FocusSeedDatabase(NativeDatabase.memory());
addTearDown(database.close);
final store = FocusSeedStore(database: database);
final state = await store.load(
defaultHabitTitles: const ['Plan', 'Focus', 'Reflect'],
);
expect(state.anchor, isEmpty);
expect(state.habits.map((habit) => habit.title), [
'Plan',
'Focus',
'Reflect',
]);
});
test(
'sqlite snapshot round-trips focus, habit, and reflection data',
() async {
final database = FocusSeedDatabase(NativeDatabase.memory());
addTearDown(database.close);
final store = FocusSeedStore(database: database);
final now = DateTime(2026, 6, 12, 10, 30);
final state = AppState(
anchor: 'Ship the local database',
anchorDateKey: '2026-06-12',
habits: [
Habit(
id: 'habit-1',
title: 'Review the plan',
createdAt: now.subtract(const Duration(days: 2)),
completedDates: const {'2026-06-11', '2026-06-12'},
),
],
sessions: [
FocusSession(id: 'session-1', minutes: 25, completedAt: now),
],
reflections: [
ReflectionEntry(
id: 'reflection-1',
dateKey: '2026-06-12',
note: 'Small scope kept it clean.',
mood: Mood.clear,
createdAt: now.add(const Duration(minutes: 1)),
),
],
);
await store.save(state);
final loaded = await store.load();
expect(loaded.anchor, 'Ship the local database');
expect(loaded.anchorDateKey, '2026-06-12');
expect(loaded.habits.single.completedDates, {'2026-06-11', '2026-06-12'});
expect(loaded.sessions.single.minutes, 25);
expect(loaded.reflections.single.note, 'Small scope kept it clean.');
},
);
test('legacy shared preferences json migrates once and is removed', () async {
final legacyState = AppState(
anchor: 'Migrate safely',
anchorDateKey: '2026-06-12',
habits: [
Habit(
id: 'habit-legacy',
title: 'Keep existing data',
createdAt: DateTime(2026, 6, 10),
completedDates: const {'2026-06-12'},
),
],
sessions: [
FocusSession(
id: 'session-legacy',
minutes: 15,
completedAt: DateTime(2026, 6, 12, 8),
),
],
reflections: const [],
);
SharedPreferences.setMockInitialValues({
'focus_seed_state_v1': jsonEncode(legacyState.toJson()),
});
final database = FocusSeedDatabase(NativeDatabase.memory());
addTearDown(database.close);
final store = FocusSeedStore(database: database);
final migrated = await store.load();
final preferences = await SharedPreferences.getInstance();
expect(migrated.anchor, 'Migrate safely');
expect(migrated.habits.single.title, 'Keep existing data');
expect(migrated.sessions.single.minutes, 15);
expect(preferences.getString('focus_seed_state_v1'), isNull);
});
}
+20 -10
View File
@@ -1,6 +1,9 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_seed/src/app/focus_seed_app.dart';
import 'package:focus_seed/src/data/focus_seed_store.dart';
import 'package:focus_seed/src/data/local/focus_seed_database.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
@@ -14,8 +17,7 @@ void main() {
});
testWidgets('Today guides the user from intention to focus', (tester) async {
await tester.pumpWidget(const FocusSeedApp(locale: Locale('en')));
await tester.pumpAndSettle();
await pumpFocusSeedApp(tester, locale: const Locale('en'));
expect(find.text('Focus Seed'), findsOneWidget);
expect(find.text("TODAY'S INTENTION"), findsOneWidget);
@@ -35,8 +37,7 @@ void main() {
});
testWidgets('bottom navigation exposes Journey and Me', (tester) async {
await tester.pumpWidget(const FocusSeedApp(locale: Locale('en')));
await tester.pumpAndSettle();
await pumpFocusSeedApp(tester, locale: const Locale('en'));
await tester.tap(find.text('Journey'));
await tester.pumpAndSettle();
@@ -60,8 +61,7 @@ void main() {
tester.platformDispatcher.clearTextScaleFactorTestValue();
});
await tester.pumpWidget(const FocusSeedApp(locale: Locale('en')));
await tester.pumpAndSettle();
await pumpFocusSeedApp(tester, locale: const Locale('en'));
expect(find.text('Focus Seed'), findsOneWidget);
expect(find.byType(Scrollable), findsWidgets);
@@ -69,8 +69,7 @@ void main() {
});
testWidgets('development default uses concise Chinese copy', (tester) async {
await tester.pumpWidget(const FocusSeedApp());
await tester.pumpAndSettle();
await pumpFocusSeedApp(tester);
expect(find.text('Focus Seed'), findsOneWidget);
expect(find.text('\u4eca\u65e5\u76ee\u6807'), findsOneWidget);
@@ -88,8 +87,7 @@ void main() {
testWidgets('development language switch changes the active locale', (
tester,
) async {
await tester.pumpWidget(const FocusSeedApp());
await tester.pumpAndSettle();
await pumpFocusSeedApp(tester);
await tester.tap(find.text('\u6211\u7684'));
await tester.pumpAndSettle();
@@ -102,3 +100,15 @@ void main() {
expect(find.text('Language'), findsOneWidget);
});
}
Future<void> pumpFocusSeedApp(WidgetTester tester, {Locale? locale}) async {
final database = FocusSeedDatabase(NativeDatabase.memory());
addTearDown(database.close);
await tester.pumpWidget(
FocusSeedApp(
locale: locale,
focusSeedStore: FocusSeedStore(database: database),
),
);
await tester.pumpAndSettle();
}