Files
habo/lib/habits/habits_manager.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

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