- 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)
442 lines
13 KiB
Dart
442 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:habo/constants.dart';
|
|
import 'package:habo/habits/habit.dart';
|
|
import 'package:habo/model/habit_data.dart';
|
|
import 'package:habo/model/category.dart';
|
|
import 'package:habo/repositories/habit_repository.dart';
|
|
import 'package:habo/repositories/event_repository.dart';
|
|
import 'package:habo/repositories/category_repository.dart';
|
|
import 'package:habo/services/backup_service.dart';
|
|
import 'package:habo/services/notification_service.dart';
|
|
import 'package:habo/services/ui_feedback_service.dart';
|
|
|
|
class HabitsManager extends ChangeNotifier {
|
|
final HabitRepository _habitRepository;
|
|
final EventRepository _eventRepository;
|
|
final CategoryRepository _categoryRepository;
|
|
final BackupService? _backupService;
|
|
final NotificationService? _notificationService;
|
|
final UIFeedbackService? _uiFeedbackService;
|
|
|
|
final List<Habit> _allHabits = [];
|
|
final List<Habit> _toDelete = [];
|
|
final List<Category> _categories = [];
|
|
|
|
List<Habit> get allHabits => _allHabits;
|
|
List<Habit> get toDelete => _toDelete;
|
|
List<Category> get categories => _categories;
|
|
|
|
List<Habit> get activeHabits =>
|
|
_allHabits.where((h) => !h.habitData.archived).toList();
|
|
|
|
List<Habit> get archivedHabits =>
|
|
_allHabits.where((h) => h.habitData.archived).toList();
|
|
|
|
HabitsManager({
|
|
required HabitRepository habitRepository,
|
|
required EventRepository eventRepository,
|
|
required CategoryRepository categoryRepository,
|
|
BackupService? backupService,
|
|
NotificationService? notificationService,
|
|
UIFeedbackService? uiFeedbackService,
|
|
}) : _habitRepository = habitRepository,
|
|
_eventRepository = eventRepository,
|
|
_categoryRepository = categoryRepository,
|
|
_backupService = backupService,
|
|
_notificationService = notificationService,
|
|
_uiFeedbackService = uiFeedbackService;
|
|
|
|
// ─── Initialization ────────────────────────────────────────────────────────
|
|
|
|
Future<void> initModel() async {
|
|
await loadHabits();
|
|
await loadCategories();
|
|
}
|
|
|
|
Future<void> loadHabits() async {
|
|
_allHabits.clear();
|
|
final habits = await _habitRepository.getAllHabits();
|
|
for (final habit in habits) {
|
|
try {
|
|
final events = await _eventRepository.getEventsMapForHabit(
|
|
habit.habitData.id ?? 0,
|
|
);
|
|
habit.habitData.events = events;
|
|
} catch (_) {
|
|
// If event loading fails, keep empty events
|
|
}
|
|
_allHabits.add(habit);
|
|
}
|
|
_allHabits.sort((a, b) => a.habitData.position.compareTo(b.habitData.position));
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> loadCategories() async {
|
|
_categories.clear();
|
|
try {
|
|
final cats = await _categoryRepository.getAllCategories();
|
|
_categories.addAll(cats);
|
|
} catch (_) {
|
|
// If category loading fails, keep empty
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
// ─── CRUD Operations ───────────────────────────────────────────────────────
|
|
|
|
void addHabit(
|
|
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 = HabitType.boolean,
|
|
double targetValue = 100.0,
|
|
double partialValue = 10.0,
|
|
String unit = '',
|
|
}) {
|
|
final habitData = HabitData(
|
|
position: _allHabits.length,
|
|
title: title,
|
|
twoDayRule: twoDayRule,
|
|
cue: cue,
|
|
routine: routine,
|
|
reward: reward,
|
|
showReward: showReward,
|
|
advanced: advanced,
|
|
notification: notification,
|
|
notTime: notTime,
|
|
sanction: sanction,
|
|
showSanction: showSanction,
|
|
accountant: accountant,
|
|
habitType: habitType,
|
|
targetValue: targetValue,
|
|
partialValue: partialValue,
|
|
unit: unit,
|
|
events: SplayTreeMap<DateTime, List>(),
|
|
);
|
|
|
|
final habit = Habit(habitData: habitData);
|
|
_habitRepository.createHabit(habit);
|
|
_allHabits.add(habit);
|
|
_notificationService?.resetNotifications(activeHabits);
|
|
notifyListeners();
|
|
}
|
|
|
|
void editHabit(HabitData data) {
|
|
final index = _allHabits.indexWhere((h) => h.habitData.id == data.id);
|
|
if (index != -1) {
|
|
_allHabits[index].habitData
|
|
..title = data.title
|
|
..twoDayRule = data.twoDayRule
|
|
..cue = data.cue
|
|
..routine = data.routine
|
|
..reward = data.reward
|
|
..showReward = data.showReward
|
|
..advanced = data.advanced
|
|
..notification = data.notification
|
|
..notTime = data.notTime
|
|
..sanction = data.sanction
|
|
..showSanction = data.showSanction
|
|
..accountant = data.accountant
|
|
..habitType = data.habitType
|
|
..targetValue = data.targetValue
|
|
..partialValue = data.partialValue
|
|
..unit = data.unit
|
|
..archived = data.archived;
|
|
|
|
_habitRepository.updateHabit(_allHabits[index]);
|
|
_notificationService?.resetNotifications(activeHabits);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void deleteHabit(int id) {
|
|
final habit = findHabitById(id);
|
|
if (habit != null) {
|
|
_toDelete.add(habit);
|
|
_allHabits.remove(habit);
|
|
_habitRepository.deleteHabit(id);
|
|
_notificationService?.removeNotifications(id);
|
|
_uiFeedbackService?.showMessageWithAction(
|
|
message: 'Habit deleted.',
|
|
actionLabel: 'Undo',
|
|
onActionPressed: () => undoDelete(),
|
|
backgroundColor: Colors.red,
|
|
);
|
|
updateOrder();
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void undoDelete() {
|
|
if (_toDelete.isNotEmpty) {
|
|
final habit = _toDelete.removeLast();
|
|
_allHabits.add(habit);
|
|
updateOrder();
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
// ─── Archive Operations ────────────────────────────────────────────────────
|
|
|
|
void archiveHabit(int id) {
|
|
final habit = findHabitById(id);
|
|
if (habit != null) {
|
|
habit.habitData.archived = true;
|
|
_habitRepository.updateHabit(habit);
|
|
_notificationService?.disableHabitNotification(id);
|
|
_uiFeedbackService?.showMessageWithAction(
|
|
message: 'Habit archived',
|
|
actionLabel: 'Undo',
|
|
onActionPressed: () => unarchiveHabit(id),
|
|
backgroundColor: Colors.orange,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void unarchiveHabit(int id) {
|
|
final habit = findHabitById(id);
|
|
if (habit != null) {
|
|
habit.habitData.archived = false;
|
|
_habitRepository.updateHabit(habit);
|
|
if (habit.habitData.notification) {
|
|
_notificationService?.setHabitNotification(
|
|
id,
|
|
habit.habitData.notTime,
|
|
'Habo',
|
|
habit.habitData.title,
|
|
);
|
|
}
|
|
_uiFeedbackService?.showSuccess('Habit unarchived');
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
// ─── Reorder ───────────────────────────────────────────────────────────────
|
|
|
|
void reorderList(int oldIndex, int newIndex) {
|
|
if (oldIndex < newIndex) newIndex--;
|
|
final item = _allHabits.removeAt(oldIndex);
|
|
_allHabits.insert(newIndex, item);
|
|
updateOrder();
|
|
_habitRepository.updateHabitsOrder(_allHabits);
|
|
notifyListeners();
|
|
}
|
|
|
|
void updateOrder() {
|
|
for (int i = 0; i < _allHabits.length; i++) {
|
|
_allHabits[i].habitData.position = i;
|
|
}
|
|
// In-memory position update only - persistence handled separately
|
|
}
|
|
|
|
// ─── Event Operations ──────────────────────────────────────────────────────
|
|
|
|
void addEvent(int id, DateTime date, List event) {
|
|
final key = DateTime(date.year, date.month, date.day);
|
|
|
|
// Update in-memory if habit exists
|
|
final habit = findHabitById(id);
|
|
if (habit != null) {
|
|
habit.habitData.events[key] = event;
|
|
_updateLastStreak(habit.habitData);
|
|
}
|
|
|
|
// Always persist to repository
|
|
try {
|
|
_eventRepository.insertEvent(id, key, event);
|
|
} catch (_) {}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
void deleteEvent(int id, DateTime date) {
|
|
final key = DateTime(date.year, date.month, date.day);
|
|
|
|
// Update in-memory if habit exists
|
|
final habit = findHabitById(id);
|
|
if (habit != null) {
|
|
habit.habitData.events.remove(key);
|
|
_updateLastStreak(habit.habitData);
|
|
}
|
|
|
|
// Always persist to repository
|
|
try {
|
|
_eventRepository.deleteEvent(id, key);
|
|
} catch (_) {}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
void _updateLastStreak(HabitData data) {
|
|
final events = data.events;
|
|
if (events.isEmpty) {
|
|
data.streak = 0;
|
|
data.streakVisible = false;
|
|
data.orangeStreak = false;
|
|
return;
|
|
}
|
|
|
|
if (data.twoDayRule) {
|
|
_updateLastStreakTwoDay(data);
|
|
} else {
|
|
_updateLastStreakNormal(data);
|
|
}
|
|
}
|
|
|
|
void _updateLastStreakNormal(HabitData data) {
|
|
final events = data.events;
|
|
if (events.isEmpty) {
|
|
data.streak = 0;
|
|
data.streakVisible = false;
|
|
data.orangeStreak = false;
|
|
return;
|
|
}
|
|
|
|
int inStreak = 0;
|
|
final dates = events.keys.toList().reversed.toList();
|
|
|
|
for (int i = 0; i < dates.length; i++) {
|
|
final date = dates[i];
|
|
final event = events[date]!;
|
|
final dayType = event[0] is DayType ? event[0] as DayType : DayType.values[event[0] as int];
|
|
|
|
if (dayType == DayType.clear) continue;
|
|
|
|
if (i > 0) {
|
|
final prevDate = dates[i - 1];
|
|
final diff = prevDate.difference(date).inDays;
|
|
if (diff > 1) break;
|
|
}
|
|
|
|
if (dayType == DayType.check) {
|
|
inStreak++;
|
|
} else if (dayType == DayType.progress && event.length >= 4) {
|
|
final progressValue = event[2] as double? ?? 0.0;
|
|
final target = event[3] as double? ?? data.targetValue;
|
|
if (progressValue >= target) inStreak++;
|
|
} else if (dayType == DayType.fail || dayType == DayType.skip) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
data.streak = inStreak;
|
|
data.streakVisible = inStreak >= 2;
|
|
data.orangeStreak = false;
|
|
}
|
|
|
|
void _updateLastStreakTwoDay(HabitData data) {
|
|
final events = data.events;
|
|
if (events.isEmpty) {
|
|
data.streak = 0;
|
|
data.streakVisible = false;
|
|
data.orangeStreak = false;
|
|
return;
|
|
}
|
|
|
|
int inStreak = 0;
|
|
bool usingTwoDayRule = false;
|
|
final dates = events.keys.toList().reversed.toList();
|
|
|
|
for (int i = 0; i < dates.length; i++) {
|
|
final date = dates[i];
|
|
final event = events[date]!;
|
|
final dayType = event[0] is DayType ? event[0] as DayType : DayType.values[event[0] as int];
|
|
|
|
if (dayType == DayType.clear) continue;
|
|
|
|
if (i > 0) {
|
|
final prevDate = dates[i - 1];
|
|
final diff = prevDate.difference(date).inDays;
|
|
if (diff > 1) break;
|
|
}
|
|
|
|
if (dayType == DayType.check) {
|
|
inStreak++;
|
|
usingTwoDayRule = false;
|
|
} else if (dayType == DayType.progress && event.length >= 4) {
|
|
final progressValue = event[2] as double? ?? 0.0;
|
|
final target = event[3] as double? ?? data.targetValue;
|
|
if (progressValue >= target) {
|
|
inStreak++;
|
|
usingTwoDayRule = false;
|
|
}
|
|
} else if (dayType == DayType.fail) {
|
|
if (usingTwoDayRule) {
|
|
break;
|
|
}
|
|
usingTwoDayRule = true;
|
|
} else if (dayType == DayType.skip) {
|
|
if (usingTwoDayRule) break;
|
|
// Skip doesn't affect streak
|
|
}
|
|
}
|
|
|
|
data.streak = inStreak;
|
|
data.streakVisible = inStreak >= 2;
|
|
data.orangeStreak = usingTwoDayRule;
|
|
}
|
|
|
|
// ─── Utility ───────────────────────────────────────────────────────────────
|
|
|
|
Habit? findHabitById(int id) {
|
|
try {
|
|
return _allHabits.firstWhere((h) => h.habitData.id == id);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String getNameOfHabit(int id) {
|
|
final habit = findHabitById(id);
|
|
return habit?.habitData.title ?? '';
|
|
}
|
|
|
|
// ─── Backup & Widget ───────────────────────────────────────────────────────
|
|
|
|
Future<void> createBackup() async {
|
|
await _backupService?.createDatabaseBackup();
|
|
}
|
|
|
|
Future<void> loadBackup(String path) async {
|
|
await _backupService?.loadBackup(path);
|
|
await loadHabits();
|
|
}
|
|
|
|
void resetNotifications([List<dynamic>? habits]) {
|
|
_notificationService?.resetNotifications(habits ?? activeHabits);
|
|
}
|
|
|
|
void updateHomeWidget() {
|
|
// Stub
|
|
}
|
|
|
|
// ─── Category ──────────────────────────────────────────────────────────────
|
|
|
|
Future<void> addCategory(Category category) async {
|
|
await _categoryRepository.createCategory(category);
|
|
await loadCategories();
|
|
}
|
|
|
|
Future<void> updateCategory(Category category) async {
|
|
await _categoryRepository.updateCategory(category);
|
|
await loadCategories();
|
|
}
|
|
|
|
Future<void> deleteCategory(int id) async {
|
|
await _categoryRepository.deleteCategory(id);
|
|
await loadCategories();
|
|
}
|
|
}
|