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 get database async { _database ??= await _initDatabase(); return _database!; } Future _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 initDatabase() async { // Initialize FFI for desktop if (Platform.isLinux || Platform.isMacOS) { sqfliteFfiInit(); } await database; } Future _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 _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>> getAllHabits() async { final db = await database; return db.query('habits', orderBy: 'position ASC'); } Future insertHabit(HabitData data) async { final db = await database; return db.insert('habits', _habitDataToMap(data)); } Future updateHabit(HabitData data) async { final db = await database; await db.update('habits', _habitDataToMap(data), where: 'id = ?', whereArgs: [data.id]); } Future deleteHabit(int id) async { final db = await database; await db.delete('habits', where: 'id = ?', whereArgs: [id]); } Future?> 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 updateHabitsOrder(List 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 deleteAllHabits() async { final db = await database; await db.delete('habits'); } // ─── Event CRUD ──────────────────────────────────────────────────────────── Future> 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> getEventsMapForHabit(int habitId) async { final events = await getEventsForHabit(habitId); final map = SplayTreeMap(); for (final event in events) { if (event.isNotEmpty && event[0] is DateTime) { map[event[0] as DateTime] = event.sublist(1); } } return map; } Future 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 deleteEvent(int habitId, DateTime date) async { final db = await database; await db.delete( 'events', where: 'id = ? AND dateTime = ?', whereArgs: [habitId, date.toIso8601String()], ); } Future deleteAllEventsForHabit(int habitId) async { final db = await database; await db.delete('events', where: 'id = ?', whereArgs: [habitId]); } Future deleteAllEvents() async { final db = await database; await db.delete('events'); } // ─── Category CRUD ──────────────────────────────────────────────────────── Future>> getAllCategories() async { final db = await database; return db.query('categories'); } Future insertCategory(Category category) async { final db = await database; return db.insert('categories', category.toJson()); } Future updateCategory(Category category) async { final db = await database; await db.update('categories', category.toJson(), where: 'id = ?', whereArgs: [category.id]); } Future deleteCategory(int id) async { final db = await database; await db.delete('categories', where: 'id = ?', whereArgs: [id]); } Future updateHabitCategories(int habitId, List 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>> 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 deleteAllCategories() async { final db = await database; await db.delete('habit_categories'); await db.delete('categories'); } // ─── Helpers ─────────────────────────────────────────────────────────────── Map _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 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 close() async { final db = _database; if (db != null) { await db.close(); _database = null; } } }