- Add watchAllActiveRecurringTasks() and watchAllActiveRecurringTasksInRoom() to CalendarDao - Add watchCompletionsInRange() for period-completion filtering - Extend CalendarDayState with prePopulatedTasks field (default empty, backward compat) - Update isEmpty getter to include prePopulatedTasks.isEmpty - Add _isInCurrentIntervalWindow() and _calculatePreviousDueDate() helpers - Rewrite calendarDayProvider and roomCalendarDayProvider with pre-population logic - Fix _subtractMonths() year-boundary bug using total-month arithmetic - Add 9 new DAO tests for watchAllActiveRecurringTasks, watchAllActiveRecurringTasksInRoom, watchCompletionsInRange
292 lines
11 KiB
Dart
292 lines
11 KiB
Dart
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<DateTime> {
|
|
@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, DateTime>(
|
|
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<TaskWithRoom> _sortTasks(
|
|
List<TaskWithRoom> tasks,
|
|
TaskSortOption sortOption,
|
|
) {
|
|
final sorted = List<TaskWithRoom>.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<CalendarDayState>((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<TaskWithRoom> 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 = <TaskWithRoom>[];
|
|
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<CalendarDayState, int>((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<TaskWithRoom> 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 = <TaskWithRoom>[];
|
|
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,
|
|
);
|
|
});
|
|
});
|