Files
2026-03-16 20:12:01 +01:00

37 KiB

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

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:

// 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:

/// 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:

// 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:

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:

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

// 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)

// 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

// 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)

{
  "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)

Secondary (MEDIUM confidence)

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)