2 Commits
v1.1 ... v1.1.1

Author SHA1 Message Date
d220dbe5ce test(TaskListScreen): add integration tests for filtered and overdue task states
All checks were successful
Build and Release to F-Droid / build-and-deploy (push) Successful in 10m30s
- Covers empty states, celebration state, and scheduled/overdue task rendering
- Verifies proper checkbox behavior for future tasks
- Tests AppBar for sort dropdown, edit/delete actions, and calendar strip
- Adds necessary test helpers and overrides for room-specific tasks
2026-03-16 23:35:17 +01:00
edce11dd78 chore: complete v1.1 milestone
Archive v1.1 Calendar & Polish milestone artifacts (roadmap,
requirements, phase directories) to milestones/. Evolve PROJECT.md
with validated requirements and new key decisions. Update
RETROSPECTIVE.md with v1.1 section and cross-milestone trends.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:32:04 +01:00
32 changed files with 1188 additions and 284 deletions

View File

@@ -1,5 +1,22 @@
# Milestones
## v1.1 Calendar & Polish (Shipped: 2026-03-16)
**Phases completed:** 3 phases, 5 plans, 11 tasks
**Codebase:** 13,031 LOC Dart (9,051 lib + 3,980 test), 108 tests, 41 commits
**Timeline:** 2 days (2026-03-15 to 2026-03-16)
**Key accomplishments:**
1. Horizontal 181-day calendar strip with German day cards, month boundaries, and floating Today button — replaces the stacked daily-plan HomeScreen
2. Date-parameterized CalendarDao with reactive Drift streams for day tasks and overdue tasks
3. Task completion history bottom sheet with per-task reverse-chronological log
4. Alphabetical, interval, and effort sort options persisted via SharedPreferences
5. SortDropdown widget integrated in both HomeScreen and TaskListScreen AppBars
**Archive:** See `milestones/v1.1-ROADMAP.md` and `milestones/v1.1-REQUIREMENTS.md`
---
## v1.0 MVP (Shipped: 2026-03-16)
**Phases completed:** 4 phases, 13 plans

View File

@@ -2,24 +2,12 @@
## What This Is
A local-first Flutter app for organizing household chores, built for personal/couple use on Android. Uses a room-based task scheduling model where users create rooms, add recurring tasks with frequency intervals, and the app auto-calculates the next due date after each completion. Features a daily plan home screen, bundled German-language task templates, room cleanliness indicators, and daily summary notifications. Fully offline, free, privacy-respecting — all data stays on-device.
A local-first Flutter app for organizing household chores, built for personal/couple use on Android. Uses a room-based task scheduling model where users create rooms, add recurring tasks with frequency intervals, and the app auto-calculates the next due date after each completion. Features a horizontal calendar strip home screen with day-by-day task navigation, task completion history, configurable sorting (alphabetical, interval, effort), bundled German-language task templates, room cleanliness indicators, and daily summary notifications. Fully offline, free, privacy-respecting — all data stays on-device.
## Core Value
Users can see what needs doing today, mark it done, and trust the app to schedule the next occurrence — without thinking about it.
## Current Milestone: v1.1 Calendar & Polish
**Goal:** Replace the stacked daily plan with a horizontal calendar strip UI, add task completion history, and task sorting options.
**Target features:**
- Horizontal date-strip calendar with day abbreviation + date number cards
- Month color shift for visual boundary between months
- Day-selection shows tasks in a list below the strip
- Undone tasks carry over to the next day with color accent (overdue marker)
- Task completion history log
- Additional task sorting (alphabetical, interval, effort)
## Requirements
### Validated
@@ -32,16 +20,16 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
- Daily summary notification with configurable time — v1.0
- Light/dark theme with calm Material 3 palette — v1.0
- Cleanliness indicator per room (based on overdue vs on-time) — v1.0
- Horizontal calendar strip home screen replacing stacked daily plan — v1.1
- Overdue task carry-over with red/orange visual accent — v1.1
- Task completion history with per-task reverse-chronological log — v1.1
- Alphabetical, interval, and effort task sorting with persistence — v1.1
### Active
- [ ] Horizontal calendar strip home screen (replacing stacked daily plan)
- [ ] Overdue task carry-over with visual accent
- [ ] Task completion history log
- [ ] Additional task sorting (alphabetical, interval, effort)
- [ ] Data export/import (JSON) — deferred
- [ ] English localization — deferred
- [ ] Room cover photos from camera or gallery — deferred
- [ ] Data export/import (JSON)
- [ ] English localization
- [ ] Room cover photos from camera or gallery
### Out of Scope
@@ -55,18 +43,22 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
- Firebase or any Google cloud services — contradicts local-first design
- Real-time cross-device sync — potential future self-hosted feature
- Tablet-optimized layout — future enhancement
- Weekly/monthly calendar views — date strip is sufficient for task app
- Drag tasks between days — tasks auto-schedule based on frequency
- Calendar sync (Google/Apple) — contradicts local-first, offline-only design
- Statistics & insights dashboard — v2.0
- Onboarding wizard — v2.0
- Custom accent color picker — v2.0
## Context
- Shipped v1.0 MVP with 10,588 LOC Dart (7,773 lib + 2,815 test), 89 tests
- Tech stack: Flutter + Dart, Riverpod 3 + code generation, Drift 2.31 SQLite, GoRouter, flutter_local_notifications
- Shipped v1.1 with 13,031 LOC Dart (9,051 lib + 3,980 test), 108 tests
- Tech stack: Flutter + Dart, Riverpod 3 + code generation, Drift 2.31 SQLite, GoRouter, flutter_local_notifications, SharedPreferences
- Inspired by BeTidy (iOS/Android household cleaning app) — room-based model, no cloud/social
- Built for personal use with partner on a shared Android device; may publish publicly later
- Code and comments in English; UI strings German-only for v1.0
- Code and comments in English; UI strings German-only through v1.1
- Gitea (self-hosted on Hetzner) for version control; no CI/CD pipeline yet
- Dead code from v1.0: daily_plan_providers.dart, daily_plan_task_row.dart, progress_card.dart (DailyPlanDao still used by notification service)
## Constraints
@@ -74,7 +66,7 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
- **Platform**: Android-first (iOS later)
- **Offline**: 100% offline-capable, zero network dependencies
- **Privacy**: No data leaves the device, no analytics, no tracking
- **Language**: German-only UI for v1.0, English code/comments
- **Language**: German-only UI through v1.1, English code/comments
- **No CI**: No automated build pipeline initially
## Key Decisions
@@ -91,6 +83,11 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
| Calendar-anchored scheduling | Monthly/quarterly/yearly tasks anchor to original day-of-month with clamping | Good — handles Feb 28/31 edge cases correctly with anchor memory |
| flutter_local_notifications v21 | Standard Flutter notification package, TZ-aware scheduling | Good — inexactAllowWhileIdle avoids SCHEDULE_EXACT_ALARM complexity |
| Manual StreamProvider for drift types | riverpod_generator throws InvalidTypeException with drift Task type | Revisit — may be fixed in future riverpod_generator versions |
| Calendar strip replaces daily plan | v1.1 goal — stacked overdue/today/upcoming sections replaced by horizontal 181-day strip | Good — cleaner navigation, day-by-day browsing |
| NotifierProvider over StateProvider | Riverpod 3.x removed StateProvider | Good — minimal Notifier subclass works cleanly |
| In-memory sort over SQL ORDER BY | Sort preference changes without re-querying DB | Good — stream.map applies sort after DB emit, reactive to preference changes |
| SharedPreferences for sort | Simple enum.name string persistence for sort preference | Good — lightweight, no DB migration needed, survives app restart |
| PopupMenuButton for sort UI | Material 3 AppBar action pattern, overlay menu | Good — clean integration in both HomeScreen and TaskListScreen AppBars |
---
*Last updated: 2026-03-16 after v1.1 milestone started*
*Last updated: 2026-03-16 after v1.1 milestone completed*

