Files
habo/lib/model/habo_model.dart
dazhuang aa69f2a91e feat: initial commit - Habo habit tracking app
- 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)
2026-04-13 15:02:30 +00:00

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;
}
}
}