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:
2026-04-13 15:02:30 +00:00
commit aa69f2a91e
212 changed files with 16694 additions and 0 deletions

25
lib/model/backup.dart Normal file
View File

@@ -0,0 +1,25 @@
class BackupData {
final int version;
final List<Map<String, dynamic>> habits;
final List<Map<String, dynamic>> categories;
final List<Map<String, dynamic>> habitCategories;
final Map<String, dynamic> metadata;
BackupData({
this.version = 3,
this.habits = const [],
this.categories = const [],
this.habitCategories = const [],
this.metadata = const {},
});
Map<String, dynamic> toJson() {
return {
'version': version,
'habits': habits,
'categories': categories,
'habit_categories': habitCategories,
'metadata': metadata,
};
}
}

31
lib/model/category.dart Normal file
View File

@@ -0,0 +1,31 @@
class Category {
int? id;
String title;
int iconCodePoint;
String? fontFamily;
Category({
this.id,
this.title = '',
this.iconCodePoint = 0,
this.fontFamily,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'iconCodePoint': iconCodePoint,
'fontFamily': fontFamily,
};
}
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'] as int?,
title: json['title'] as String? ?? '',
iconCodePoint: json['iconCodePoint'] as int? ?? 0,
fontFamily: json['fontFamily'] as String?,
);
}
}

179
lib/model/habit_data.dart Normal file
View File

