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)
This commit is contained in:
363
lib/model/habo_model.dart
Normal file
363
lib/model/habo_model.dart
Normal file
@@ -0,0 +1,363 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user