chore: archive v1.0 phase directories to milestones/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,695 @@
|
||||
# Phase 3: Daily Plan and Cleanliness - Research
|
||||
|
||||
**Researched:** 2026-03-16
|
||||
**Domain:** Flutter daily plan screen with cross-room Drift queries, animated task completion, sectioned list UI, progress indicators
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 3 transforms the placeholder Home tab into the app's primary "daily plan" screen -- the first thing users see when opening HouseHoldKeaper. The screen needs three key capabilities: (1) a cross-room Drift query that watches all tasks and categorizes them by due date (overdue, today, tomorrow), (2) animated task removal on checkbox completion with the existing `TasksDao.completeTask()` logic, and (3) a progress indicator card showing "X von Y erledigt" that updates in real-time.
|
||||
|
||||
The existing codebase provides strong foundations: `TasksDao.completeTask()` already handles completion + scheduling in a single transaction, `formatRelativeDate()` produces German date labels, `TaskRow` provides the baseline task row widget (needs adaptation), and the Riverpod stream provider pattern is well-established. The main new work is: (a) a new DAO method that joins tasks with rooms for cross-room queries, (b) a `DailyPlanTaskRow` widget variant with room name tag and no row-tap navigation, (c) `AnimatedList` for slide-out completion animation, (d) `ExpansionTile` for the collapsible "Demnachst" section, and (e) new localization strings.
|
||||
|
||||
CLEAN-01 (cleanliness indicator on room cards) is already fully implemented in Phase 2 via `RoomWithStats.cleanlinessRatio` and the `LinearProgressIndicator` bar at the bottom of `RoomCard`. This requirement needs only verification, not new implementation.
|
||||
|
||||
**Primary recommendation:** Add a single new DAO method `watchAllTasksWithRoomName()` that joins tasks with rooms, expose it through a manual `StreamProvider` (same pattern as `tasksInRoomProvider` due to drift type issues), derive overdue/today/tomorrow categorization in the provider layer, and build the daily plan screen as a `CustomScrollView` with `SliverAnimatedList` for animated completion.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- **Daily plan screen structure**: Single scroll list with three section headers: Uberfaellig, Heute, Demnachst. Flat task list within each section -- tasks are not grouped under room sub-headers. Each task row shows room name as an inline tappable tag that navigates to that room's task list. Progress indicator at the very top as a prominent card/banner ("5 von 12 erledigt") -- first thing the user sees. Overdue section only appears when there are overdue tasks. Demnachst section is collapsed by default -- shows header with count (e.g. "Demnachst (4)"), expands on tap. PLAN-01 "grouped by room" is satisfied by room name shown on each task -- not visual sub-grouping.
|
||||
- **Task completion on daily plan**: Checkbox only -- no swipe-to-complete gesture. Consistent with Phase 2 room task list. Completed tasks animate out of the list (slide away). Progress counter updates immediately. No navigation from tapping task rows -- the daily plan is a focused "get things done" screen. Only the checkbox and the room name tag are interactive. Completion behavior identical to Phase 2: immediate, no undo, records timestamp, auto-calculates next due date.
|
||||
- **Upcoming tasks scope**: Tomorrow only -- Demnachst shows tasks due the next calendar day. Read-only preview -- no checkboxes, tasks cannot be completed ahead of schedule from the daily plan. Collapsed by default to keep focus on today's actionable tasks.
|
||||
|
||||
### Claude's Discretion
|
||||
- "All clear" empty state design (follow Phase 1's playful, emoji-friendly German tone with the established visual pattern: Material icon + message + optional action)
|
||||
- Task row adaptation for daily plan context (may differ from TaskRow in room view since no row-tap navigation and room name tag is added)
|
||||
- Exact animation for task completion (slide direction, duration, easing)
|
||||
- Progress card/banner visual design (linear progress bar, circular, or text-only)
|
||||
- Section header styling and the collapsed/expanded toggle for Demnachst
|
||||
- How overdue tasks are sorted within the flat list (most overdue first, or by room, or alphabetical)
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None -- discussion stayed within phase scope
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| PLAN-01 | User sees all tasks due today grouped by room on the daily plan screen | New DAO join query `watchAllTasksWithRoomName()`, flat list with room name tag on each row satisfies "grouped by room" per CONTEXT.md |
|
||||
| PLAN-02 | Overdue tasks appear in a separate highlighted section at the top | Provider-layer date categorization splits tasks into overdue/today/tomorrow sections; overdue section conditionally rendered |
|
||||
| PLAN-03 | User can preview upcoming tasks (tomorrow) | Demnachst section with `ExpansionTile` collapsed by default, read-only rows (no checkbox) |
|
||||
| PLAN-04 | User can checkbox to mark tasks done from daily plan | Reuse existing `taskActionsProvider.completeTask()`, `AnimatedList.removeItem()` for slide-out animation |
|
||||
| PLAN-05 | Progress indicator showing completed vs total tasks today | Computed from stream data: `completedToday` count from `TaskCompletions` + `totalToday` from due tasks. Progress card at top of screen |
|
||||
| PLAN-06 | "All clear" empty state when no tasks are due | Established empty state pattern (Material icon + message + optional action) in German |
|
||||
| CLEAN-01 | Each room card displays cleanliness indicator | Already implemented in Phase 2 via `RoomWithStats.cleanlinessRatio` and `RoomCard` -- verification only |
|
||||
</phase_requirements>
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (already in project)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| drift | 2.31.0 | Type-safe SQLite ORM with join queries | Already established; provides `leftOuterJoin`, `.watch()` streams for cross-room task queries |
|
||||
| flutter_riverpod | 3.3.1 | State management | Already established; `StreamProvider` pattern for reactive daily plan data |
|
||||
| riverpod_annotation | 4.0.2 | Provider code generation | `@riverpod` for generated providers |
|
||||
| go_router | 17.1.0 | Declarative routing | Room name tag navigation uses `context.go('/rooms/$roomId')` |
|
||||
| flutter (SDK) | 3.41.1 | Framework | Provides `AnimatedList`, `ExpansionTile`, `LinearProgressIndicator` |
|
||||
|
||||
### New Dependencies
|
||||
None. Phase 3 uses only Flutter built-in widgets and existing project dependencies.
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `AnimatedList` with manual state sync | Simple `ListView` with `AnimatedSwitcher` | `AnimatedList` provides proper slide-out with `removeItem()`. `AnimatedSwitcher` only fades, no size collapse. Use `AnimatedList`. |
|
||||
| `ExpansionTile` for Demnachst | Custom `AnimatedContainer` | `ExpansionTile` is Material 3 native, handles expand/collapse animation, arrow icon, and state automatically. No reason to hand-roll. |
|
||||
| `LinearProgressIndicator` in card | `CircularProgressIndicator` or custom radial | Linear is simpler, more glanceable for "X von Y" context, and matches the progress bar pattern already on room cards. Use linear. |
|
||||
| Drift join query | In-memory join via multiple stream providers | Drift join runs in SQLite, more efficient for large task counts, and produces a single reactive stream. In-memory join requires watching two streams and combining, more complex. |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
lib/
|
||||
features/
|
||||
home/
|
||||
data/
|
||||
daily_plan_dao.dart # New DAO for cross-room task queries
|
||||
daily_plan_dao.g.dart
|
||||
domain/
|
||||
daily_plan_models.dart # TaskWithRoom data class, DailyPlanState
|
||||
presentation/
|
||||
home_screen.dart # Replace current placeholder (COMPLETE REWRITE)
|
||||
daily_plan_providers.dart # Riverpod providers for daily plan data
|
||||
daily_plan_providers.g.dart
|
||||
daily_plan_task_row.dart # Task row variant for daily plan context
|
||||
progress_card.dart # "X von Y erledigt" progress banner
|
||||
tasks/
|
||||
data/
|
||||
tasks_dao.dart # Unchanged -- reuse completeTask()
|
||||
presentation/
|
||||
task_providers.dart # Unchanged -- reuse taskActionsProvider
|
||||
l10n/
|
||||
app_de.arb # Add ~10 new localization keys
|
||||
```
|
||||
|
||||
### Pattern 1: Drift Join Query for Tasks with Room Name
|
||||
**What:** A DAO method that joins the tasks table with rooms to produce task objects paired with their room name, watched as a reactive stream.
|
||||
**When to use:** Daily plan needs all tasks across all rooms with room name for display.
|
||||
**Example:**
|
||||
```dart
|
||||
// Source: drift.simonbinder.eu/dart_api/select/ (join documentation)
|
||||
|
||||
/// A task paired with its room name for daily plan display.
|
||||
class TaskWithRoom {
|
||||
final Task task;
|
||||
final String roomName;
|
||||
final int roomId;
|
||||
|
||||
const TaskWithRoom({
|
||||
required this.task,
|
||||
required this.roomName,
|
||||
required this.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
@DriftAccessor(tables: [Tasks, Rooms, TaskCompletions])
|
||||
class DailyPlanDao extends DatabaseAccessor<AppDatabase>
|
||||
with _$DailyPlanDaoMixin {
|
||||
DailyPlanDao(super.attachedDatabase);
|
||||
|
||||
/// Watch all tasks joined with room name, sorted by nextDueDate ascending.
|
||||
Stream<List<TaskWithRoom>> watchAllTasksWithRoomName() {
|
||||
final query = select(tasks).join([
|
||||
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
|
||||
]);
|
||||
query.orderBy([OrderingTerm.asc(tasks.nextDueDate)]);
|
||||
|
||||
return query.watch().map((rows) {
|
||||
return rows.map((row) {
|
||||
final task = row.readTable(tasks);
|
||||
final room = row.readTable(rooms);
|
||||
return TaskWithRoom(
|
||||
task: task,
|
||||
roomName: room.name,
|
||||
roomId: room.id,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Count completions recorded today (for progress tracking).
|
||||
/// Counts tasks completed today regardless of their current due date.
|
||||
Stream<int> watchCompletionsToday({DateTime? now}) {
|
||||
final today = now ?? DateTime.now();
|
||||
final startOfDay = DateTime(today.year, today.month, today.day);
|
||||
final endOfDay = startOfDay.add(const Duration(days: 1));
|
||||
|
||||
final query = selectOnly(taskCompletions)
|
||||
..addColumns([taskCompletions.id.count()])
|
||||
..where(taskCompletions.completedAt.isBiggerOrEqualValue(startOfDay) &
|
||||
taskCompletions.completedAt.isSmallerThanValue(endOfDay));
|
||||
|
||||
return query.watchSingle().map((row) {
|
||||
return row.read(taskCompletions.id.count()) ?? 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Provider-Layer Date Categorization
|
||||
**What:** A Riverpod provider that watches the raw task stream and categorizes tasks into overdue, today, and tomorrow sections. Also computes progress stats.
|
||||
**When to use:** Transforming flat task data into the three-section daily plan structure.
|
||||
**Example:**
|
||||
```dart
|
||||
/// Daily plan data categorized into sections.
|
||||
class DailyPlanState {
|
||||
final List<TaskWithRoom> overdueTasks;
|
||||
final List<TaskWithRoom> todayTasks;
|
||||
final List<TaskWithRoom> tomorrowTasks;
|
||||
final int completedTodayCount;
|
||||
final int totalTodayCount; // overdue + today (actionable tasks)
|
||||
|
||||
const DailyPlanState({
|
||||
required this.overdueTasks,
|
||||
required this.todayTasks,
|
||||
required this.tomorrowTasks,
|
||||
required this.completedTodayCount,
|
||||
required this.totalTodayCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Manual StreamProvider (same pattern as tasksInRoomProvider)
|
||||
// due to drift Task type issue with riverpod_generator
|
||||
final dailyPlanProvider =
|
||||
StreamProvider.autoDispose<DailyPlanState>((ref) {
|
||||
final db = ref.watch(appDatabaseProvider);
|
||||
final taskStream = db.dailyPlanDao.watchAllTasksWithRoomName();
|
||||
final completionStream = db.dailyPlanDao.watchCompletionsToday();
|
||||
|
||||
// Combine both streams using Dart's asyncMap pattern
|
||||
return taskStream.asyncMap((allTasks) async {
|
||||
// Get today's completion count (latest value)
|
||||
final completedToday = await db.dailyPlanDao
|
||||
.watchCompletionsToday().first;
|
||||
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final tomorrow = today.add(const Duration(days: 1));
|
||||
final dayAfterTomorrow = tomorrow.add(const Duration(days: 1));
|
||||
|
||||
final overdue = <TaskWithRoom>[];
|
||||
final todayList = <TaskWithRoom>[];
|
||||
final tomorrowList = <TaskWithRoom>[];
|
||||
|
||||
for (final tw in allTasks) {
|
||||
final dueDate = DateTime(
|
||||
tw.task.nextDueDate.year,
|
||||
tw.task.nextDueDate.month,
|
||||
tw.task.nextDueDate.day,
|
||||
);
|
||||
if (dueDate.isBefore(today)) {
|
||||
overdue.add(tw);
|
||||
} else if (dueDate.isBefore(tomorrow)) {
|
||||
todayList.add(tw);
|
||||
} else if (dueDate.isBefore(dayAfterTomorrow)) {
|
||||
tomorrowList.add(tw);
|
||||
}
|
||||
}
|
||||
|
||||
return DailyPlanState(
|
||||
overdueTasks: overdue, // already sorted by dueDate from query
|
||||
todayTasks: todayList,
|
||||
tomorrowTasks: tomorrowList,
|
||||
completedTodayCount: completedToday,
|
||||
totalTodayCount: overdue.length + todayList.length + completedToday,
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 3: AnimatedList for Task Completion Slide-Out
|
||||
**What:** Use `AnimatedList` with `GlobalKey<AnimatedListState>` to animate task removal when checkbox is tapped. The removed item slides horizontally and collapses vertically.
|
||||
**When to use:** Overdue and Today sections where tasks have checkboxes.
|
||||
**Example:**
|
||||
```dart
|
||||
// Slide-out animation for completed tasks
|
||||
void _onTaskCompleted(int index, TaskWithRoom taskWithRoom) {
|
||||
// 1. Trigger database completion (fire-and-forget)
|
||||
ref.read(taskActionsProvider.notifier).completeTask(taskWithRoom.task.id);
|
||||
|
||||
// 2. Animate the item out of the list
|
||||
_listKey.currentState?.removeItem(
|
||||
index,
|
||||
(context, animation) {
|
||||
// Combine slide + size collapse for smooth exit
|
||||
return SizeTransition(
|
||||
sizeFactor: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0), // slide right
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeInOut,
|
||||
)),
|
||||
child: DailyPlanTaskRow(
|
||||
taskWithRoom: taskWithRoom,
|
||||
showCheckbox: true,
|
||||
onCompleted: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: ExpansionTile for Collapsible Demnachst Section
|
||||
**What:** Use Flutter's built-in `ExpansionTile` for the "Demnachst (N)" collapsible section. Starts collapsed per user decision.
|
||||
**When to use:** Tomorrow tasks section that is read-only and collapsed by default.
|
||||
**Example:**
|
||||
```dart
|
||||
ExpansionTile(
|
||||
initiallyExpanded: false,
|
||||
title: Text(
|
||||
'${l10n.dailyPlanUpcoming} (${tomorrowTasks.length})',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
children: tomorrowTasks.map((tw) => DailyPlanTaskRow(
|
||||
taskWithRoom: tw,
|
||||
showCheckbox: false, // read-only, no completion from daily plan
|
||||
onRoomTap: () => context.go('/rooms/${tw.roomId}'),
|
||||
)).toList(),
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 5: Progress Card with LinearProgressIndicator
|
||||
**What:** A card/banner at the top of the daily plan showing "X von Y erledigt" with a linear progress bar beneath.
|
||||
**When to use:** First widget in the daily plan scroll, shows today's completion progress.
|
||||
**Example:**
|
||||
```dart
|
||||
class ProgressCard extends StatelessWidget {
|
||||
final int completed;
|
||||
final int total;
|
||||
|
||||
const ProgressCard({
|
||||
super.key,
|
||||
required this.completed,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final progress = total > 0 ? completed / total : 0.0;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.dailyPlanProgress(completed, total),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 8,
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Using `AnimatedList` for ALL sections (including Demnachst):** The tomorrow section is read-only -- no items are added or removed dynamically. A plain `Column` inside `ExpansionTile` is simpler and avoids unnecessary `GlobalKey` management.
|
||||
- **Rebuilding `AnimatedList` on every stream emission:** `AnimatedList` requires imperative `insertItem`/`removeItem` calls. Rebuilding the widget discards animation state. The list must synchronize imperatively with stream data, OR use a simpler approach where completion removes from a local list and the stream handles the rest after animation completes.
|
||||
- **Using a single monolithic `AnimatedList` for all three sections:** Each section has different behavior (overdue: checkbox, today: checkbox, tomorrow: no checkbox, collapsible). Use separate widgets per section.
|
||||
- **Computing progress from stream-only data:** After completing a task, it moves to a future due date and disappears from "today". The completion count must come from `TaskCompletions` table (tasks completed today), not from the absence of tasks in the stream.
|
||||
- **Navigating on task row tap:** Per user decision, daily plan task rows have NO row-tap navigation. Only the checkbox and room name tag are interactive. Do NOT reuse `TaskRow` directly -- it has `onTap` navigating to edit form.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Expand/collapse section | Custom `AnimatedContainer` + boolean state | `ExpansionTile` | Material 3 native, handles animation, arrow icon, state persistence automatically |
|
||||
| Cross-room task query | Multiple stream providers + in-memory merge | Drift `join` query with `.watch()` | Single SQLite query is more efficient and produces one reactive stream |
|
||||
| Progress bar | Custom `CustomPainter` circular indicator | `LinearProgressIndicator` with `value` | Built-in Material 3 widget, themed automatically, supports determinate mode |
|
||||
| Slide-out animation | Manual `AnimationController` per row | `AnimatedList.removeItem()` with `SizeTransition` + `SlideTransition` | Framework handles list index bookkeeping and animation lifecycle |
|
||||
| Date categorization | Separate DB queries per category | Single query + in-memory partitioning | One Drift stream for all tasks, partitioned in Dart. Fewer database watchers, simpler invalidation |
|
||||
|
||||
**Key insight:** Phase 3 is primarily a presentation-layer phase. The data layer changes are minimal (one new DAO method + registering the DAO). Most complexity is in the UI: synchronizing `AnimatedList` state with reactive stream data, and building a polished sectioned scroll view with proper animation.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: AnimatedList State Desynchronization
|
||||
**What goes wrong:** `AnimatedList` requires imperative `insertItem()`/`removeItem()` calls to stay in sync with the data. If the widget rebuilds from a new stream emission while an animation is in progress, the list state and data diverge, causing index-out-of-bounds or duplicate item errors.
|
||||
**Why it happens:** `AnimatedList` maintains its own internal item count. Stream emissions update the data list independently. If a completion triggers both an `AnimatedList.removeItem()` call AND a stream re-emission (because Drift sees the task's `nextDueDate` changed), the item gets "removed" twice.
|
||||
**How to avoid:** Use one of two approaches: (A) Optimistic local list: maintain a local `List<TaskWithRoom>` that is initialized from the stream but modified locally on completion. Only re-sync from the stream when a new emission arrives that differs from the local state. (B) Simpler approach: skip `AnimatedList` entirely and use `AnimatedSwitcher` per item with `SizeTransition`, or use `AnimatedList` only for the removal animation then let the stream rebuild the list normally after animation completes. Approach (B) is simpler -- animate out, then after the animation duration, the stream naturally excludes the completed task.
|
||||
**Warning signs:** "RangeError: index out of range" or items flickering during completion animation.
|
||||
|
||||
### Pitfall 2: Progress Count Accuracy After Completion
|
||||
**What goes wrong:** Counting "completed today" by subtracting current due tasks from an initial count loses accuracy. A task completed today moves its `nextDueDate` to a future date, so it disappears from both overdue and today. Without tracking completions separately, the progress denominator shrinks and the bar appears to jump backward.
|
||||
**Why it happens:** The total tasks due today changes as tasks are completed (they move to future dates). If you compute `total = overdue.length + today.length`, the total decreases with each completion, making progress misleading (e.g., 0/5 -> complete one -> 0/4 instead of 1/5).
|
||||
**How to avoid:** Track `completedTodayCount` from the `TaskCompletions` table (count of completions where `completedAt` is today). Compute `totalToday = remainingOverdue + remainingToday + completedTodayCount`. This way, as tasks are completed, `completedTodayCount` increases and `remaining` decreases, keeping the total stable.
|
||||
**Warning signs:** Progress bar shows "0 von 3 erledigt", complete one task, shows "0 von 2 erledigt" instead of "1 von 3 erledigt".
|
||||
|
||||
### Pitfall 3: Drift Stream Over-Emission on Cross-Table Join
|
||||
**What goes wrong:** A join query watching both `tasks` and `rooms` tables re-fires whenever ANY write happens to either table -- not just relevant rows. Room reordering, for example, triggers a daily plan re-query even though no task data changed.
|
||||
**Why it happens:** Drift's stream invalidation is table-level, not row-level.
|
||||
**How to avoid:** This is generally acceptable at household-app scale (dozens of tasks, not thousands). If needed, use `ref.select()` in the widget to avoid rebuilding when the data hasn't meaningfully changed. Alternatively, `distinctUntilChanged` on the stream (using `ListEquality` from `collection` package) prevents redundant widget rebuilds.
|
||||
**Warning signs:** Daily plan screen rebuilds when user reorders rooms on the Rooms tab.
|
||||
|
||||
### Pitfall 4: Empty State vs Loading State Confusion
|
||||
**What goes wrong:** Showing the "all clear" empty state while data is still loading gives users a false impression that nothing is due.
|
||||
**Why it happens:** `AsyncValue.when()` with `data: []` is indistinguishable from "no tasks at all" vs "tasks haven't loaded yet" if not handled carefully.
|
||||
**How to avoid:** Always handle `loading`, `error`, and `data` states in `asyncValue.when()`. Show a subtle progress indicator during loading. Only show the "all clear" empty state when `data` is loaded AND both overdue and today lists are empty.
|
||||
**Warning signs:** App briefly flashes "Alles erledigt!" on startup before tasks load.
|
||||
|
||||
### Pitfall 5: Room Name Tag Navigation Conflict with Tab Shell
|
||||
**What goes wrong:** Tapping the room name tag on a daily plan task should navigate to that room's task list, but `context.go('/rooms/$roomId')` is on a different tab branch. The navigation switches tabs, which may lose scroll position on the Home tab.
|
||||
**Why it happens:** GoRouter's `StatefulShellRoute.indexedStack` preserves tab state, but `context.go('/rooms/$roomId')` navigates within the Rooms branch, switching the active tab.
|
||||
**How to avoid:** This is actually the desired behavior -- the user explicitly tapped the room tag to navigate there. The Home tab state is preserved by `indexedStack` and will be restored when the user taps back to the Home tab. No special handling needed beyond using `context.go('/rooms/$roomId')`.
|
||||
**Warning signs:** None expected -- this is standard GoRouter tab behavior.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Complete DailyPlanDao with Join Query
|
||||
```dart
|
||||
// Source: drift.simonbinder.eu/dart_api/select/ (joins documentation)
|
||||
import 'package:drift/drift.dart';
|
||||
import '../../../core/database/database.dart';
|
||||
|
||||
part 'daily_plan_dao.g.dart';
|
||||
|
||||
/// A task paired with its room for daily plan display.
|
||||
class TaskWithRoom {
|
||||
final Task task;
|
||||
final String roomName;
|
||||
final int roomId;
|
||||
|
||||
const TaskWithRoom({
|
||||
required this.task,
|
||||
required this.roomName,
|
||||
required this.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
@DriftAccessor(tables: [Tasks, Rooms, TaskCompletions])
|
||||
class DailyPlanDao extends DatabaseAccessor<AppDatabase>
|
||||
with _$DailyPlanDaoMixin {
|
||||
DailyPlanDao(super.attachedDatabase);
|
||||
|
||||
/// Watch all tasks joined with room name, sorted by nextDueDate ascending.
|
||||
/// Includes ALL tasks (overdue, today, future) -- filtering is done in the
|
||||
/// provider layer to avoid multiple queries.
|
||||
Stream<List<TaskWithRoom>> watchAllTasksWithRoomName() {
|
||||
final query = select(tasks).join([
|
||||
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
|
||||
]);
|
||||
query.orderBy([OrderingTerm.asc(tasks.nextDueDate)]);
|
||||
|
||||
return query.watch().map((rows) {
|
||||
return rows.map((row) {
|
||||
final task = row.readTable(tasks);
|
||||
final room = row.readTable(rooms);
|
||||
return TaskWithRoom(
|
||||
task: task,
|
||||
roomName: room.name,
|
||||
roomId: room.id,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Count task completions recorded today.
|
||||
Stream<int> watchCompletionsToday({DateTime? today}) {
|
||||
final now = today ?? DateTime.now();
|
||||
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||
final endOfDay = startOfDay.add(const Duration(days: 1));
|
||||
|
||||
return customSelect(
|
||||
'SELECT COUNT(*) AS c FROM task_completions '
|
||||
'WHERE completed_at >= ? AND completed_at < ?',
|
||||
variables: [Variable(startOfDay), Variable(endOfDay)],
|
||||
readsFrom: {taskCompletions},
|
||||
).watchSingle().map((row) => row.read<int>('c'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Daily Plan Task Row (Adapted from TaskRow)
|
||||
```dart
|
||||
// Source: existing TaskRow pattern adapted per CONTEXT.md decisions
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Warm coral/terracotta color for overdue styling (reused from TaskRow).
|
||||
const _overdueColor = Color(0xFFE07A5F);
|
||||
|
||||
class DailyPlanTaskRow extends ConsumerWidget {
|
||||
const DailyPlanTaskRow({
|
||||
super.key,
|
||||
required this.taskWithRoom,
|
||||
required this.showCheckbox,
|
||||
this.onCompleted,
|
||||
});
|
||||
|
||||
final TaskWithRoom taskWithRoom;
|
||||
final bool showCheckbox; // false for tomorrow (read-only)
|
||||
final VoidCallback? onCompleted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final task = taskWithRoom.task;
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final dueDate = DateTime(
|
||||
task.nextDueDate.year,
|
||||
task.nextDueDate.month,
|
||||
task.nextDueDate.day,
|
||||
);
|
||||
final isOverdue = dueDate.isBefore(today);
|
||||
final relativeDateText = formatRelativeDate(task.nextDueDate, now);
|
||||
|
||||
return ListTile(
|
||||
leading: showCheckbox
|
||||
? Checkbox(
|
||||
value: false,
|
||||
onChanged: (_) => onCompleted?.call(),
|
||||
)
|
||||
: null,
|
||||
title: Text(
|
||||
task.name,
|
||||
style: theme.textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
// Room name tag (tappable)
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/rooms/${taskWithRoom.roomId}'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
taskWithRoom.roomName,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
relativeDateText,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isOverdue
|
||||
? _overdueColor
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// NO onTap -- daily plan is a focused "get things done" screen
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### "All Clear" Empty State
|
||||
```dart
|
||||
// Source: established empty state pattern from HomeScreen and TaskListScreen
|
||||
Widget _buildAllClearState(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.celebration_outlined,
|
||||
size: 80,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l10n.dailyPlanAllClearTitle,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.dailyPlanAllClearMessage,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### New Localization Keys (app_de.arb additions)
|
||||
```json
|
||||
{
|
||||
"dailyPlanProgress": "{completed} von {total} erledigt",
|
||||
"@dailyPlanProgress": {
|
||||
"placeholders": {
|
||||
"completed": { "type": "int" },
|
||||
"total": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"dailyPlanSectionOverdue": "Ueberfaellig",
|
||||
"dailyPlanSectionToday": "Heute",
|
||||
"dailyPlanSectionUpcoming": "Demnachst",
|
||||
"dailyPlanUpcomingCount": "Demnachst ({count})",
|
||||
"@dailyPlanUpcomingCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"dailyPlanAllClearTitle": "Alles erledigt!",
|
||||
"dailyPlanAllClearMessage": "Keine Aufgaben fuer heute. Geniesse den Moment!"
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Multiple separate DB queries for overdue/today/tomorrow | Single join query with in-memory partitioning | Best practice with Drift 2.x | Fewer database watchers, one stream invalidation path |
|
||||
| `rxdart` `CombineLatest` for merging streams | `ref.watch()` on multiple providers in Riverpod 3 | Riverpod 3.0 (Sep 2025) | `ref.watch(provider.stream)` removed; use computed providers instead |
|
||||
| `ExpansionTileController` | `ExpansibleController` (Flutter 3.32+) | Flutter 3.32 | `ExpansionTileController` deprecated in favor of `ExpansibleController` |
|
||||
| `LinearProgressIndicator` 2023 design | `year2023: false` for 2024 design spec | Flutter 3.41+ | New design with rounded corners, gap between tracks. Still defaulting to 2023 design unless opted in. |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `ExpansionTileController`: Deprecated after Flutter 3.31. Use `ExpansibleController` for programmatic expand/collapse. However, `ExpansionTile` still works with `initiallyExpanded` without needing a controller.
|
||||
- `ref.watch(provider.stream)`: Removed in Riverpod 3. Cannot access underlying stream directly. Use `ref.watch` on the provider value instead.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **AnimatedList vs simpler approach for completion animation**
|
||||
- What we know: `AnimatedList` provides proper `removeItem()` with animation, but requires imperative state management that can desync with Drift streams.
|
||||
- What's unclear: Whether the complexity of `AnimatedList` + stream synchronization is worth it vs a simpler approach (e.g., `AnimatedSwitcher` wrapping each item, or just letting items disappear on stream re-emission).
|
||||
- Recommendation: Use `AnimatedList` for overdue + today sections. On completion, call `removeItem()` for the slide-out animation, then let the stream naturally update. The stream re-emission after the animation completes will be a no-op if the item is already gone. Use a local copy of the list to avoid desync -- only update from stream when the local list is stale. This is manageable because the daily plan list is typically small (< 30 items).
|
||||
|
||||
2. **Progress count accuracy edge case: midnight rollover**
|
||||
- What we know: "Today" is `DateTime.now()` at query time. If the app stays open past midnight, "today" shifts.
|
||||
- What's unclear: Whether the daily plan should auto-refresh at midnight or require app restart.
|
||||
- Recommendation: Not critical for v1. The stream re-fires on any DB write. The user will see stale "today" data only if they leave the app open overnight without interacting. Acceptable for a household app.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | flutter_test (built-in) |
|
||||
| Config file | none -- standard Flutter test setup |
|
||||
| Quick run command | `flutter test test/features/home/` |
|
||||
| Full suite command | `flutter test` |
|
||||
|
||||
### Phase Requirements -> Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| PLAN-01 | Cross-room query returns tasks with room names | unit | `flutter test test/features/home/data/daily_plan_dao_test.dart` | Wave 0 |
|
||||
| PLAN-02 | Tasks with nextDueDate before today categorized as overdue | unit | `flutter test test/features/home/data/daily_plan_dao_test.dart` | Wave 0 |
|
||||
| PLAN-03 | Tasks due tomorrow returned in upcoming list | unit | `flutter test test/features/home/data/daily_plan_dao_test.dart` | Wave 0 |
|
||||
| PLAN-04 | Completing a task via DAO records completion and updates due date | unit | Already covered by `test/features/tasks/data/tasks_dao_test.dart` | Exists |
|
||||
| PLAN-05 | Completions today count matches actual completions | unit | `flutter test test/features/home/data/daily_plan_dao_test.dart` | Wave 0 |
|
||||
| PLAN-06 | Empty state shown when no overdue/today tasks exist | widget | `flutter test test/features/home/presentation/home_screen_test.dart` | Wave 0 |
|
||||
| CLEAN-01 | Room card shows cleanliness indicator | unit | Already covered by `test/features/rooms/data/rooms_dao_test.dart` | Exists |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `flutter test test/features/home/`
|
||||
- **Per wave merge:** `flutter test`
|
||||
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `test/features/home/data/daily_plan_dao_test.dart` -- covers PLAN-01, PLAN-02, PLAN-03, PLAN-05 (cross-room join query, date categorization, completion count)
|
||||
- [ ] `test/features/home/presentation/home_screen_test.dart` -- covers PLAN-06 (empty state rendering) and basic section rendering
|
||||
- [ ] No new framework dependencies needed; existing `flutter_test` + `drift` `NativeDatabase.memory()` pattern is sufficient
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [Drift Select/Join docs](https://drift.simonbinder.eu/dart_api/select/) - Join query syntax, `readTable()`, `readTableOrNull()`, ordering on joins
|
||||
- [Drift Stream docs](https://drift.simonbinder.eu/dart_api/streams/) - `.watch()` mechanism, table-level invalidation, stream behavior
|
||||
- [Flutter AnimatedList API](https://api.flutter.dev/flutter/widgets/AnimatedList-class.html) - `removeItem()`, `GlobalKey<AnimatedListState>`, animation builder
|
||||
- [Flutter AnimatedListState.removeItem](https://api.flutter.dev/flutter/widgets/AnimatedListState/removeItem.html) - Method signature, duration, builder pattern
|
||||
- [Flutter ExpansionTile API](https://api.flutter.dev/flutter/material/ExpansionTile-class.html) - `initiallyExpanded`, Material 3 theming, `controlAffinity`
|
||||
- [Flutter LinearProgressIndicator API](https://api.flutter.dev/flutter/material/LinearProgressIndicator-class.html) - Determinate mode with `value`, `minHeight`, Material 3 styling
|
||||
- [Flutter M3 Progress Indicators Breaking Changes](https://docs.flutter.dev/release/breaking-changes/updated-material-3-progress-indicators) - `year2023` flag, new 2024 design spec
|
||||
- Existing project codebase: `TasksDao`, `TaskRow`, `HomeScreen`, `RoomsDao`, `room_providers.dart`, `task_providers.dart`, `router.dart`
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Expansible in Flutter 3.32](https://himanshu-agarwal.medium.com/expansible-in-flutter-3-32-why-it-matters-how-to-use-it-727eeacb8dd2) - `ExpansibleController` deprecating `ExpansionTileController`
|
||||
- [Riverpod combining providers](https://app.studyraid.com/en/read/12027/384445/combining-multiple-providers-for-complex-state-management) - `ref.watch` pattern for computed providers
|
||||
- [Riverpod 3 stream alternatives](https://yfujiki.medium.com/an-alternative-of-stream-operation-in-riverpod-3-627a45f65140) - Stream combining without `.stream` access
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None -- all findings verified with official docs or established project patterns
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - All libraries already in project, no new dependencies
|
||||
- Architecture: HIGH - Patterns directly extend Phase 2 established conventions (DAO, StreamProvider, widget patterns), verified against existing code
|
||||
- Data layer (join query): HIGH - Drift join documentation is clear and well-tested; project already uses Drift 2.31.0 with the exact same pattern available
|
||||
- Animation (AnimatedList): MEDIUM - AnimatedList is well-documented but synchronization with reactive streams requires careful implementation. The desync pitfall is real but manageable at household-app scale.
|
||||
- Pitfalls: HIGH - All identified pitfalls are based on established Flutter/Drift behavior verified in official docs
|
||||
|
||||
**Research date:** 2026-03-16
|
||||
**Valid until:** 2026-04-16 (stable stack, no fast-moving dependencies)
|
||||
Reference in New Issue
Block a user