View File

@@ -46,6 +46,50 @@
---
## Milestone: v1.1 — Calendar & Polish
**Shipped:** 2026-03-16
**Phases:** 3 | **Plans:** 5
### What Was Built
- Horizontal 181-day calendar strip replacing the stacked daily plan HomeScreen
- CalendarDao with date-parameterized reactive Drift streams for day tasks and overdue tasks
- Task completion history bottom sheet with per-task reverse-chronological log
- Alphabetical, interval, and effort sort options with SharedPreferences persistence
- SortDropdown widget in both HomeScreen and TaskListScreen AppBars
### What Worked
- Phase dependency ordering (5 → 6+7 parallel-capable) meant calendar strip was stable before building features on top
- TDD red-green cycle continued smoothly — every plan had failing tests before implementation
- Auto-advance mode enabled rapid phase chaining with minimal manual intervention
- Existing patterns from v1.0 (DAO, provider, widget test) were reused directly — no new patterns invented unnecessarily
- CalendarStripController (VoidCallback holder) was simpler than GlobalKey approach — good architecture call
### What Was Inefficient
- StateProvider removal in Riverpod 3.x was discovered during execution rather than research — same category of issue as v1.0's riverpod_generator problem
- ROADMAP.md plan checkboxes still not auto-checked by executor (same bookkeeping gap as v1.0)
- Phase 5 plan split (data layer + UI) could have been a single plan given the small scope — overhead of 2 separate plans wasn't justified for ~13 min total
### Patterns Established
- CalendarStripController: VoidCallback holder for parent-to-child imperative scroll communication
- CalendarDayList state machine: first-run → celebration → emptyDay → hasTasks (5 states)
- In-memory sort via stream.map after DB stream emit — sort preference changes without re-querying
- SortPreferenceNotifier: sync default + async _loadPersisted() — matches ThemeNotifier pattern
- Nested Scaffold pattern for per-tab AppBars in StatefulShellRoute.indexedStack
### Key Lessons
1. Riverpod API surface changes (StateProvider removal) should be caught during phase research, not during execution — pattern repeats from v1.0
2. Plans under ~5 min execution can be merged into a single plan to reduce orchestration overhead
3. In-memory sort is the right approach when sort criteria don't affect DB queries — avoids re-streaming
4. Bottom sheets for one-shot modals (history) don't need dedicated Riverpod providers — ref.read() in ConsumerWidget is sufficient
### Cost Observations
- Model mix: orchestrator on opus, executors/checkers on sonnet
- Total execution: ~26 min for 5 plans across 3 phases
- Notable: Each plan averaged ~5 min — significantly faster than v1.0's ~6 min average due to established patterns
---
## Cross-Milestone Trends
### Process Evolution
@@ -53,13 +97,17 @@
| Milestone | Phases | Plans | Key Change |
|-----------|--------|-------|------------|
| v1.0 | 4 | 13 | Initial project — established all patterns |
| v1.1 | 3 | 5 | Reused v1.0 patterns — faster execution, auto-advance mode |
### Cumulative Quality
| Milestone | Tests | Key Metric |
|-----------|-------|------------|
| v1.0 | 89 | dart analyze clean, 0 issues |
| Milestone | Tests | LOC (lib) | Key Metric |
|-----------|-------|-----------|------------|
| v1.0 | 89 | 7,773 | dart analyze clean, 0 issues |
| v1.1 | 108 | 9,051 | dart analyze clean, 0 issues |
### Top Lessons (Verified Across Milestones)
1. (Single milestone — lessons above will be cross-validated as more milestones ship)
1. **Research must verify current package API signatures** — v1.0 hit riverpod_generator type incompatibility, v1.1 hit StateProvider removal. Same root cause: outdated API assumptions in plans.
2. **Established patterns compound** — v1.1 plans averaged ~5 min vs v1.0's ~6 min. Reusing DAO, provider, and test patterns eliminated design decisions.
3. **Verification gates are cheap insurance** — Consistently ~2 min per phase, caught regressions in both milestones.

View File

@@ -2,13 +2,13 @@
## Milestones
- **v1.0 MVP** — Phases 1-4 (shipped 2026-03-16)
- **v1.1 Calendar & Polish** — Phases 5-7 (in progress)
- **v1.0 MVP** — Phases 1-4 (shipped 2026-03-16)
- **v1.1 Calendar & Polish** — Phases 5-7 (shipped 2026-03-16)
## Phases
<details>
<summary>v1.0 MVP (Phases 1-4) — SHIPPED 2026-03-16</summary>
<summary>v1.0 MVP (Phases 1-4) — SHIPPED 2026-03-16</summary>
- [x] Phase 1: Foundation (2/2 plans) — completed 2026-03-15
- [x] Phase 2: Rooms and Tasks (5/5 plans) — completed 2026-03-15
@@ -19,54 +19,16 @@ See `milestones/v1.0-ROADMAP.md` for full phase details.
</details>
**v1.1 Calendar & Polish (Phases 5-7):**
<details>
<summary>✅ v1.1 Calendar & Polish (Phases 5-7) — SHIPPED 2026-03-16</summary>
- [x] **Phase 5: Calendar Strip** - Replace the stacked daily plan home screen with a horizontal scrollable date-strip and day-task list (completed 2026-03-16)
- [x] **Phase 6: Task History** - Record every task completion with a timestamp and expose a per-task history view (completed 2026-03-16)
- [x] **Phase 7: Task Sorting** - Add alphabetical, interval, and effort sort options to task lists (completed 2026-03-16)
- [x] Phase 5: Calendar Strip (2/2 plans) — completed 2026-03-16
- [x] Phase 6: Task History (1/1 plans) — completed 2026-03-16
- [x] Phase 7: Task Sorting (2/2 plans) — completed 2026-03-16
## Phase Details
See `milestones/v1.1-ROADMAP.md` for full phase details.
### Phase 5: Calendar Strip
**Goal**: Users navigate their tasks through a horizontal date-strip that replaces the stacked daily plan, seeing today's tasks by default and any day's tasks on tap
**Depends on**: Phase 4 (v1.0 shipped — all data layer and scheduling in place)
**Requirements**: CAL-01, CAL-02, CAL-03, CAL-04, CAL-05
**Success Criteria** (what must be TRUE):
1. The home screen shows a horizontal scrollable strip of day cards, each displaying the German day abbreviation (Mo, Di, Mi...) and the date number
2. Tapping any day card updates the task list below the strip to show that day's tasks, with the selected card visually highlighted
3. On app launch the strip auto-scrolls so today's card is centered and selected by default
4. When two adjacent day cards span a month boundary, a subtle color shift or divider makes the boundary visible without extra chrome
5. Tasks that were not completed on their due date appear in subsequent days' lists with a red/orange accent marking them as overdue
**Plans:** 2/2 plans complete
Plans:
- [ ] 05-01-PLAN.md — Data layer: CalendarDao, CalendarDayState model, Riverpod providers, localization, DAO tests
- [ ] 05-02-PLAN.md — UI: CalendarStrip, CalendarDayList, CalendarTaskRow widgets, HomeScreen replacement
### Phase 6: Task History
**Goal**: Users can see exactly when each task was completed in the past, building trust that the scheduling loop is working correctly
**Depends on**: Phase 5
**Requirements**: HIST-01, HIST-02
**Success Criteria** (what must be TRUE):
1. Every task completion (tap done in any view) is recorded in the database with a precise timestamp — data persists across app restarts
2. From a task's detail or context menu the user can open a history view listing all past completion dates for that task in reverse-chronological order
3. The history view shows a meaningful empty state if the task has never been completed
**Plans:** 1/1 plans complete
Plans:
- [ ] 06-01-PLAN.md — DAO query + history bottom sheet + TaskFormScreen integration + CalendarTaskRow navigation
### Phase 7: Task Sorting
**Goal**: Users can reorder task lists by the dimension most useful to them — name, how often the task recurs, or how much effort it requires
**Depends on**: Phase 5
**Requirements**: SORT-01, SORT-02, SORT-03
**Success Criteria** (what must be TRUE):
1. A sort control (dropdown, segmented button, or similar) is visible on task list screens and persists the chosen sort across app restarts
2. Selecting alphabetical sort orders tasks A-Z by name within the visible list
3. Selecting interval sort orders tasks from most-frequent (daily) to least-frequent (yearly/custom) intervals
4. Selecting effort sort orders tasks from lowest effort to highest effort level
**Plans:** 2/2 plans complete
Plans:
- [ ] 07-01-PLAN.md — Sort model, persistence notifier, localization, provider integration
- [ ] 07-02-PLAN.md — Sort dropdown widget, HomeScreen AppBar, TaskListScreen integration, tests
</details>
## Progress
@@ -76,6 +38,6 @@ Plans:
| 2. Rooms and Tasks | v1.0 | 5/5 | Complete | 2026-03-15 |
| 3. Daily Plan and Cleanliness | v1.0 | 3/3 | Complete | 2026-03-16 |
| 4. Notifications | v1.0 | 3/3 | Complete | 2026-03-16 |
| 5. Calendar Strip | 2/2 | Complete | 2026-03-16 | - |
| 6. Task History | 1/1 | Complete | 2026-03-16 | - |
| 7. Task Sorting | 2/2 | Complete | 2026-03-16 | - |
| 5. Calendar Strip | v1.1 | 2/2 | Complete | 2026-03-16 |
| 6. Task History | v1.1 | 1/1 | Complete | 2026-03-16 |
| 7. Task Sorting | v1.1 | 2/2 | Complete | 2026-03-16 |

