- Complete MVP with Repository Pattern, SQLite storage - Provider + ChangeNotifier state management - Navigation 2.0 with deep link support - Habit CRUD with twoDayRule, notifications, categories - Backup/Restore via JSON - Statistics with streak tracking - Material You theme support - Biometric lock support - Desktop widget support - 27 languages i18n structure - Comprehensive test suite (87/89 passing)
364 lines
12 KiB
Dart
364 lines
12 KiB
Dart
import 'dart:collection';
|
|
import 'dart:io';
|
|
import 'package:sqflite_common/sqlite_api.dart';
|
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
import 'package:path/path.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:habo/constants.dart';
|
|
import 'package:habo/model/habit_data.dart';
|
|
import 'package:habo/model/category.dart';
|
|
|
|
class HaboModel {
|
|
static final HaboModel _instance = HaboModel._internal();
|
|
factory HaboModel() => _instance;
|
|
HaboModel._internal();
|
|
|
|
DatabaseFactory? _factory;
|
|
Database? _database;
|
|
|
|
static const int _dbVersion = 9;
|
|
|
|
DatabaseFactory get _dbFactory {
|
|
_factory ??= (Platform.isLinux || Platform.isMacOS)
|
|
? databaseFactoryFfi
|
|
: databaseFactory;
|
|
return _factory!;
|
|
}
|
|
|
|
Future<Database> get database async {
|
|
_database ??= await _initDatabase();
|
|
return _database!;
|
|
}
|
|
|
|
Future<Database> _initDatabase() async {
|
|
// For Linux/Mac, use application support directory
|
|
String dbPath;
|
|
if (Platform.isLinux || Platform.isMacOS) {
|
|
final dir = await getApplicationSupportDirectory();
|
|
dbPath = dir.path;
|
|
} else {
|
|
dbPath = await getDatabasesPath();
|
|
}
|
|
|
|
final path = join(dbPath, 'habo_db0.db');
|
|
return _dbFactory.openDatabase(
|
|
path,
|
|
options: OpenDatabaseOptions(
|
|
version: _dbVersion,
|
|
onCreate: _onCreate,
|
|
onUpgrade: _onUpgrade,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> initDatabase() async {
|
|
// Initialize FFI for desktop
|
|
if (Platform.isLinux || Platform.isMacOS) {
|
|
sqfliteFfiInit();
|
|
}
|
|
await database;
|
|
}
|
|
|
|
Future<void> _onCreate(Database db, int version) async {
|
|
await db.execute('''
|
|
CREATE TABLE habits (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
position INTEGER,
|
|
title TEXT,
|
|
twoDayRule INTEGER,
|
|
cue TEXT DEFAULT '',
|
|
routine TEXT DEFAULT '',
|
|
reward TEXT DEFAULT '',
|
|
showReward INTEGER,
|
|
advanced INTEGER,
|
|
notification INTEGER,
|
|
notTime TEXT,
|
|
sanction TEXT DEFAULT '',
|
|
showSanction INTEGER DEFAULT 0,
|
|
accountant TEXT DEFAULT '',
|
|
habitType INTEGER DEFAULT 0,
|
|
targetValue REAL DEFAULT 1.0,
|
|
partialValue REAL DEFAULT 1.0,
|
|
unit TEXT DEFAULT '',
|
|
archived INTEGER DEFAULT 0
|
|
)
|
|
''');
|
|
|
|
await db.execute('''
|
|
CREATE TABLE events (
|
|
id INTEGER,
|
|
dateTime TEXT,
|
|
dayType INTEGER,
|
|
comment TEXT DEFAULT '',
|
|
progressValue REAL DEFAULT 0.0,
|
|
targetValue REAL DEFAULT 0.0,
|
|
PRIMARY KEY(id, dateTime),
|
|
FOREIGN KEY (id) REFERENCES habits(id) ON DELETE CASCADE
|
|
)
|
|
''');
|
|
|
|
await db.execute('''
|
|
CREATE TABLE categories (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
iconCodePoint INTEGER NOT NULL,
|
|
fontFamily TEXT
|
|
)
|
|
''');
|
|
|
|
await db.execute('''
|
|
CREATE TABLE habit_categories (
|
|
habit_id INTEGER NOT NULL,
|
|
category_id INTEGER NOT NULL,
|
|
PRIMARY KEY (habit_id, category_id),
|
|
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
|
)
|
|
''');
|
|
}
|
|
|
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
|
if (oldVersion < 2) {
|
|
await db.execute("ALTER TABLE events ADD COLUMN comment TEXT DEFAULT ''");
|
|
}
|
|
if (oldVersion < 3) {
|
|
await db.execute("ALTER TABLE habits ADD COLUMN sanction TEXT DEFAULT ''");
|
|
await db.execute("ALTER TABLE habits ADD COLUMN showSanction INTEGER DEFAULT 0");
|
|
await db.execute("ALTER TABLE habits ADD COLUMN accountant TEXT DEFAULT ''");
|
|
}
|
|
if (oldVersion < 4) {
|
|
await db.execute("ALTER TABLE habits ADD COLUMN habitType INTEGER DEFAULT 0");
|
|
await db.execute("ALTER TABLE habits ADD COLUMN targetValue REAL DEFAULT 1.0");
|
|
await db.execute("ALTER TABLE habits ADD COLUMN partialValue REAL DEFAULT 1.0");
|
|
await db.execute("ALTER TABLE habits ADD COLUMN unit TEXT DEFAULT ''");
|
|
await db.execute("ALTER TABLE events ADD COLUMN progressValue REAL DEFAULT 0.0");
|
|
}
|
|
if (oldVersion < 5) {
|
|
await db.execute("ALTER TABLE events ADD COLUMN targetValue REAL DEFAULT 0.0");
|
|
await db.execute('''
|
|
CREATE TABLE IF NOT EXISTS categories (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
iconCodePoint INTEGER NOT NULL,
|
|
fontFamily TEXT
|
|
)
|
|
''');
|
|
await db.execute('''
|
|
CREATE TABLE IF NOT EXISTS habit_categories (
|
|
habit_id INTEGER NOT NULL,
|
|
category_id INTEGER NOT NULL,
|
|
PRIMARY KEY (habit_id, category_id),
|
|
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
|
)
|
|
''');
|
|
}
|
|
if (oldVersion < 6) {
|
|
await db.execute("ALTER TABLE habits ADD COLUMN archived INTEGER DEFAULT 0");
|
|
}
|
|
if (oldVersion < 7) {
|
|
await db.execute("ALTER TABLE categories ADD COLUMN fontFamily TEXT");
|
|
}
|
|
}
|
|
|
|
// ─── Habit CRUD ────────────────────────────────────────────────────────────
|
|
|
|
Future<List<Map<String, dynamic>>> getAllHabits() async {
|
|
final db = await database;
|
|
return db.query('habits', orderBy: 'position ASC');
|
|
}
|
|
|
|
Future<int> insertHabit(HabitData data) async {
|
|
final db = await database;
|
|
return db.insert('habits', _habitDataToMap(data));
|
|
}
|
|
|
|
Future<void> updateHabit(HabitData data) async {
|
|
final db = await database;
|
|
await db.update('habits', _habitDataToMap(data), where: 'id = ?', whereArgs: [data.id]);
|
|
}
|
|
|
|
Future<void> deleteHabit(int id) async {
|
|
final db = await database;
|
|
await db.delete('habits', where: 'id = ?', whereArgs: [id]);
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> getHabitById(int id) async {
|
|
final db = await database;
|
|
final results = await db.query('habits', where: 'id = ?', whereArgs: [id]);
|
|
return results.isNotEmpty ? results.first : null;
|
|
}
|
|
|
|
Future<void> updateHabitsOrder(List<dynamic> habits) async {
|
|
final db = await database;
|
|
final batch = db.batch();
|
|
for (int i = 0; i < habits.length; i++) {
|
|
final habit = habits[i];
|
|
final id = habit.habitData.id;
|
|
batch.update('habits', {'position': i}, where: 'id = ?', whereArgs: [id]);
|
|
}
|
|
await batch.commit(noResult: true);
|
|
}
|
|
|
|
Future<void> deleteAllHabits() async {
|
|
final db = await database;
|
|
await db.delete('habits');
|
|
}
|
|
|
|
// ─── Event CRUD ────────────────────────────────────────────────────────────
|
|
|
|
Future<List<List>> getEventsForHabit(int habitId) async {
|
|
final db = await database;
|
|
final results = await db.query(
|
|
'events',
|
|
where: 'id = ?',
|
|
whereArgs: [habitId],
|
|
orderBy: 'dateTime ASC',
|
|
);
|
|
return results.map((row) => _rowToEvent(row)).toList();
|
|
}
|
|
|
|
Future<SplayTreeMap<DateTime, List>> getEventsMapForHabit(int habitId) async {
|
|
final events = await getEventsForHabit(habitId);
|
|
final map = SplayTreeMap<DateTime, List>();
|
|
for (final event in events) {
|
|
if (event.isNotEmpty && event[0] is DateTime) {
|
|
map[event[0] as DateTime] = event.sublist(1);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
Future<void> insertEvent(int habitId, DateTime date, List event) async {
|
|
final db = await database;
|
|
final dayType = event[0] is DayType ? (event[0] as DayType).index : event[0];
|
|
final comment = event.length > 1 ? event[1] as String : '';
|
|
final progressValue = event.length > 2 ? (event[2] as num).toDouble() : 0.0;
|
|
final targetValue = event.length > 3 ? (event[3] as num).toDouble() : 0.0;
|
|
final dateStr = date.toIso8601String();
|
|
|
|
await db.insert(
|
|
'events',
|
|
{
|
|
'id': habitId,
|
|
'dateTime': dateStr,
|
|
'dayType': dayType,
|
|
'comment': comment,
|
|
'progressValue': progressValue,
|
|
'targetValue': targetValue,
|
|
},
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
Future<void> deleteEvent(int habitId, DateTime date) async {
|
|
final db = await database;
|
|
await db.delete(
|
|
'events',
|
|
where: 'id = ? AND dateTime = ?',
|
|
whereArgs: [habitId, date.toIso8601String()],
|
|
);
|
|
}
|
|
|
|
Future<void> deleteAllEventsForHabit(int habitId) async {
|
|
final db = await database;
|
|
await db.delete('events', where: 'id = ?', whereArgs: [habitId]);
|
|
}
|
|
|
|
Future<void> deleteAllEvents() async {
|
|
final db = await database;
|
|
await db.delete('events');
|
|
}
|
|
|
|
// ─── Category CRUD ────────────────────────────────────────────────────────
|
|
|
|
Future<List<Map<String, dynamic>>> getAllCategories() async {
|
|
final db = await database;
|
|
return db.query('categories');
|
|
}
|
|
|
|
Future<int> insertCategory(Category category) async {
|
|
final db = await database;
|
|
return db.insert('categories', category.toJson());
|
|
}
|
|
|
|
Future<void> updateCategory(Category category) async {
|
|
final db = await database;
|
|
await db.update('categories', category.toJson(), where: 'id = ?', whereArgs: [category.id]);
|
|
}
|
|
|
|
Future<void> deleteCategory(int id) async {
|
|
final db = await database;
|
|
await db.delete('categories', where: 'id = ?', whereArgs: [id]);
|
|
}
|
|
|
|
Future<void> updateHabitCategories(int habitId, List<Category> categories) async {
|
|
final db = await database;
|
|
await db.delete('habit_categories', where: 'habit_id = ?', whereArgs: [habitId]);
|
|
for (final cat in categories) {
|
|
if (cat.id != null) {
|
|
await db.insert('habit_categories', {'habit_id': habitId, 'category_id': cat.id});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> getCategoriesForHabit(int habitId) async {
|
|
final db = await database;
|
|
return db.rawQuery(
|
|
'SELECT c.* FROM categories c JOIN habit_categories hc ON c.id = hc.category_id WHERE hc.habit_id = ?',
|
|
[habitId],
|
|
);
|
|
}
|
|
|
|
Future<void> deleteAllCategories() async {
|
|
final db = await database;
|
|
await db.delete('habit_categories');
|
|
await db.delete('categories');
|
|
}
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
Map<String, dynamic> _habitDataToMap(HabitData data) {
|
|
return {
|
|
if (data.id != null) 'id': data.id,
|
|
'position': data.position,
|
|
'title': data.title,
|
|
'twoDayRule': data.twoDayRule ? 1 : 0,
|
|
'cue': data.cue,
|
|
'routine': data.routine,
|
|
'reward': data.reward,
|
|
'showReward': data.showReward ? 1 : 0,
|
|
'advanced': data.advanced ? 1 : 0,
|
|
'notification': data.notification ? 1 : 0,
|
|
'notTime': '${data.notTime.hour}:${data.notTime.minute}',
|
|
'sanction': data.sanction,
|
|
'showSanction': data.showSanction ? 1 : 0,
|
|
'accountant': data.accountant,
|
|
'habitType': data.habitType.index,
|
|
'targetValue': data.targetValue,
|
|
'partialValue': data.partialValue,
|
|
'unit': data.unit,
|
|
'archived': data.archived ? 1 : 0,
|
|
};
|
|
}
|
|
|
|
List _rowToEvent(Map<String, dynamic> row) {
|
|
final dateStr = row['dateTime'] as String;
|
|
final date = DateTime.parse(dateStr);
|
|
final dayTypeIndex = row['dayType'] as int;
|
|
final dayType = DayType.values[dayTypeIndex];
|
|
final comment = row['comment'] as String? ?? '';
|
|
final progressValue = (row['progressValue'] as num?)?.toDouble() ?? 0.0;
|
|
final targetValue = (row['targetValue'] as num?)?.toDouble() ?? 0.0;
|
|
return [date, dayType, comment, progressValue, targetValue];
|
|
}
|
|
|
|
Future<void> close() async {
|
|
final db = _database;
|
|
if (db != null) {
|
|
await db.close();
|
|
_database = null;
|
|
}
|
|
}
|
|
}
|