Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa778a238a | |||
| d220dbe5ce | |||
| edce11dd78 |
@@ -86,6 +86,30 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Set version from git tag
|
||||
run: |
|
||||
set -e
|
||||
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||
|
||||
# Strip leading 'v' if present (v1.2.3 -> 1.2.3)
|
||||
VERSION="${RAW_TAG#v}"
|
||||
|
||||
# Extract a numeric build number for versionCode.
|
||||
# Converts semver x.y.z into a single integer: x*10000 + y*100 + z
|
||||
# e.g. 1.1.1 -> 10101, 2.3.15 -> 20315
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
|
||||
echo "Version: $VERSION, VersionCode: $VERSION_CODE"
|
||||
|
||||
# Update pubspec.yaml: replace the version line
|
||||
sed -i "s/^version: .*/version: ${VERSION}+${VERSION_CODE}/" pubspec.yaml
|
||||
|
||||
grep '^version:' pubspec.yaml
|
||||
|
||||
# ADD THIS NEW STEP
|
||||
- name: Setup Android Keystore
|
||||
env:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"commit_docs": true,
|
||||
"model_profile": "balanced",
|
||||
"workflow": {
|
||||
"research": true,
|
||||
"research": false,
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
|
||||
@@ -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
|
||||
81
.planning/milestones/v1.1-ROADMAP.md
Normal file
81
.planning/milestones/v1.1-ROADMAP.md
Normal 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 | - |
|
||||
105
.planning/milestones/v1.1-phases/05-calendar-strip/5-CONTEXT.md
Normal file
105
.planning/milestones/v1.1-phases/05-calendar-strip/5-CONTEXT.md
Normal 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*
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
325
test/features/tasks/presentation/task_list_screen_test.dart
Normal file
325
test/features/tasks/presentation/task_list_screen_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user