View File

@@ -1,11 +1,11 @@
---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
milestone: v1.1
milestone_name: Calendar & Polish
status: completed
stopped_at: Completed 07-task-sorting/07-02-PLAN.md
last_updated: "2026-03-16T21:43:23.009Z"
last_activity: 2026-03-16 — Completed Phase 6 Plan 01 (task completion history)
stopped_at: Milestone v1.1 archived
last_updated: "2026-03-16T23:26:00.000Z"
last_activity: 2026-03-16 — Milestone v1.1 archived
progress:
total_phases: 3
completed_phases: 3
@@ -21,55 +21,32 @@ progress:
See: .planning/PROJECT.md (updated 2026-03-16)
**Core value:** Users can see what needs doing today, mark it done, and trust the app to schedule the next occurrence — without thinking about it.
**Current focus:** v1.1 Calendar & Polish — Phase 6: Task History
**Current focus:** Planning next milestone
## Current Position
Phase: 6 — Task History
Plan: 1/1 complete (Phase 6 done)
Status: Phase Complete
Last activity: 2026-03-16 — Completed Phase 6 Plan 01 (task completion history)
Milestone: v1.1 Calendar & Polish — SHIPPED
Status: Milestone Complete
Last activity: 2026-03-16 — Archived milestone v1.1
```
Progress: [██████████] 100% (1/1 plans in Phase 6)
Progress: [██████████] 100% (v1.1 shipped)
```
## Performance Metrics
| Metric | v1.0 | v1.1 |
|--------|------|------|
| Phases | 4 | 3 planned |
| Plans | 13 | TBD |
| LOC (lib) | 7,773 | TBD |
| Tests | 89 | TBD |
| Phase 05-calendar-strip P01 | 5 | 2 tasks | 10 files |
| Phase 05-calendar-strip P02 | 8 | 3 tasks | 9 files |
| Phase 06-task-history P01 | 5 | 2 tasks | 9 files |
| Phase 07-task-sorting P01 | 4 | 2 tasks | 9 files |
| Phase 07-task-sorting P02 | 4 | 2 tasks | 5 files |
| Phases | 4 | 3 |
| Plans | 13 | 5 |
| LOC (lib) | 7,773 | 9,051 |
| Tests | 89 | 108 |
## Accumulated Context
### Decisions
| Decision | Rationale |
|----------|-----------|
| Calendar strip replaces daily plan home screen | v1.1 goal per PROJECT.md — not additive, the stacked overdue/today/upcoming sections are removed |
| Phase 5 before Phase 6 and 7 | Calendar strip is the primary UI surface; history and sorting operate within or alongside it |
| Phase 6 and 7 both depend on Phase 5 only | History and sorting are independent of each other — could execute in either order |
| HIST-01 and HIST-02 in same phase | Data layer (HIST-01) is only 1-2 DAO additions; grouping with the UI (HIST-02) keeps the phase coherent |
| Used NotifierProvider<SelectedDateNotifier> instead of deprecated StateProvider | Riverpod 3.x removed StateProvider; NotifierProvider is the correct replacement |
| calendarDayProvider fetches overdue tasks with .first in asyncMap when isToday | Consistent with dailyPlanProvider pattern; avoids combining two streams |
| watchTasksForDate sorts alphabetically by task name | Same-day tasks have no meaningful time-based order; alpha sort is deterministic and user-friendly |
| CalendarStripController as VoidCallback holder | Avoids GlobalKey for single imperative scroll-to-today action — simpler |
| Tests use pump()+pump(Duration) instead of pumpAndSettle() | CalendarStrip animation controllers cause pumpAndSettle timeout — fixed-duration pump steps are reliable |
| No separate Riverpod provider for history sheet | ref.read(appDatabaseProvider) directly in ConsumerWidget — one-shot modals do not need a dedicated provider |
| CalendarTaskRow onTap navigates to task edit form | Makes history accessible in one tap from home screen, consistent with GoRouter route patterns |
- [Phase 07-task-sorting]: Default sort is alphabetical — continuity with existing A-Z SQL sort in CalendarDayList
- [Phase 07-task-sorting]: overdueTasks are NOT sorted — pinned at top in existing order per design decision
- [Phase 07-task-sorting]: Sort preference stored as enum.name string in SharedPreferences (not intEnum) — enum reordering safe
- [Phase 07-task-sorting]: Used PopupMenuButton for SortDropdown in AppBar — menu overlay vs inline expansion, Material 3 pattern
- [Phase 07-task-sorting]: HomeScreen uses nested Scaffold for AppBar — standard StatefulShellRoute.indexedStack per-tab AppBar pattern
Decisions archived to PROJECT.md Key Decisions table.
### Pending Todos
@@ -77,11 +54,11 @@ None.
### Blockers/Concerns
- Phase 5 complete. daily_plan_providers.dart, daily_plan_task_row.dart, and progress_card.dart are now dead code (safe to clean up in a future phase). DailyPlanDao must NOT be deleted — still used by the notification service.
- Dead code from v1.0: daily_plan_providers.dart, daily_plan_task_row.dart, progress_card.dart (DailyPlanDao still used by notification service)
## Session Continuity
Last session: 2026-03-16T21:40:24.556Z
Stopped at: Completed 07-task-sorting/07-02-PLAN.md
Last session: 2026-03-16
Stopped at: Milestone v1.1 archived
Resume file: None
Next action: Phase 7 (task sorting) or release
Next action: /gsd:new-milestone