@@ -0,0 +1,179 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:habo/constants.dart';
import 'package:habo/model/category.dart';
class HabitData {
int? id;
int position;
String title;
bool twoDayRule;
String cue;
String routine;
String reward;
bool showReward;
bool advanced;
bool notification;
TimeOfDay notTime;
String sanction;
bool showSanction;
String accountant;
HabitType habitType;
double targetValue;
double partialValue;
String unit;
bool archived;
SplayTreeMap<DateTime, List> events;
List<Category> categories;
// Runtime computed fields
int streak;
bool streakVisible;
bool orangeStreak;
HabitData({
this.id,
this.position = 0,
this.title = '',
this.twoDayRule = false,
this.cue = '',
this.routine = '',
this.reward = '',
this.showReward = false,
this.advanced = false,
this.notification = false,
this.notTime = const TimeOfDay(hour: 20, minute: 0),
this.sanction = '',
this.showSanction = false,
this.accountant = '',
this.habitType = HabitType.boolean,
this.targetValue = 100.0,
this.partialValue = 10.0,
this.unit = '',
this.archived = false,
SplayTreeMap<DateTime, List>? events,
this.categories = const [],
this.streak = 0,
this.streakVisible = false,
this.orangeStreak = false,
}) : events = events ?? SplayTreeMap<DateTime, List>();
bool isCompletedForDate(DateTime date) {
final key = DateTime(date.year, date.month, date.day);
final event = events[key];
if (event == null) return false;
final dayType = event[0] as DayType;
if (dayType == DayType.check) return true;
if (dayType == DayType.progress && event.length >= 4) {
final progressValue = event[2] as double? ?? 0.0;
final target = event[3] as double? ?? targetValue;
return progressValue >= target;
}
return false;
}
double getProgressForDate(DateTime date) {
final key = DateTime(date.year, date.month, date.day);
final event = events[key];
if (event == null || event.length < 3) return 0.0;
return event[2] as double? ?? 0.0;
}
double getProgressPercentage(DateTime date) {
final key = DateTime(date.year, date.month, date.day);
final event = events[key];
if (event == null || event.length < 4) return 0.0;
final progressValue = event[2] as double? ?? 0.0;
final target = event[3] as double? ?? targetValue;
if (target == 0) return 0.0;
return (progressValue / target).clamp(0.0, 1.0);
}
Map<String, dynamic> toJson() {
final eventsJson = <String, dynamic>{};
for (final entry in events.entries) {
eventsJson[entry.key.toIso8601String()] = [
entry.value[0] is DayType ? (entry.value[0] as DayType).index : entry.value[0],
if (entry.value.length > 1) entry.value[1] else '',
if (entry.value.length > 2) entry.value[2],
if (entry.value.length > 3) entry.value[3],
];
}
return {
'id': id,
'position': position,
'title': title,
'twoDayRule': twoDayRule,
'cue': cue,
'routine': routine,
'reward': reward,
'showReward': showReward,
'advanced': advanced,
'notification': notification,
'notTime': {'hour': notTime.hour, 'minute': notTime.minute},
'sanction': sanction,
'showSanction': showSanction,
'accountant': accountant,
'habitType': habitType.index,
'targetValue': targetValue,
'partialValue': partialValue,
'unit': unit,
'archived': archived,
'events': eventsJson,
};
}
factory HabitData.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
final events = SplayTreeMap<DateTime, List>();
for (final entry in eventsJson.entries) {
try {
final date = DateTime.parse(entry.key);
final value = entry.value as List;
final dayType = value[0] is int ? DayType.values[value[0] as int] : value[0];
final event = [
dayType,
if (value.length > 1) value[1] else '',
if (value.length > 2) value[2] else 0.0,
if (value.length > 3) value[3] else 0.0,
];
events[date] = event;
} catch (_) {}
}
final notTimeJson = json['notTime'];
TimeOfDay notTime;
if (notTimeJson is Map) {
notTime = TimeOfDay(
hour: notTimeJson['hour'] as int? ?? 20,
minute: notTimeJson['minute'] as int? ?? 0,
);
} else {
notTime = const TimeOfDay(hour: 20, minute: 0);
}
return HabitData(
id: json['id'] as int?,
position: json['position'] as int? ?? 0,
title: json['title'] as String? ?? '',
twoDayRule: json['twoDayRule'] as bool? ?? false,
cue: json['cue'] as String? ?? '',
routine: json['routine'] as String? ?? '',
reward: json['reward'] as String? ?? '',
showReward: json['showReward'] as bool? ?? false,
advanced: json['advanced'] as bool? ?? false,
notification: json['notification'] as bool? ?? false,
notTime: notTime,
sanction: json['sanction'] as String? ?? '',
showSanction: json['showSanction'] as bool? ?? false,
accountant: json['accountant'] as String? ?? '',
habitType: HabitType.values[json['habitType'] as int? ?? 0],
targetValue: (json['targetValue'] as num?)?.toDouble() ?? 100.0,
partialValue: (json['partialValue'] as num?)?.toDouble() ?? 10.0,
unit: json['unit'] as String? ?? '',
archived: json['archived'] as bool? ?? false,
events: events,
);
}
}

363
lib/model/habo_model.dart Normal file
View 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;
}
}
}

View File

@@ -0,0 +1,50 @@
class SettingsData {
// Appearance
String theme;
String weekStart;
bool showMonthName;
bool showCategories;
// Notifications
bool showDailyNot;
int notTimeHour;
int notTimeMinute;
// Sound
bool soundEffects;
double soundVolume;
// Security
bool biometricLock;
bool oneTapCheck;
// Onboarding
bool seenOnboarding;
String lastWhatsNewVersion;
// Custom Colors
int checkColor;
int failColor;
int skipColor;
int progressColor;
SettingsData({
this.theme = 'device',
this.weekStart = 'monday',
this.showMonthName = true,
this.showCategories = true,
this.showDailyNot = true,
this.notTimeHour = 20,
this.notTimeMinute = 0,
this.soundEffects = true,
this.soundVolume = 3.0,
this.biometricLock = false,
this.oneTapCheck = false,
this.seenOnboarding = false,
this.lastWhatsNewVersion = '',
this.checkColor = 0xFF09BF30,
this.failColor = 0xFFF44336,
this.skipColor = 0xFFFBC02D,
this.progressColor = 0xFF2196F3,
});
}