import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:household_keeper/core/providers/database_provider.dart'; import 'package:household_keeper/features/home/domain/calendar_models.dart'; import 'package:household_keeper/features/home/domain/daily_plan_models.dart'; import 'package:household_keeper/features/tasks/domain/frequency.dart'; import 'package:household_keeper/features/tasks/domain/task_sort_option.dart'; import 'package:household_keeper/features/tasks/presentation/sort_preference_notifier.dart'; import '../../../core/database/database.dart'; /// Notifier that manages the currently selected date in the calendar strip. /// /// Defaults to today (start of day, time zeroed out). /// NOT autoDispose — the selected date persists while the app is alive. class SelectedDateNotifier extends Notifier { @override DateTime build() { final now = DateTime.now(); return DateTime(now.year, now.month, now.day); } /// Update the selected date (always normalized to start of day). void selectDate(DateTime date) { state = DateTime(date.year, date.month, date.day); } } /// Provider for the currently selected date in the calendar strip. final selectedDateProvider = NotifierProvider( SelectedDateNotifier.new, ); /// Sort a list of [TaskWithRoom] by the given [sortOption]. /// /// Returns a new sorted list; never mutates the original. /// Only [dayTasks] are sorted — the overdue section stays in its existing /// order per user decision. List _sortTasks( List tasks, TaskSortOption sortOption, ) { final sorted = List.from(tasks); switch (sortOption) { case TaskSortOption.alphabetical: sorted.sort((a, b) => a.task.name.toLowerCase().compareTo( b.task.name.toLowerCase(), )); case TaskSortOption.interval: sorted.sort((a, b) { final cmp = a.task.intervalType.index.compareTo( b.task.intervalType.index, ); if (cmp != 0) return cmp; return a.task.intervalDays.compareTo(b.task.intervalDays); }); case TaskSortOption.effort: sorted.sort((a, b) => a.task.effortLevel.index.compareTo( b.task.effortLevel.index, )); } return sorted; } /// Determines whether [task] should appear on [selectedDate] via pre-population. /// /// Per D-07: A task shows on all days within the current interval window /// leading up to its nextDueDate. For example: /// - weekly task due Monday: shows on all 7 days (Tue-Mon) before nextDueDate /// - monthly task due 15th: shows on all ~30 days leading up to the 15th /// - daily task: shows every day (interval window = 1 day, always due today) bool _isInCurrentIntervalWindow(Task task, DateTime selectedDate) { final dueDate = DateTime( task.nextDueDate.year, task.nextDueDate.month, task.nextDueDate.day); final selected = DateTime(selectedDate.year, selectedDate.month, selectedDate.day); // If selected date IS the due date, it is a "due today" task (not pre-populated) if (selected == dueDate) return false; // If selected date is after the due date, task is overdue (handled separately) if (selected.isAfter(dueDate)) return false; // Calculate the start of the current interval window: // The previous due date = current nextDueDate minus one interval final previousDue = _calculatePreviousDueDate(task); final prevDay = DateTime( previousDue.year, previousDue.month, previousDue.day); // Selected date must be AFTER the previous due date (exclusive) // and BEFORE the next due date (exclusive — due date itself is "dayTasks" not pre-pop) return selected.isAfter(prevDay) && selected.isBefore(dueDate); } /// Reverse-calculate the previous due date by subtracting one interval. /// This gives the start of the current interval window. DateTime _calculatePreviousDueDate(Task task) { switch (task.intervalType) { case IntervalType.daily: return task.nextDueDate.subtract(const Duration(days: 1)); case IntervalType.everyNDays: return task.nextDueDate.subtract(Duration(days: task.intervalDays)); case IntervalType.weekly: return task.nextDueDate.subtract(const Duration(days: 7)); case IntervalType.biweekly: return task.nextDueDate.subtract(const Duration(days: 14)); case IntervalType.monthly: return _subtractMonths(task.nextDueDate, 1, task.anchorDay); case IntervalType.everyNMonths: return _subtractMonths(task.nextDueDate, task.intervalDays, task.anchorDay); case IntervalType.quarterly: return _subtractMonths(task.nextDueDate, 3, task.anchorDay); case IntervalType.yearly: return _subtractMonths(task.nextDueDate, 12, task.anchorDay); } } /// Subtract [months] from [date], respecting [anchorDay] clamping. /// /// Uses total-month arithmetic to handle year-boundary crossings correctly /// (e.g. January - 1 month = December of the previous year). DateTime _subtractMonths(DateTime date, int months, int? anchorDay) { // Convert date to total months from year 0 (0-indexed month), subtract, convert back. final totalMonths = date.year * 12 + (date.month - 1) - months; final targetYear = totalMonths ~/ 12; final normalizedMonth = (totalMonths % 12) + 1; final day = anchorDay ?? date.day; final lastDay = DateTime(targetYear, normalizedMonth + 1, 0).day; final clampedDay = day > lastDay ? lastDay : day; return DateTime(targetYear, normalizedMonth, clampedDay); } /// Reactive calendar day state: tasks for the selected date + overdue tasks /// + pre-populated virtual instances within the current interval window. /// /// Overdue tasks are only included when the selected date is today. /// Past and future dates show only tasks originally due on that day. /// /// dayTasks are sorted in-memory according to the active [sortPreferenceProvider]. /// overdueTasks retain their existing order (pinned at top, unsorted per design). /// prePopulatedTasks show tasks visible via interval-window pre-population. /// /// Defined manually (not @riverpod) because riverpod_generator has trouble /// with drift's generated [Task] type. Same pattern as [dailyPlanProvider]. final calendarDayProvider = StreamProvider.autoDispose((ref) { final db = ref.watch(appDatabaseProvider); final selectedDate = ref.watch(selectedDateProvider); final sortOption = ref.watch(sortPreferenceProvider); final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final isToday = selectedDate == today; final dayTasksStream = db.calendarDao.watchTasksForDate(selectedDate); final allTasksStream = db.calendarDao.watchAllActiveRecurringTasks(); return dayTasksStream.asyncMap((dayTasks) async { final List overdueTasks; if (isToday) { // When viewing today, include overdue tasks (due before today) overdueTasks = await db.calendarDao.watchOverdueTasks(selectedDate).first; } else { // Past or future dates: no overdue carry-over overdueTasks = const []; } // Get all active tasks for pre-population filtering final allTasks = await allTasksStream.first; // IDs of tasks already showing as due-today or overdue final dueTodayIds = dayTasks.map((t) => t.task.id).toSet(); final overdueIds = overdueTasks.map((t) => t.task.id).toSet(); // Filter for pre-populated tasks final prePopulated = []; for (final tw in allTasks) { // Skip if already showing as due-today or overdue if (dueTodayIds.contains(tw.task.id)) continue; if (overdueIds.contains(tw.task.id)) continue; // Check if in current interval window if (!_isInCurrentIntervalWindow(tw.task, selectedDate)) continue; // Check if already completed in current period (D-09, D-10) final prevDue = _calculatePreviousDueDate(tw.task); final completions = await db.calendarDao .watchCompletionsInRange( tw.task.id, DateTime(prevDue.year, prevDue.month, prevDue.day), DateTime(tw.task.nextDueDate.year, tw.task.nextDueDate.month, tw.task.nextDueDate.day) .add(const Duration(days: 1)), ) .first; if (completions.isEmpty) { prePopulated.add(tw); } } final totalTaskCount = await db.calendarDao.getTaskCount(); return CalendarDayState( selectedDate: selectedDate, dayTasks: _sortTasks(dayTasks, sortOption), overdueTasks: overdueTasks, prePopulatedTasks: _sortTasks(prePopulated, sortOption), totalTaskCount: totalTaskCount, ); }); }); /// Room-scoped calendar day state: tasks for the selected date within a room. /// /// Mirrors [calendarDayProvider] but filters by [roomId]. /// Uses the shared [selectedDateProvider] so date selection is consistent /// across HomeScreen and room views. final roomCalendarDayProvider = StreamProvider.autoDispose.family((ref, roomId) { final db = ref.watch(appDatabaseProvider); final selectedDate = ref.watch(selectedDateProvider); final sortOption = ref.watch(sortPreferenceProvider); final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final isToday = selectedDate == today; final dayTasksStream = db.calendarDao.watchTasksForDateInRoom(selectedDate, roomId); final allTasksStream = db.calendarDao.watchAllActiveRecurringTasksInRoom(roomId); return dayTasksStream.asyncMap((dayTasks) async { final List overdueTasks; if (isToday) { overdueTasks = await db.calendarDao .watchOverdueTasksInRoom(selectedDate, roomId) .first; } else { overdueTasks = const []; } // Get all active tasks in room for pre-population filtering final allTasks = await allTasksStream.first; // IDs of tasks already showing as due-today or overdue final dueTodayIds = dayTasks.map((t) => t.task.id).toSet(); final overdueIds = overdueTasks.map((t) => t.task.id).toSet(); // Filter for pre-populated tasks final prePopulated = []; for (final tw in allTasks) { // Skip if already showing as due-today or overdue if (dueTodayIds.contains(tw.task.id)) continue; if (overdueIds.contains(tw.task.id)) continue; // Check if in current interval window if (!_isInCurrentIntervalWindow(tw.task, selectedDate)) continue; // Check if already completed in current period (D-09, D-10) final prevDue = _calculatePreviousDueDate(tw.task); final completions = await db.calendarDao .watchCompletionsInRange( tw.task.id, DateTime(prevDue.year, prevDue.month, prevDue.day), DateTime(tw.task.nextDueDate.year, tw.task.nextDueDate.month, tw.task.nextDueDate.day) .add(const Duration(days: 1)), ) .first; if (completions.isEmpty) { prePopulated.add(tw); } } final totalTaskCount = await db.calendarDao.getTaskCountInRoom(roomId); return CalendarDayState( selectedDate: selectedDate, dayTasks: _sortTasks(dayTasks, sortOption), overdueTasks: overdueTasks, prePopulatedTasks: _sortTasks(prePopulated, sortOption), totalTaskCount: totalTaskCount, ); }); });