View File

@@ -5,7 +5,7 @@
"commit_docs": true,
"model_profile": "balanced",
"workflow": {
"research": true,
"research": false,
"plan_check": true,
"verifier": true,
"nyquist_validation": true,

View File

@@ -1,3 +1,12 @@
# Requirements Archive: v1.1 Calendar & Polish
**Archived:** 2026-03-16
**Status:** SHIPPED
For current requirements, see `.planning/REQUIREMENTS.md`.
---
# Requirements: HouseHoldKeaper
**Defined:** 2026-03-16

View File

@@ -0,0 +1,81 @@
# Roadmap: HouseHoldKeaper
## Milestones
- **v1.0 MVP** — Phases 1-4 (shipped 2026-03-16)
- **v1.1 Calendar & Polish** — Phases 5-7 (in progress)
## Phases
<details>
<summary>v1.0 MVP (Phases 1-4) — SHIPPED 2026-03-16</summary>
- [x] Phase 1: Foundation (2/2 plans) — completed 2026-03-15
- [x] Phase 2: Rooms and Tasks (5/5 plans) — completed 2026-03-15
- [x] Phase 3: Daily Plan and Cleanliness (3/3 plans) — completed 2026-03-16
- [x] Phase 4: Notifications (3/3 plans) — completed 2026-03-16
See `milestones/v1.0-ROADMAP.md` for full phase details.
</details>
**v1.1 Calendar & Polish (Phases 5-7):**
- [x] **Phase 5: Calendar Strip** - Replace the stacked daily plan home screen with a horizontal scrollable date-strip and day-task list (completed 2026-03-16)
- [x] **Phase 6: Task History** - Record every task completion with a timestamp and expose a per-task history view (completed 2026-03-16)
- [x] **Phase 7: Task Sorting** - Add alphabetical, interval, and effort sort options to task lists (completed 2026-03-16)
## Phase Details
### Phase 5: Calendar Strip
**Goal**: Users navigate their tasks through a horizontal date-strip that replaces the stacked daily plan, seeing today's tasks by default and any day's tasks on tap
**Depends on**: Phase 4 (v1.0 shipped — all data layer and scheduling in place)
**Requirements**: CAL-01, CAL-02, CAL-03, CAL-04, CAL-05
**Success Criteria** (what must be TRUE):
1. The home screen shows a horizontal scrollable strip of day cards, each displaying the German day abbreviation (Mo, Di, Mi...) and the date number
2. Tapping any day card updates the task list below the strip to show that day's tasks, with the selected card visually highlighted
3. On app launch the strip auto-scrolls so today's card is centered and selected by default
4. When two adjacent day cards span a month boundary, a subtle color shift or divider makes the boundary visible without extra chrome
5. Tasks that were not completed on their due date appear in subsequent days' lists with a red/orange accent marking them as overdue
**Plans:** 2/2 plans complete
Plans:
- [ ] 05-01-PLAN.md — Data layer: CalendarDao, CalendarDayState model, Riverpod providers, localization, DAO tests
- [ ] 05-02-PLAN.md — UI: CalendarStrip, CalendarDayList, CalendarTaskRow widgets, HomeScreen replacement
### Phase 6: Task History
**Goal**: Users can see exactly when each task was completed in the past, building trust that the scheduling loop is working correctly
**Depends on**: Phase 5
**Requirements**: HIST-01, HIST-02
**Success Criteria** (what must be TRUE):
1. Every task completion (tap done in any view) is recorded in the database with a precise timestamp — data persists across app restarts
2. From a task's detail or context menu the user can open a history view listing all past completion dates for that task in reverse-chronological order
3. The history view shows a meaningful empty state if the task has never been completed
**Plans:** 1/1 plans complete
Plans:
- [ ] 06-01-PLAN.md — DAO query + history bottom sheet + TaskFormScreen integration + CalendarTaskRow navigation
### Phase 7: Task Sorting
**Goal**: Users can reorder task lists by the dimension most useful to them — name, how often the task recurs, or how much effort it requires
**Depends on**: Phase 5
**Requirements**: SORT-01, SORT-02, SORT-03
**Success Criteria** (what must be TRUE):
1. A sort control (dropdown, segmented button, or similar) is visible on task list screens and persists the chosen sort across app restarts
2. Selecting alphabetical sort orders tasks A-Z by name within the visible list
3. Selecting interval sort orders tasks from most-frequent (daily) to least-frequent (yearly/custom) intervals
4. Selecting effort sort orders tasks from lowest effort to highest effort level
**Plans:** 2/2 plans complete
Plans:
- [ ] 07-01-PLAN.md — Sort model, persistence notifier, localization, provider integration
- [ ] 07-02-PLAN.md — Sort dropdown widget, HomeScreen AppBar, TaskListScreen integration, tests
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Foundation | v1.0 | 2/2 | Complete | 2026-03-15 |
| 2. Rooms and Tasks | v1.0 | 5/5 | Complete | 2026-03-15 |
| 3. Daily Plan and Cleanliness | v1.0 | 3/3 | Complete | 2026-03-16 |
| 4. Notifications | v1.0 | 3/3 | Complete | 2026-03-16 |
| 5. Calendar Strip | 2/2 | Complete | 2026-03-16 | - |
| 6. Task History | 1/1 | Complete | 2026-03-16 | - |
| 7. Task Sorting | 2/2 | Complete | 2026-03-16 | - |

View File

@@ -0,0 +1,105 @@
# Phase 5: Calendar Strip - Context
**Gathered:** 2026-03-16
**Status:** Ready for planning
<domain>
## Phase Boundary
Replace the stacked daily plan home screen (overdue/today/tomorrow sections) with a horizontal scrollable date-strip and day-task list. Users navigate by tapping day cards to view that day's tasks below the strip. Requirements: CAL-01 through CAL-05.
</domain>
<decisions>
## Implementation Decisions
### Day card appearance
- Each card shows: German day abbreviation (Mo, Di, Mi...) and date number only
- No task-count badges, dots, or indicators on the cards
- All cards have a light sage/green tint
- Selected card has a noticeably stronger green and is always centered in the strip
- Today's card uses bold text with an accent underline
- When today is selected, both treatments combine (bold + underline + stronger green + centered)
### Month boundary treatment (CAL-03)
- A slightly wider gap between the last day of one month and the first of the next
- A small month name label (e.g., "Apr") inserted in the gap between months
### Scroll range & navigation
- Strip scrolls both into the past and into the future (Claude picks a reasonable range balancing performance and usefulness)
- On app launch, the strip auto-scrolls to center on today with a quick slide animation (~200ms)
- A floating "Today" button appears when the user has scrolled away from today; tap to snap back. Hidden when today is already visible.
### Task list below the strip
- No ProgressCard — task list appears directly under the strip
- Overdue tasks (CAL-05) appear in a separate section with coral accent header above the day's own tasks, same pattern as current "Überfällig" section
- Task rows show: task name, tappable room tag, and checkbox — no relative date (strip already communicates which day)
- Checkboxes are interactive — tapping completes the task with the existing slide-out animation
### Empty and celebration states
- If a selected day had tasks that were all completed: show celebration state (icon + message, same spirit as current AllClear)
- If a selected day never had any tasks: simple centered "Keine Aufgaben" message with subtle icon
- First-run empty state (no rooms/tasks at all): keep the current pattern pointing user to create rooms
### Overdue carry-over behavior (CAL-05)
- Overdue tasks (due before today, not yet completed) appear in a separate "Überfällig" section when viewing today
- When viewing past days: show what was due that day (tasks whose nextDueDate matches that day)
- When viewing future days: show only tasks due that day, no overdue carry-over
- Overdue tasks use the existing warm coral/terracotta accent (#E07A5F)
### Claude's Discretion
- Exact scroll range (past and future day count)
- Day card dimensions, spacing, and border radius
- Animation curves and durations beyond the ~200ms auto-scroll
- Floating "Today" button styling and position
- How the celebration state adapts to the calendar context (may simplify from current full-screen version)
- Whether to reuse DailyPlanDao or create a new CalendarDao
- Widget architecture and state management approach
</decisions>
<specifics>
## Specific Ideas
- Day cards should feel like a unified strip with a light green wash — the selected day stands out by being a "marginally stronger green," not a completely different color. Think cohesive gradient, not toggle buttons.
- The selected card is always centered — the strip scrolls to keep the selection in the middle, giving a carousel feel.
- Month labels in the gap between months act as wayfinding, similar to section headers in a contact list.
</specifics>
<code_context>
## Existing Code Insights
### Reusable Assets
- `DailyPlanTaskRow`: Existing task row widget — can be adapted by removing the relative date display and keeping name + room tag + checkbox
- `_CompletingTaskRow`: Animated slide-out on task completion — reuse directly for calendar task list
- `ProgressCard`: Will NOT be used in the new view, but the pattern of a card above a list is established
- `_overdueColor` (#E07A5F): Warm coral constant already defined for overdue indicators — reuse as-is
- `TaskWithRoom` model: Pairs task with room name/ID — directly usable for calendar task list
### Established Patterns
- Riverpod `StreamProvider.autoDispose` for reactive data (see `dailyPlanProvider`) — calendar provider follows same pattern
- Manual provider definition (not `@riverpod`) because of drift's generated types — same constraint applies
- Feature folder structure: `features/home/data/`, `domain/`, `presentation/` — new calendar code lives here (replaces daily plan)
- German-only localization via `.arb` files and `AppLocalizations`
### Integration Points
- `HomeScreen` at route `/` in `router.dart` — the calendar screen replaces this widget entirely
- `AppShell` with bottom NavigationBar — home tab stays as-is, just the screen content changes
- `DailyPlanDao.watchAllTasksWithRoomName()` — returns all tasks sorted by nextDueDate; may need a new query or adapted filtering for arbitrary date selection
- `TaskActionsProvider``completeTask(taskId)` already handles task completion and nextDueDate advancement
- `AppDatabase` with `DailyPlanDao` registered — any new DAO must be registered here
</code_context>
<deferred>
## Deferred Ideas
None — discussion stayed within phase scope
</deferred>
---
*Phase: 05-calendar-strip*
*Context gathered: 2026-03-16*

View File

@@ -55,6 +55,33 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
return result.read(countExp) ?? 0;
}
/// Watch tasks due on [date] within a specific [roomId].
///
/// Same as [watchTasksForDate] but filtered to a single room.
Stream<List<TaskWithRoom>> watchTasksForDateInRoom(
DateTime date, int roomId) {
final startOfDay = DateTime(date.year, date.month, date.day);
final endOfDay = startOfDay.add(const Duration(days: 1));
final query = select(tasks).join([
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
]);
query.where(
tasks.nextDueDate.isBiggerOrEqualValue(startOfDay) &
tasks.nextDueDate.isSmallerThanValue(endOfDay) &
tasks.roomId.equals(roomId),
);
query.orderBy([OrderingTerm.asc(tasks.name)]);
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();
});
}
/// Watch tasks whose [nextDueDate] is strictly before [referenceDate].
///
/// Returns tasks sorted by [nextDueDate] ascending (oldest first).
@@ -84,4 +111,50 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
}).toList();
});
}
/// Watch overdue tasks (before [referenceDate]) within a specific [roomId].
///
/// Same as [watchOverdueTasks] but filtered to a single room.
Stream<List<TaskWithRoom>> watchOverdueTasksInRoom(
DateTime referenceDate, int roomId) {
final startOfReferenceDay = DateTime(
referenceDate.year,
referenceDate.month,
referenceDate.day,
);
final query = select(tasks).join([
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
]);
query.where(
tasks.nextDueDate.isSmallerThanValue(startOfReferenceDay) &
tasks.roomId.equals(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();
});
}
/// Total task count within a specific room.
///
/// Used to distinguish first-run empty state from celebration state
/// in the room calendar view.
Future<int> getTaskCountInRoom(int roomId) async {
final countExp = tasks.id.count();
final query = selectOnly(tasks)
..addColumns([countExp])
..where(tasks.roomId.equals(roomId));
final result = await query.getSingle();
return result.read(countExp) ?? 0;
}
}

View File

@@ -14,7 +14,8 @@ const _overdueColor = Color(0xFFE07A5F);
/// Shows the task list for the selected calendar day.
///
/// Watches [calendarDayProvider] and renders one of several states:
/// Watches [calendarDayProvider] (or [roomCalendarDayProvider] when [roomId]
/// is provided) and renders one of several states:
/// - Loading spinner while data loads
/// - Error text on failure
/// - First-run empty state (no rooms/tasks at all) — prompts to create a room
@@ -22,7 +23,10 @@ const _overdueColor = Color(0xFFE07A5F);
/// - Celebration state (today is selected and all tasks are done)
/// - Has-tasks state with optional overdue section (today only) and checkboxes
class CalendarDayList extends ConsumerStatefulWidget {
const CalendarDayList({super.key});
const CalendarDayList({super.key, this.roomId});
/// When non-null, filters tasks to this room only.
final int? roomId;
@override
ConsumerState<CalendarDayList> createState() => _CalendarDayListState();
@@ -43,7 +47,9 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final dayState = ref.watch(calendarDayProvider);
final dayState = widget.roomId != null
? ref.watch(roomCalendarDayProvider(widget.roomId!))
: ref.watch(calendarDayProvider);
return dayState.when(
loading: () => const Center(child: CircularProgressIndicator()),
@@ -96,6 +102,46 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
AppLocalizations l10n,
ThemeData theme,
) {
// Room-scoped: prompt to create a task in this room.
if (widget.roomId != null) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.task_alt,
size: 80,
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
),
const SizedBox(height: 24),
Text(
l10n.taskEmptyTitle,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.taskEmptyMessage,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () =>
context.go('/rooms/${widget.roomId}/tasks/new'),
child: Text(l10n.taskEmptyAction),
),
],
),
),
);
}
// Home-screen: prompt to create a room.
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -194,20 +240,36 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
AppLocalizations l10n,
ThemeData theme,
) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final isFuture = state.selectedDate.isAfter(today);
final showRoomTag = widget.roomId == null;
final items = <Widget>[];
// Overdue section (today only, when overdue tasks exist).
// Overdue tasks are always completable (they're past due, only shown on today).
if (state.overdueTasks.isNotEmpty) {
items.add(_buildSectionHeader(l10n.dailyPlanSectionOverdue, theme,
color: _overdueColor));
for (final tw in state.overdueTasks) {
items.add(_buildAnimatedTaskRow(tw, isOverdue: true));
items.add(_buildAnimatedTaskRow(
tw,
isOverdue: true,
showRoomTag: showRoomTag,
canComplete: true,
));
}
}
// Day tasks section.
for (final tw in state.dayTasks) {
items.add(_buildAnimatedTaskRow(tw, isOverdue: false));
items.add(_buildAnimatedTaskRow(
tw,
isOverdue: false,
showRoomTag: showRoomTag,
canComplete: !isFuture,
));
}
return ListView(children: items);
@@ -227,7 +289,12 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
);
}
Widget _buildAnimatedTaskRow(TaskWithRoom tw, {required bool isOverdue}) {
Widget _buildAnimatedTaskRow(
TaskWithRoom tw, {
required bool isOverdue,
required bool showRoomTag,
required bool canComplete,
}) {
final isCompleting = _completingTaskIds.contains(tw.task.id);
if (isCompleting) {
@@ -235,6 +302,7 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
key: ValueKey('completing-${tw.task.id}'),
taskWithRoom: tw,
isOverdue: isOverdue,
showRoomTag: showRoomTag,
);
}
@@ -242,6 +310,8 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
key: ValueKey('task-${tw.task.id}'),
taskWithRoom: tw,
isOverdue: isOverdue,
showRoomTag: showRoomTag,
canComplete: canComplete,
onCompleted: () => _onTaskCompleted(tw.task.id),
);
}
@@ -253,10 +323,12 @@ class _CompletingTaskRow extends StatefulWidget {
super.key,
required this.taskWithRoom,
required this.isOverdue,
required this.showRoomTag,
});
final TaskWithRoom taskWithRoom;
final bool isOverdue;
final bool showRoomTag;
@override
State<_CompletingTaskRow> createState() => _CompletingTaskRowState();
@@ -302,6 +374,7 @@ class _CompletingTaskRowState extends State<_CompletingTaskRow>
child: CalendarTaskRow(
taskWithRoom: widget.taskWithRoom,
isOverdue: widget.isOverdue,
showRoomTag: widget.showRoomTag,
onCompleted: () {}, // Already completing — ignore repeat taps.
),
),

View File

@@ -104,3 +104,43 @@ final calendarDayProvider =
);
});
});
/// 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);
return dayTasksStream.asyncMap((dayTasks) async {
final List<TaskWithRoom> overdueTasks;
if (isToday) {
overdueTasks = await db.calendarDao
.watchOverdueTasksInRoom(selectedDate, roomId)
.first;
} else {
overdueTasks = const [];
}
final totalTaskCount = await db.calendarDao.getTaskCountInRoom(roomId);
return CalendarDayState(
selectedDate: selectedDate,
dayTasks: _sortTasks(dayTasks, sortOption),
overdueTasks: overdueTasks,
totalTaskCount: totalTaskCount,
);
});
});

View File

@@ -20,6 +20,8 @@ class CalendarTaskRow extends StatelessWidget {
required this.taskWithRoom,
required this.onCompleted,
this.isOverdue = false,
this.showRoomTag = true,
this.canComplete = true,
});
final TaskWithRoom taskWithRoom;
@@ -30,6 +32,12 @@ class CalendarTaskRow extends StatelessWidget {
/// When true, task name is rendered in coral color.
final bool isOverdue;
/// When false, the room tag subtitle is hidden (e.g. in room-scoped view).
final bool showRoomTag;
/// When false, the checkbox is disabled (e.g. for future tasks).
final bool canComplete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -41,7 +49,7 @@ class CalendarTaskRow extends StatelessWidget {
),
leading: Checkbox(
value: false,
onChanged: (_) => onCompleted(),
onChanged: canComplete ? (_) => onCompleted() : null,
),
title: Text(
task.name,
@@ -51,22 +59,25 @@ class CalendarTaskRow extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: 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,
),
),
),
),
subtitle: showRoomTag
? 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,
),
),
),
)
: null,
);
}
}

View File

@@ -4,34 +4,46 @@ import 'package:go_router/go_router.dart';
import 'package:household_keeper/core/database/database.dart';
import 'package:household_keeper/core/providers/database_provider.dart';
import 'package:household_keeper/features/home/presentation/calendar_day_list.dart';
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
import 'package:household_keeper/features/home/presentation/calendar_strip.dart';
import 'package:household_keeper/features/tasks/presentation/sort_dropdown.dart';
import 'package:household_keeper/features/tasks/presentation/task_providers.dart';
import 'package:household_keeper/features/tasks/presentation/task_row.dart';
import 'package:household_keeper/l10n/app_localizations.dart';
/// Screen displaying all tasks within a room, sorted by due date.
/// Screen displaying tasks within a room filtered by the selected calendar day.
///
/// Shows an empty state when no tasks exist, with a button to create one.
/// FAB always visible for quick task creation.
/// Shows a horizontal calendar strip at the top (same as HomeScreen) and
/// a date-filtered task list below. FAB always visible for quick task creation.
/// AppBar shows room name with edit and delete actions.
class TaskListScreen extends ConsumerWidget {
class TaskListScreen extends ConsumerStatefulWidget {
const TaskListScreen({super.key, required this.roomId});
final int roomId;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<TaskListScreen> createState() => _TaskListScreenState();
}
class _TaskListScreenState extends ConsumerState<TaskListScreen> {
late final CalendarStripController _stripController =
CalendarStripController();
/// Whether to show the floating "Heute" button.
/// True when the user has scrolled away from today's card.
bool _showTodayButton = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final asyncTasks = ref.watch(tasksInRoomProvider(roomId));
return Scaffold(
appBar: AppBar(
title: _RoomTitle(roomId: roomId),
title: _RoomTitle(roomId: widget.roomId),
actions: [
const SortDropdown(),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => context.go('/rooms/$roomId/edit'),
onPressed: () => context.go('/rooms/${widget.roomId}/edit'),
),
IconButton(
icon: const Icon(Icons.delete),
@@ -39,33 +51,43 @@ class TaskListScreen extends ConsumerWidget {
),
],
),
body: asyncTasks.when(
data: (tasks) {
if (tasks.isEmpty) {
return _EmptyState(l10n: l10n, roomId: roomId);
}
return _TaskListView(
tasks: tasks,
l10n: l10n,
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
body: Stack(
children: [
Column(
children: [
Text('Fehler: $error'),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => ref.invalidate(tasksInRoomProvider(roomId)),
child: const Text('Erneut versuchen'),
CalendarStrip(
controller: _stripController,
onTodayVisibilityChanged: (visible) {
setState(() => _showTodayButton = !visible);
},
),
Expanded(child: CalendarDayList(roomId: widget.roomId)),
],
),
),
if (_showTodayButton)
Positioned(
bottom: 80, // Above the FAB
left: 0,
right: 0,
child: Center(
child: FloatingActionButton.extended(
onPressed: () {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
ref
.read(selectedDateProvider.notifier)
.selectDate(today);
_stripController.scrollToToday();
},
icon: const Icon(Icons.today),
label: Text(l10n.calendarTodayButton),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.go('/rooms/$roomId/tasks/new'),
onPressed: () => context.go('/rooms/${widget.roomId}/tasks/new'),
child: const Icon(Icons.add),
),
);
@@ -90,7 +112,7 @@ class TaskListScreen extends ConsumerWidget {
onPressed: () {
Navigator.pop(ctx);
final db = ref.read(appDatabaseProvider);
db.roomsDao.deleteRoom(roomId);
db.roomsDao.deleteRoom(widget.roomId);
// Navigate back to rooms list
context.go('/rooms');
},
@@ -126,105 +148,3 @@ class _RoomTitle extends ConsumerWidget {
);
}
}
/// Empty state shown when the room has no tasks.
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.l10n, required this.roomId});
final AppLocalizations l10n;
final int roomId;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.task_alt,
size: 80,
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
),
const SizedBox(height: 24),
Text(
l10n.taskEmptyTitle,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.taskEmptyMessage,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () => context.go('/rooms/$roomId/tasks/new'),
child: Text(l10n.taskEmptyAction),
),
],
),
),
);
}
}
/// List view of task rows with long-press delete support.
class _TaskListView extends ConsumerWidget {
const _TaskListView({required this.tasks, required this.l10n});
final List<Task> tasks;
final AppLocalizations l10n;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return TaskRow(
key: ValueKey(task.id),
task: task,
onDelete: () => _showDeleteConfirmation(context, ref, task),
);
},
);
}
void _showDeleteConfirmation(
BuildContext context,
WidgetRef ref,
Task task,
) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.taskDeleteConfirmTitle),
content: Text(l10n.taskDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () {
Navigator.pop(ctx);
ref.read(taskActionsProvider.notifier).deleteTask(task.id);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: Text(l10n.taskDeleteConfirmAction),
),
],
),
);
}
}

View File

@@ -42,6 +42,7 @@ class TaskRow extends ConsumerWidget {
task.nextDueDate.day,
);
final isOverdue = dueDate.isBefore(today);
final isFuture = dueDate.isAfter(today);
// Format relative due date in German
final relativeDateText = formatRelativeDate(task.nextDueDate, now);
@@ -56,10 +57,12 @@ class TaskRow extends ConsumerWidget {
return ListTile(
leading: Checkbox(
value: false, // Always unchecked -- completion is immediate + reschedule
onChanged: (_) {
// Mark done immediately (optimistic UI, no undo per user decision)
ref.read(taskActionsProvider.notifier).completeTask(task.id);
},
onChanged: isFuture
? null // Future tasks cannot be completed yet
: (_) {
// Mark done immediately (optimistic UI, no undo per user decision)
ref.read(taskActionsProvider.notifier).completeTask(task.id);
},
),
title: Text(
task.name,

View File

@@ -283,4 +283,267 @@ void main() {
expect(result.first.roomId, room2Id);
});
});
group('CalendarDao.watchTasksForDateInRoom', () {
test('returns empty list when no tasks exist in room', () async {
final result = await db.calendarDao
.watchTasksForDateInRoom(DateTime(2026, 3, 16), room1Id)
.first;
expect(result, isEmpty);
});
test('returns only tasks due on the queried date in the specified room',
() async {
// Task due on March 16 in room1
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Abspuelen',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 16, 9, 30),
));
// Task due on March 15 in room1 (should NOT appear)
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Staubsaugen',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 15),
));
// Task due on March 17 in room1 (should NOT appear)
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Fenster putzen',
intervalType: IntervalType.monthly,
effortLevel: EffortLevel.high,
nextDueDate: DateTime(2026, 3, 17),
));
final result = await db.calendarDao
.watchTasksForDateInRoom(DateTime(2026, 3, 16), room1Id)
.first;
expect(result.length, 1);
expect(result.first.task.name, 'Abspuelen');
});
test('does not return tasks from other rooms on the same date', () async {
// Task due on March 16 in room1
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Kueche Aufgabe',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 16),
));
// Task due on March 16 in room2 (should NOT appear when querying room1)
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room2Id,
name: 'Bad Aufgabe',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 16),
));
final result = await db.calendarDao
.watchTasksForDateInRoom(DateTime(2026, 3, 16), room1Id)
.first;
expect(result.length, 1);
expect(result.first.task.name, 'Kueche Aufgabe');
expect(result.first.roomId, room1Id);
});
test('returns tasks sorted alphabetically by name', () async {
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Zitrone putzen',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 18),
));
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Abspuelen',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 18, 10),
));
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Moppen',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 18, 8),
));
final result = await db.calendarDao
.watchTasksForDateInRoom(DateTime(2026, 3, 18), room1Id)
.first;
expect(result.length, 3);
expect(result[0].task.name, 'Abspuelen');
expect(result[1].task.name, 'Moppen');
expect(result[2].task.name, 'Zitrone putzen');
});
});
group('CalendarDao.watchOverdueTasksInRoom', () {
test('returns empty list when no overdue tasks in room', () async {
final result = await db.calendarDao
.watchOverdueTasksInRoom(DateTime(2026, 3, 16), room1Id)
.first;
expect(result, isEmpty);
});
test('returns only overdue tasks in the specified room', () async {
// Task due March 15 in room1 — overdue relative to March 16
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Ueberfaelliges Task',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 15),
));
// Task due March 10 in room1 — also overdue
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Sehr altes Task',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 10),
));
// Task due on March 16 in room1 — NOT overdue (should NOT appear)
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Heutiges Task',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 16),
));
final result = await db.calendarDao
.watchOverdueTasksInRoom(DateTime(2026, 3, 16), room1Id)
.first;
expect(result.length, 2);
});
test('does not return overdue tasks from other rooms', () async {
// Overdue task in room1
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Kueche Overdue',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 14),
));
// Overdue task in room2 (should NOT appear when querying room1)
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room2Id,
name: 'Bad Overdue',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 14),
));
final result = await db.calendarDao
.watchOverdueTasksInRoom(DateTime(2026, 3, 16), room1Id)
.first;
expect(result.length, 1);
expect(result.first.task.name, 'Kueche Overdue');
expect(result.first.roomId, room1Id);
});
test('returns overdue tasks sorted by nextDueDate ascending', () async {
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Neues Overdue',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 15),
));
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Altes Overdue',
intervalType: IntervalType.monthly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 1),
));
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Mittleres Overdue',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 10),
));
final result = await db.calendarDao
.watchOverdueTasksInRoom(DateTime(2026, 3, 16), room1Id)
.first;
expect(result.length, 3);
expect(result[0].task.name, 'Altes Overdue');
expect(result[1].task.name, 'Mittleres Overdue');
expect(result[2].task.name, 'Neues Overdue');
});
});
group('CalendarDao.getTaskCountInRoom', () {
test('returns 0 when room has no tasks', () async {
final count = await db.calendarDao.getTaskCountInRoom(room1Id);
expect(count, 0);
});
test('returns correct count for room', () async {
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Aufgabe 1',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 16),
));
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Aufgabe 2',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 20),
));
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Aufgabe 3',
intervalType: IntervalType.monthly,
effortLevel: EffortLevel.high,
nextDueDate: DateTime(2026, 3, 25),
));
final count = await db.calendarDao.getTaskCountInRoom(room1Id);
expect(count, 3);
});
test('does not count tasks from other rooms', () async {
// Two tasks in room1
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Kueche Aufgabe 1',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 16),
));
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room1Id,
name: 'Kueche Aufgabe 2',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 20),
));
// One task in room2 (should NOT be counted for room1)
await db.tasksDao.insertTask(TasksCompanion.insert(
roomId: room2Id,
name: 'Bad Aufgabe',
intervalType: IntervalType.monthly,
effortLevel: EffortLevel.high,
nextDueDate: DateTime(2026, 3, 18),
));
final count = await db.calendarDao.getTaskCountInRoom(room1Id);
expect(count, 2);
});
});
}

View File

@@ -0,0 +1,325 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:household_keeper/core/database/database.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/home/presentation/calendar_providers.dart';
import 'package:household_keeper/features/tasks/domain/effort_level.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 'package:household_keeper/features/tasks/presentation/task_list_screen.dart';
import 'package:household_keeper/l10n/app_localizations.dart';
/// Helper to create a test [Task] with sensible defaults.
Task _makeTask({
int id = 1,
int roomId = 1,
String name = 'Test Task',
required DateTime nextDueDate,
}) {
return Task(
id: id,
roomId: roomId,
name: name,
intervalType: IntervalType.weekly,
intervalDays: 7,
effortLevel: EffortLevel.medium,
nextDueDate: nextDueDate,
createdAt: DateTime(2026, 1, 1),
);
}
/// Helper to create a [TaskWithRoom].
TaskWithRoom _makeTaskWithRoom({
int id = 1,
int roomId = 1,
String taskName = 'Test Task',
String roomName = 'Kueche',
required DateTime nextDueDate,
}) {
return TaskWithRoom(
task: _makeTask(
id: id,
roomId: roomId,
name: taskName,
nextDueDate: nextDueDate,
),
roomName: roomName,
roomId: roomId,
);
}
/// Build the app with [roomCalendarDayProvider] overridden to the given state.
///
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
/// the riverpod_lint scoped_providers_should_specify_dependencies warning.
///
/// Renders [TaskListScreen] directly with roomId 1, wrapped in a [MaterialApp]
/// to avoid router complexity. The _RoomTitle widget will show '...' because
/// appDatabaseProvider is not overridden — this is acceptable for these tests.
Widget _buildApp(CalendarDayState dayState) {
final container = ProviderContainer(overrides: [
roomCalendarDayProvider.overrideWith(
(ref, roomId) => Stream.value(dayState),
),
selectedDateProvider.overrideWith(SelectedDateNotifier.new),
sortPreferenceProvider.overrideWith(SortPreferenceNotifier.new),
]);
return UncontrolledProviderScope(
container: container,
child: const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: [Locale('de')],
locale: Locale('de'),
home: TaskListScreen(roomId: 1),
),
);
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
group('TaskListScreen empty states', () {
testWidgets('shows no-tasks empty state when no tasks exist in room',
(tester) async {
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: today,
dayTasks: const [],
overdueTasks: const [],
totalTaskCount: 0,
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Should show "Noch keine Aufgaben" (taskEmptyTitle)
expect(find.text('Noch keine Aufgaben'), findsOneWidget);
// Should show action button to create a task
expect(find.text('Aufgabe erstellen'), findsOneWidget);
});
testWidgets('shows celebration state when tasks exist but today is clear',
(tester) async {
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: today,
dayTasks: const [],
overdueTasks: const [],
totalTaskCount: 5, // tasks exist elsewhere
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Should show celebration state
expect(find.byIcon(Icons.celebration_outlined), findsOneWidget);
expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget);
});
testWidgets('shows empty-day state for non-today date with no tasks',
(tester) async {
final tomorrow = today.add(const Duration(days: 1));
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: tomorrow,
dayTasks: const [],
overdueTasks: const [],
totalTaskCount: 5, // tasks exist on other days
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Should show "Keine Aufgaben" (not celebration — not today)
expect(find.text('Keine Aufgaben'), findsOneWidget);
expect(find.byIcon(Icons.event_available), findsOneWidget);
});
});
group('TaskListScreen normal state', () {
testWidgets('shows overdue section when overdue tasks exist (today)',
(tester) async {
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: today,
dayTasks: [
_makeTaskWithRoom(
id: 2,
taskName: 'Staubsaugen',
roomName: 'Wohnzimmer',
nextDueDate: today,
),
],
overdueTasks: [
_makeTaskWithRoom(
id: 1,
taskName: 'Boden wischen',
roomName: 'Kueche',
nextDueDate: yesterday,
),
],
totalTaskCount: 2,
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Should show overdue section header
expect(find.text('\u00dcberf\u00e4llig'), findsOneWidget);
// Should show both tasks
expect(find.text('Boden wischen'), findsOneWidget);
expect(find.text('Staubsaugen'), findsOneWidget);
});
testWidgets('does not show overdue section for non-today date',
(tester) async {
final tomorrow = today.add(const Duration(days: 1));
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: tomorrow,
dayTasks: [
_makeTaskWithRoom(
id: 1,
taskName: 'Staubsaugen',
roomName: 'Wohnzimmer',
nextDueDate: tomorrow,
),
],
overdueTasks: const [], // No overdue for non-today
totalTaskCount: 1,
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Should NOT show overdue section header
expect(find.text('\u00dcberf\u00e4llig'), findsNothing);
// Should show day task
expect(find.text('Staubsaugen'), findsOneWidget);
});
testWidgets('tasks have checkboxes', (tester) async {
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: today,
dayTasks: [
_makeTaskWithRoom(
id: 1,
taskName: 'Staubsaugen',
roomName: 'Wohnzimmer',
nextDueDate: today,
),
],
overdueTasks: const [],
totalTaskCount: 1,
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Task should have a checkbox
expect(find.byType(Checkbox), findsOneWidget);
});
testWidgets('room tag is NOT shown on task rows', (tester) async {
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: today,
dayTasks: [
_makeTaskWithRoom(
id: 1,
taskName: 'Staubsaugen',
roomName: 'Kueche',
nextDueDate: today,
),
],
overdueTasks: const [],
totalTaskCount: 1,
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Task name should be visible
expect(find.text('Staubsaugen'), findsOneWidget);
// Room tag text should NOT appear (showRoomTag is false in room context)
expect(find.text('Kueche'), findsNothing);
});
});
group('TaskListScreen future task restriction', () {
testWidgets('checkboxes are disabled for future tasks', (tester) async {
final futureDate = today.add(const Duration(days: 3));
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: futureDate,
dayTasks: [
_makeTaskWithRoom(
id: 1,
taskName: 'Fenster putzen',
roomName: 'Kueche',
nextDueDate: futureDate,
),
],
overdueTasks: const [],
totalTaskCount: 1,
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Task should be visible
expect(find.text('Fenster putzen'), findsOneWidget);
// Checkbox should exist but be disabled (onChanged is null)
final checkbox = tester.widget<Checkbox>(find.byType(Checkbox));
expect(checkbox.onChanged, isNull);
});
});
group('TaskListScreen AppBar', () {
testWidgets('shows sort dropdown in AppBar', (tester) async {
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: today,
dayTasks: const [],
overdueTasks: const [],
totalTaskCount: 0,
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// SortDropdown wraps a PopupMenuButton<TaskSortOption>
expect(
find.byType(PopupMenuButton<TaskSortOption>),
findsOneWidget,
);
});
testWidgets('shows edit and delete icon buttons', (tester) async {
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: today,
dayTasks: const [],
overdueTasks: const [],
totalTaskCount: 0,
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Edit icon button
expect(find.byIcon(Icons.edit), findsOneWidget);
// Delete icon button
expect(find.byIcon(Icons.delete), findsOneWidget);
});
testWidgets('calendar strip is shown', (tester) async {
await tester.pumpWidget(_buildApp(CalendarDayState(
selectedDate: today,
dayTasks: const [],
overdueTasks: const [],
totalTaskCount: 0,
)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// The strip is a horizontal ListView — verify it exists by finding
// ListView widgets (strip + potentially the task list).
expect(find.byType(ListView), findsWidgets);
});
});
}