Compare commits
52 Commits
b0765795b8
...
v1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ea79e0853 | |||
| 772034cba1 | |||
| a3e4d0224b | |||
| e5eccb74e5 | |||
| 9398193c1e | |||
| 3697e4efc4 | |||
| 13c7d623ba | |||
| a9f298350e | |||
| a44f2b80b5 | |||
| 27f18d4f39 | |||
| a94d41b7f7 | |||
| 99358ed704 | |||
| 2a4b14cb43 | |||
| 7a2c1b81de | |||
| 7344933278 | |||
| 9f902ff2c7 | |||
| ceae7d7d61 | |||
| 2687f5e31e | |||
| 97eaa6dacc | |||
| 03ebaac5a8 | |||
| dec15204de | |||
| adb46d847e | |||
| b674497003 | |||
| 7536f2f759 | |||
| 27b1a80f29 | |||
| 88ef248a33 | |||
| f718ee8483 | |||
| 01de2d0f9c | |||
| 588f215078 | |||
| 68ba7c65ce | |||
| c666f9a1c6 | |||
| f5c4b4928f | |||
| 31d4ef879b | |||
| fe7ba21061 | |||
| 90ff66223c | |||
| b7a243603c | |||
| fa26d6b301 | |||
| 6bb1bc35d7 | |||
| ead085ad26 | |||
| 74a801c6f2 | |||
| 0059095e38 | |||
| 8c72403c85 | |||
| 1a1a10c9ea | |||
| 36126acc18 | |||
| 76192e22fa | |||
| 9c2ae12012 | |||
| dcb2cd0afa | |||
| c2570cdc01 | |||
| 3c2ad5c7c6 | |||
| f6272a39b4 | |||
| 170326dd85 | |||
| 74de67de59 |
@@ -8,7 +8,10 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -19,15 +22,71 @@ jobs:
|
|||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
run: |
|
||||||
|
sdkmanager --licenses >/dev/null <<'EOF'
|
||||||
|
y
|
||||||
|
y
|
||||||
|
y
|
||||||
|
y
|
||||||
|
y
|
||||||
|
y
|
||||||
|
y
|
||||||
|
y
|
||||||
|
y
|
||||||
|
y
|
||||||
|
EOF
|
||||||
|
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Install jq
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SUDO=""
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y jq
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
$SUDO apk add --no-cache jq
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
$SUDO dnf install -y jq
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
$SUDO yum install -y jq
|
||||||
|
else
|
||||||
|
echo "Could not find a supported package manager to install jq"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: '3.11.0'
|
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Trust Flutter SDK git directory
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
FLUTTER_BIN_DIR="$(dirname "$(command -v flutter)")"
|
||||||
|
FLUTTER_SDK_DIR="$(cd "$FLUTTER_BIN_DIR/.." && pwd -P)"
|
||||||
|
git config --global --add safe.directory "$FLUTTER_SDK_DIR"
|
||||||
|
if [ -n "${FLUTTER_ROOT:-}" ]; then
|
||||||
|
git config --global --add safe.directory "$FLUTTER_ROOT"
|
||||||
|
fi
|
||||||
|
# Runner-specific fallback observed in failing logs
|
||||||
|
git config --global --add safe.directory /opt/hostedtoolcache/flutter/stable-3.41.4-x64 || true
|
||||||
|
|
||||||
|
- name: Verify Android + Flutter toolchain
|
||||||
|
run: flutter doctor -v
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
|
# ADD THIS NEW STEP
|
||||||
- name: Setup Android Keystore
|
- name: Setup Android Keystore
|
||||||
env:
|
env:
|
||||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
@@ -37,6 +96,7 @@ jobs:
|
|||||||
# Decode the base64 string back into the binary .jks file
|
# Decode the base64 string back into the binary .jks file
|
||||||
echo "$KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks
|
echo "$KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks
|
||||||
|
|
||||||
|
# Create the key.properties file that build.gradle expects
|
||||||
echo "storePassword=$KEY_PASSWORD" > android/key.properties
|
echo "storePassword=$KEY_PASSWORD" > android/key.properties
|
||||||
echo "keyPassword=$KEY_PASSWORD" >> android/key.properties
|
echo "keyPassword=$KEY_PASSWORD" >> android/key.properties
|
||||||
echo "keyAlias=$KEY_ALIAS" >> android/key.properties
|
echo "keyAlias=$KEY_ALIAS" >> android/key.properties
|
||||||
@@ -47,8 +107,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup F-Droid Server Tools
|
- name: Setup F-Droid Server Tools
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
SUDO=""
|
||||||
sudo apt-get install -y fdroidserver sshpass
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
$SUDO apt-get update
|
||||||
|
# sshpass from apt, fdroidserver via pip to get a newer androguard that
|
||||||
|
# can parse modern Flutter/AGP APKs (apt ships fdroidserver 2.2.1 which crashes)
|
||||||
|
$SUDO apt-get install -y sshpass python3-pip
|
||||||
|
pip3 install --break-system-packages --upgrade fdroidserver
|
||||||
|
|
||||||
- name: Initialize or fetch F-Droid Repository
|
- name: Initialize or fetch F-Droid Repository
|
||||||
env:
|
env:
|
||||||
@@ -57,16 +124,49 @@ jobs:
|
|||||||
PASS: ${{ secrets.HETZNER_PASS }}
|
PASS: ${{ secrets.HETZNER_PASS }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p fdroid
|
mkdir -p fdroid
|
||||||
|
|
||||||
|
# Ensure remote path exists (sftp mkdir, ignoring errors if already present).
|
||||||
|
sshpass -p "$PASS" sftp -o StrictHostKeyChecking=no "$USER@$HOST" <<'SFTP'
|
||||||
|
-mkdir dev
|
||||||
|
-mkdir dev/fdroid
|
||||||
|
-mkdir dev/fdroid/repo
|
||||||
|
SFTP
|
||||||
|
|
||||||
|
# Try to download the entire fdroid/ directory from Hetzner to keep
|
||||||
|
# older APKs, the repo keystore, and config.yml across runs.
|
||||||
|
# If it fails (first time), initialize a new local repo.
|
||||||
|
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/." fdroid/ || (cd fdroid && fdroid init)
|
||||||
|
|
||||||
|
- name: Ensure F-Droid repo signing key and icon
|
||||||
|
run: |
|
||||||
cd fdroid
|
cd fdroid
|
||||||
|
|
||||||
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r $USER@$HOST:dev/fdroid/repo . || fdroid init
|
# Ensure repo icon exists (use app launcher icon)
|
||||||
|
mkdir -p repo/icons
|
||||||
|
if [ ! -f repo/icons/icon.png ]; then
|
||||||
|
cp ../android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png repo/icons/icon.png
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If keystore doesn't exist, create the signing key.
|
||||||
|
# This only runs on the very first deployment; subsequent runs
|
||||||
|
# download the keystore from Hetzner via the scp step above.
|
||||||
|
if [ ! -f keystore.p12 ]; then
|
||||||
|
fdroid update --create-key
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Copy new APK to repo
|
- name: Copy new APK to repo
|
||||||
run: |
|
run: |
|
||||||
# The app-release.apk name should ideally include the version number
|
set -e
|
||||||
# so it doesn't overwrite older versions in the repo.
|
mkdir -p fdroid/repo
|
||||||
VERSION_TAG=${GITHUB_REF#refs/tags/} # gets 'v1.0.0'
|
|
||||||
cp build/app/outputs/flutter-apk/app-release.apk fdroid/repo/my_flutter_app_${VERSION_TAG}.apk
|
# Prefer tag name for release builds; fallback to ref name for manual runs.
|
||||||
|
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||||
|
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
|
||||||
|
if [ -z "$SAFE_REF_NAME" ]; then
|
||||||
|
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp build/app/outputs/flutter-apk/app-release.apk "fdroid/repo/my_flutter_app_${SAFE_REF_NAME}.apk"
|
||||||
|
|
||||||
- name: Generate F-Droid Index
|
- name: Generate F-Droid Index
|
||||||
run: |
|
run: |
|
||||||
@@ -79,5 +179,17 @@ jobs:
|
|||||||
USER: ${{ secrets.HETZNER_USER }}
|
USER: ${{ secrets.HETZNER_USER }}
|
||||||
PASS: ${{ secrets.HETZNER_PASS }}
|
PASS: ${{ secrets.HETZNER_PASS }}
|
||||||
run: |
|
run: |
|
||||||
# Use rsync to efficiently upload only the changed files (the new APK and updated index files)
|
set -euo pipefail
|
||||||
sshpass -p "$PASS" rsync -avz -e "ssh -o StrictHostKeyChecking=no" fdroid/repo/ $USER@$HOST:dev/fdroid/repo/
|
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
||||||
|
|
||||||
|
# Create remote directory tree via SFTP batch (no exec channel needed).
|
||||||
|
# Leading '-' on each mkdir means "ignore error if already exists".
|
||||||
|
sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP'
|
||||||
|
-mkdir dev
|
||||||
|
-mkdir dev/fdroid
|
||||||
|
-mkdir dev/fdroid/repo
|
||||||
|
SFTP
|
||||||
|
|
||||||
|
# Upload the entire fdroid/ directory (repo + keystore + config)
|
||||||
|
# so the signing key persists across runs.
|
||||||
|
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/"
|
||||||
|
|||||||
20
.planning/MILESTONES.md
Normal file
20
.planning/MILESTONES.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Milestones
|
||||||
|
|
||||||
|
## v1.0 MVP (Shipped: 2026-03-16)
|
||||||
|
|
||||||
|
**Phases completed:** 4 phases, 13 plans
|
||||||
|
**Codebase:** 10,588 LOC Dart (7,773 lib + 2,815 test), 89 tests, 76 commits
|
||||||
|
**Timeline:** 2 days (2026-03-15 to 2026-03-16)
|
||||||
|
|
||||||
|
**Key accomplishments:**
|
||||||
|
1. Flutter project with Drift SQLite, Riverpod 3 state management, ARB localization, and calm sage & stone Material 3 theme
|
||||||
|
2. Full room CRUD with drag-and-drop reorder, icon picker, and cleanliness indicator per room card
|
||||||
|
3. Task CRUD with 11 frequency presets + custom intervals, calendar-anchored scheduling with anchor memory, and auto-calculated next due dates
|
||||||
|
4. Bundled German-language task templates for 14 room types with post-creation template picker
|
||||||
|
5. Daily plan home screen with overdue/today/tomorrow sections, animated checkbox completion, and progress tracking
|
||||||
|
6. Daily summary notification with configurable time, POST_NOTIFICATIONS permission handling, and boot receiver rescheduling
|
||||||
|
|
||||||
|
**Archive:** See `milestones/v1.0-ROADMAP.md` and `milestones/v1.0-REQUIREMENTS.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -2,30 +2,46 @@
|
|||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
A local-first Flutter app for organizing household chores and one-time projects, built for personal/couple use on Android. Takes the room-based task scheduling model (inspired by BeTidy), strips cloud/account/social features, and wraps it in a calm, minimal Material 3 UI. 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 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.
|
||||||
|
|
||||||
## Core Value
|
## 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.
|
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
|
## Requirements
|
||||||
|
|
||||||
### Validated
|
### Validated
|
||||||
|
|
||||||
(None yet — ship to validate)
|
- Room CRUD with icons and drag-and-drop reorder — v1.0
|
||||||
|
- Task CRUD with frequency intervals and due date calculation — v1.0
|
||||||
|
- Daily plan view with overdue/today/upcoming sections — v1.0
|
||||||
|
- Task completion with auto-scheduling of next due date — v1.0
|
||||||
|
- Bundled task templates per room type (German only, 14 room types) — v1.0
|
||||||
|
- 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
|
||||||
|
|
||||||
### Active
|
### Active
|
||||||
|
|
||||||
- [ ] Room CRUD with icons and optional photos
|
- [ ] Horizontal calendar strip home screen (replacing stacked daily plan)
|
||||||
- [ ] Task CRUD with frequency intervals and due date calculation
|
- [ ] Overdue task carry-over with visual accent
|
||||||
- [ ] Daily plan view with overdue/today/upcoming sections
|
- [ ] Task completion history log
|
||||||
- [ ] Task completion with auto-scheduling of next due date
|
- [ ] Additional task sorting (alphabetical, interval, effort)
|
||||||
- [ ] Bundled task templates per room type (German only)
|
- [ ] Data export/import (JSON) — deferred
|
||||||
- [ ] Daily summary notification
|
- [ ] English localization — deferred
|
||||||
- [ ] Light/dark theme with calm Material 3 palette
|
- [ ] Room cover photos from camera or gallery — deferred
|
||||||
- [ ] Cleanliness indicator per room (based on overdue vs on-time)
|
|
||||||
- [ ] Task sorting (due date, alphabetical, interval, effort)
|
|
||||||
- [ ] Task history (completion log per task)
|
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
@@ -34,8 +50,9 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
|
|||||||
- Subscription model / in-app purchases — free forever
|
- Subscription model / in-app purchases — free forever
|
||||||
- Family profile sharing across devices — single-device app
|
- Family profile sharing across devices — single-device app
|
||||||
- Server-side infrastructure — zero backend
|
- Server-side infrastructure — zero backend
|
||||||
- Data export/import (JSON) — deferred to v1.1
|
- AI-powered task suggestions — overkill for curated templates
|
||||||
- English localization — deferred to v1.1 (ship German-only MVP)
|
- Per-task push notifications — daily summary is more effective
|
||||||
|
- Firebase or any Google cloud services — contradicts local-first design
|
||||||
- Real-time cross-device sync — potential future self-hosted feature
|
- Real-time cross-device sync — potential future self-hosted feature
|
||||||
- Tablet-optimized layout — future enhancement
|
- Tablet-optimized layout — future enhancement
|
||||||
- Statistics & insights dashboard — v2.0
|
- Statistics & insights dashboard — v2.0
|
||||||
@@ -44,11 +61,11 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
|
|||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
- Inspired by BeTidy (iOS/Android household cleaning app) — taking the proven room-based model, removing cloud/social, refining the UI
|
- 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
|
||||||
|
- 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
|
- 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 MVP
|
- Code and comments in English; UI strings German-only for v1.0
|
||||||
- Room photos are nice-to-have for MVP — icon-only rooms are sufficient initially
|
|
||||||
- Developer is new to Drift (SQLite ORM) — plan should account for learning curve
|
|
||||||
- Gitea (self-hosted on Hetzner) for version control; no CI/CD pipeline yet
|
- Gitea (self-hosted on Hetzner) for version control; no CI/CD pipeline yet
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
@@ -57,20 +74,23 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
|
|||||||
- **Platform**: Android-first (iOS later)
|
- **Platform**: Android-first (iOS later)
|
||||||
- **Offline**: 100% offline-capable, zero network dependencies
|
- **Offline**: 100% offline-capable, zero network dependencies
|
||||||
- **Privacy**: No data leaves the device, no analytics, no tracking
|
- **Privacy**: No data leaves the device, no analytics, no tracking
|
||||||
- **Language**: German-only UI for MVP, English code/comments
|
- **Language**: German-only UI for v1.0, English code/comments
|
||||||
- **No CI**: No automated build pipeline initially
|
- **No CI**: No automated build pipeline initially
|
||||||
|
|
||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
| Decision | Rationale | Outcome |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| Riverpod over Bloc | Modern, compile-safe, less boilerplate, Dart-native | — Pending |
|
| Riverpod 3 over Bloc | Modern, compile-safe, less boilerplate, Dart-native | Good — code generation works well, @riverpod annotation reduces boilerplate |
|
||||||
| Drift over raw sqflite | Type-safe queries, compile-time validation, migration support | — Pending |
|
| Drift over raw sqflite | Type-safe queries, compile-time validation, migration support | Good — DAOs with stream queries provide reactive UI, migration workflow established |
|
||||||
| Android-first | Primary device is Android; iOS follows | — Pending |
|
| Android-first | Primary device is Android; iOS follows | Good — no iOS-specific issues encountered |
|
||||||
| German-only MVP | Primary user language; defer localization infrastructure | — Pending |
|
| German-only MVP | Primary user language; defer localization infrastructure | Good — ARB localization infrastructure in place from Phase 1, ready for English |
|
||||||
| No CI initially | Keep scope focused on the app itself | — Pending |
|
| No CI initially | Keep scope focused on the app itself | Good — manual dart analyze + flutter test sufficient for solo dev |
|
||||||
| Calm Material 3 palette | Muted greens, warm grays, gentle blues — calm productivity, not playful | — Pending |
|
| Calm Material 3 palette | Muted greens, warm grays, gentle blues — calm productivity | Good — sage & stone theme (seed 0xFF7A9A6D) with warm charcoal dark mode |
|
||||||
| Clean Architecture | Feature-based folder structure with data/domain/presentation layers | — Pending |
|
| Clean Architecture | Feature-based folder structure with data/domain/presentation layers | Good — clear separation, easy to navigate |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last updated: 2026-03-15 after initialization*
|
*Last updated: 2026-03-16 after v1.1 milestone started*
|
||||||
|
|||||||
@@ -1,102 +1,47 @@
|
|||||||
# Requirements: HouseHoldKeaper
|
# Requirements: HouseHoldKeaper
|
||||||
|
|
||||||
**Defined:** 2026-03-15
|
**Defined:** 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.
|
**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.
|
||||||
|
|
||||||
## v1 Requirements
|
## v1.1 Requirements
|
||||||
|
|
||||||
Requirements for initial release. Each maps to roadmap phases.
|
Requirements for milestone v1.1 Calendar & Polish. Each maps to roadmap phases.
|
||||||
|
|
||||||
### Room Management
|
### Calendar UI
|
||||||
|
|
||||||
- [x] **ROOM-01**: User can create a room with a name and an icon from a curated Material Icons set
|
- [x] **CAL-01**: User sees a horizontal scrollable date-strip with day abbreviation (Mo, Di...) and date number per card
|
||||||
- [x] **ROOM-02**: User can edit a room's name and icon
|
- [x] **CAL-02**: User can tap a day card to see that day's tasks in a list below the strip
|
||||||
- [x] **ROOM-03**: User can delete a room with confirmation (cascades to associated tasks)
|
- [x] **CAL-03**: User sees a subtle color shift at month boundaries for visual orientation
|
||||||
- [x] **ROOM-04**: User can reorder rooms via drag-and-drop on the rooms screen
|
- [x] **CAL-04**: Calendar strip auto-scrolls to today on app launch
|
||||||
- [x] **ROOM-05**: User can view all rooms as cards showing name, icon, due task count, and cleanliness indicator
|
- [x] **CAL-05**: Undone tasks carry over to the next day with a red/orange color accent marking them as overdue
|
||||||
|
|
||||||
### Task Management
|
### Task History
|
||||||
|
|
||||||
- [x] **TASK-01**: User can create a task within a room with name, optional description, frequency interval, and effort level
|
- [x] **HIST-01**: Each task completion is recorded with a timestamp
|
||||||
- [x] **TASK-02**: User can edit a task's name, description, frequency interval, and effort level
|
- [x] **HIST-02**: User can view past completion dates for any individual task
|
||||||
- [x] **TASK-03**: User can delete a task with confirmation
|
|
||||||
- [x] **TASK-04**: User can set frequency interval from: daily, every 2 days, every 3 days, weekly, biweekly, monthly, every 2 months, quarterly, every 6 months, yearly, or custom (every N days)
|
|
||||||
- [x] **TASK-05**: User can set effort level (low / medium / high) on a task
|
|
||||||
- [x] **TASK-06**: User can sort tasks within a room by due date (default sort order)
|
|
||||||
- [x] **TASK-07**: User can mark a task as done via tap or swipe, which records a completion timestamp and auto-calculates the next due date based on the interval
|
|
||||||
- [x] **TASK-08**: Overdue tasks are visually highlighted with distinct color/badge on room cards and in task lists
|
|
||||||
|
|
||||||
### Task Templates
|
### Task Sorting
|
||||||
|
|
||||||
- [x] **TMPL-01**: When creating a room, user can select from bundled German-language task templates appropriate for that room type
|
- [x] **SORT-01**: User can sort tasks alphabetically
|
||||||
- [x] **TMPL-02**: Preset room types with templates include: Küche, Badezimmer, Schlafzimmer, Wohnzimmer, Flur, Büro, Garage, Balkon, Waschküche, Keller, Kinderzimmer, Gästezimmer, Esszimmer, Garten/Außenbereich
|
- [x] **SORT-02**: User can sort tasks by frequency interval
|
||||||
|
- [x] **SORT-03**: User can sort tasks by effort level
|
||||||
|
|
||||||
### Daily Plan
|
## Future Requirements
|
||||||
|
|
||||||
- [x] **PLAN-01**: User sees all tasks due today grouped by room on the daily plan screen (primary/default screen)
|
|
||||||
- [x] **PLAN-02**: Overdue tasks appear in a separate highlighted section at the top of the daily plan
|
|
||||||
- [x] **PLAN-03**: User can preview upcoming tasks (tomorrow / this week)
|
|
||||||
- [x] **PLAN-04**: User can swipe-to-complete or tap checkbox to mark tasks done directly from the daily plan view
|
|
||||||
- [x] **PLAN-05**: User sees a progress indicator showing completed vs total tasks for today (e.g. "5 of 12 tasks done")
|
|
||||||
- [x] **PLAN-06**: When no tasks are due, user sees an encouraging "all clear" empty state
|
|
||||||
|
|
||||||
### Cleanliness Indicator
|
|
||||||
|
|
||||||
- [x] **CLEAN-01**: Each room card displays a cleanliness indicator derived from the ratio of overdue tasks to total tasks in that room
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
|
|
||||||
- [x] **NOTF-01**: User receives a daily summary notification showing today's task count at a configurable time
|
|
||||||
- [x] **NOTF-02**: User can enable/disable notifications in settings
|
|
||||||
|
|
||||||
### Theme & UI
|
|
||||||
|
|
||||||
- [x] **THEME-01**: App supports light and dark themes, following the system setting by default
|
|
||||||
- [x] **THEME-02**: App uses a calm Material 3 palette with muted greens, warm grays, and gentle blues
|
|
||||||
|
|
||||||
### Foundation
|
|
||||||
|
|
||||||
- [x] **FOUND-01**: App uses Drift for local SQLite storage with proper schema migration workflow
|
|
||||||
- [x] **FOUND-02**: App uses Riverpod 3 for state management with code generation
|
|
||||||
- [x] **FOUND-03**: App uses localization infrastructure (ARB files + AppLocalizations) with German locale, even though only one language ships in v1
|
|
||||||
- [x] **FOUND-04**: Bottom navigation with tabs: Home (Daily Plan), Rooms, Settings
|
|
||||||
|
|
||||||
## v2 Requirements
|
|
||||||
|
|
||||||
Deferred to future release. Tracked but not in current roadmap.
|
Deferred to future release. Tracked but not in current roadmap.
|
||||||
|
|
||||||
### v1.1 — Near-Term
|
### Data
|
||||||
|
|
||||||
- **EXPORT-01**: User can export all data as JSON file
|
- **DATA-01**: User can export all data as JSON
|
||||||
- **EXPORT-02**: User can import data from a JSON file
|
- **DATA-02**: User can import data from JSON backup
|
||||||
- **I18N-01**: App supports English as a second language
|
|
||||||
- **PHOTO-01**: User can add a cover photo to a room from camera or gallery
|
|
||||||
- **HIST-01**: User can view a completion history log per task (scrollable timeline of completion dates)
|
|
||||||
- **SORT-01**: User can sort tasks by alphabetical order, interval length, or effort level (in addition to due date)
|
|
||||||
|
|
||||||
### v1.2 — Medium-Term
|
### Localization
|
||||||
|
|
||||||
- **PROJ-01**: User can create one-time organization projects with sub-task steps
|
- **LOC-01**: User can switch UI language to English
|
||||||
- **PROJ-02**: User can attach before/after photos to a project
|
|
||||||
- **PROF-01**: User can create named local profiles for household members
|
|
||||||
- **PROF-02**: User can assign tasks to one or more profiles
|
|
||||||
- **PROF-03**: User can enable task rotation (round-robin) for shared recurring tasks
|
|
||||||
- **PROF-04**: User can filter the daily plan view by profile ("My tasks" vs "All tasks")
|
|
||||||
- **WIDG-01**: Home screen widget showing today's due tasks and overdue count
|
|
||||||
- **CAL-01**: User can view a weekly overview with task load per day
|
|
||||||
- **CAL-02**: User can view a monthly calendar heatmap showing task density
|
|
||||||
- **VAC-01**: User can pause/freeze all task due dates during vacation and resume on return
|
|
||||||
|
|
||||||
### v2.0 — Future
|
### Rooms
|
||||||
|
|
||||||
- **STAT-01**: User can view completion rate (% on time this week/month)
|
- **ROOM-01**: User can set a cover photo for a room from camera or gallery
|
||||||
- **STAT-02**: User can view streak of consecutive days with all tasks completed
|
|
||||||
- **STAT-03**: User can view per-room health scores over time
|
|
||||||
- **ONBRD-01**: First-launch wizard walks user through creating first room and adding tasks
|
|
||||||
- **COLOR-01**: User can pick a custom accent color for the app theme
|
|
||||||
- **SYNC-01**: User can optionally sync data via self-hosted infrastructure
|
|
||||||
- **TABLET-01**: App provides a tablet-optimized layout with adaptive breakpoints
|
|
||||||
- **NOTF-03**: Optional evening nudge notification if overdue tasks remain
|
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
@@ -104,14 +49,10 @@ Explicitly excluded. Documented to prevent scope creep.
|
|||||||
|
|
||||||
| Feature | Reason |
|
| Feature | Reason |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| User accounts & cloud sync | Local-only by design — zero backend, zero data leaves device |
|
| Weekly/monthly calendar views | Overcomplicates UI — date strip is sufficient for task app |
|
||||||
| Leaderboards & points ranking | Anti-feature — gamification causes app burnout and embeds unequal labor dynamics |
|
| Drag tasks between days | Not needed — tasks auto-schedule based on frequency |
|
||||||
| Subscription model / in-app purchases | Free forever by design — paywalls are the #2 complaint in chore app reviews |
|
| Calendar sync (Google/Apple) | Contradicts local-first, offline-only design |
|
||||||
| Family profile sharing across devices | Single-device app; cross-device requires cloud infrastructure |
|
| Task statistics/charts | Deferred to v2.0 — history log is the foundation |
|
||||||
| AI-powered task suggestions | Requires network/ML; overkill for personal app with curated templates |
|
|
||||||
| Per-task push notifications | Causes notification fatigue; daily summary is more effective for habit formation |
|
|
||||||
| Focus timer / Pomodoro | Not a productivity timer app; out of domain |
|
|
||||||
| Firebase or any Google cloud services | Contradicts local-first, privacy-first design |
|
|
||||||
|
|
||||||
## Traceability
|
## Traceability
|
||||||
|
|
||||||
@@ -119,42 +60,22 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| FOUND-01 | Phase 1: Foundation | Complete |
|
| CAL-01 | Phase 5 | Complete |
|
||||||
| FOUND-02 | Phase 1: Foundation | Complete |
|
| CAL-02 | Phase 5 | Complete |
|
||||||
| FOUND-03 | Phase 1: Foundation | Complete |
|
| CAL-03 | Phase 5 | Complete |
|
||||||
| FOUND-04 | Phase 1: Foundation | Complete |
|
| CAL-04 | Phase 5 | Complete |
|
||||||
| THEME-01 | Phase 1: Foundation | Complete |
|
| CAL-05 | Phase 5 | Complete |
|
||||||
| THEME-02 | Phase 1: Foundation | Complete |
|
| HIST-01 | Phase 6 | Complete |
|
||||||
| ROOM-01 | Phase 2: Rooms and Tasks | Complete |
|
| HIST-02 | Phase 6 | Complete |
|
||||||
| ROOM-02 | Phase 2: Rooms and Tasks | Complete |
|
| SORT-01 | Phase 7 | Complete |
|
||||||
| ROOM-03 | Phase 2: Rooms and Tasks | Complete |
|
| SORT-02 | Phase 7 | Complete |
|
||||||
| ROOM-04 | Phase 2: Rooms and Tasks | Complete |
|
| SORT-03 | Phase 7 | Complete |
|
||||||
| ROOM-05 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TASK-01 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TASK-02 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TASK-03 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TASK-04 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TASK-05 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TASK-06 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TASK-07 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TASK-08 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TMPL-01 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| TMPL-02 | Phase 2: Rooms and Tasks | Complete |
|
|
||||||
| PLAN-01 | Phase 3: Daily Plan and Cleanliness | Complete |
|
|
||||||
| PLAN-02 | Phase 3: Daily Plan and Cleanliness | Complete |
|
|
||||||
| PLAN-03 | Phase 3: Daily Plan and Cleanliness | Complete |
|
|
||||||
| PLAN-04 | Phase 3: Daily Plan and Cleanliness | Complete |
|
|
||||||
| PLAN-05 | Phase 3: Daily Plan and Cleanliness | Complete |
|
|
||||||
| PLAN-06 | Phase 3: Daily Plan and Cleanliness | Complete |
|
|
||||||
| CLEAN-01 | Phase 3: Daily Plan and Cleanliness | Complete |
|
|
||||||
| NOTF-01 | Phase 4: Notifications | Complete |
|
|
||||||
| NOTF-02 | Phase 4: Notifications | Complete |
|
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
- v1 requirements: 30 total
|
- v1.1 requirements: 10 total
|
||||||
- Mapped to phases: 30
|
- Mapped to phases: 10
|
||||||
- Unmapped: 0
|
- Unmapped: 0
|
||||||
|
|
||||||
---
|
---
|
||||||
*Requirements defined: 2026-03-15*
|
*Requirements defined: 2026-03-16*
|
||||||
*Last updated: 2026-03-15 after roadmap creation*
|
*Last updated: 2026-03-16 after roadmap creation (phases 5-7)*
|
||||||
|
|||||||
65
.planning/RETROSPECTIVE.md
Normal file
65
.planning/RETROSPECTIVE.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Project Retrospective
|
||||||
|
|
||||||
|
*A living document updated after each milestone. Lessons feed forward into future planning.*
|
||||||
|
|
||||||
|
## Milestone: v1.0 — MVP
|
||||||
|
|
||||||
|
**Shipped:** 2026-03-16
|
||||||
|
**Phases:** 4 | **Plans:** 13
|
||||||
|
|
||||||
|
### What Was Built
|
||||||
|
- Complete room-based household chore app with auto-scheduling task management
|
||||||
|
- Daily plan home screen with overdue/today/tomorrow sections and progress tracking
|
||||||
|
- Bundled German task templates for 14 room types
|
||||||
|
- Daily summary notifications with configurable time and Android permission handling
|
||||||
|
- 89 tests covering DAOs, scheduling logic, providers, and widget behavior
|
||||||
|
|
||||||
|
### What Worked
|
||||||
|
- Bottom-up phase structure (foundation -> data -> UI -> polish) kept each phase clean with minimal rework
|
||||||
|
- TDD approach for providers and services caught several issues early (async race conditions, API mismatches)
|
||||||
|
- Verification gates at the end of Phase 2, 3, and 4 confirmed all requirements before moving on
|
||||||
|
- Calendar-anchored scheduling with anchor memory was designed right the first time — no rework needed
|
||||||
|
- ARB localization from Phase 1 meant adding German strings was frictionless throughout
|
||||||
|
|
||||||
|
### What Was Inefficient
|
||||||
|
- riverpod_generator InvalidTypeException with drift Task type required workaround (manual StreamProvider) in 3 separate plans — should have been caught in Phase 1 research
|
||||||
|
- Some plan specifications referenced outdated API patterns (flutter_local_notifications positional parameters removed in v20+) — research needs to verify exact current API signatures
|
||||||
|
- Phase 4 plan checkboxes in ROADMAP.md weren't updated to [x] by executor — minor bookkeeping gap
|
||||||
|
|
||||||
|
### Patterns Established
|
||||||
|
- `@Riverpod(keepAlive: true)` AsyncNotifier with SharedPreferences for persistent settings (ThemeNotifier, NotificationSettingsNotifier)
|
||||||
|
- Manual StreamProvider.family/autoDispose for drift type compatibility
|
||||||
|
- DailyPlanDao innerJoin pattern for cross-table queries
|
||||||
|
- ConsumerStatefulWidget for screens with async callbacks requiring `mounted` guards
|
||||||
|
- Provider override pattern in widget tests for database isolation
|
||||||
|
|
||||||
|
### Key Lessons
|
||||||
|
1. Research phase should verify exact current package API signatures — breaking changes between major versions cause plan deviations
|
||||||
|
2. Drift + riverpod_generator type incompatibility is a known issue — plan for manual providers from the start when using drift
|
||||||
|
3. Verification gates add minimal time (~2 min) but catch integration issues — keep them for all phases
|
||||||
|
4. Progressive disclosure (AnimatedSize) is a clean pattern for conditional settings UI
|
||||||
|
|
||||||
|
### Cost Observations
|
||||||
|
- Model mix: orchestrator on opus, researchers/planners/executors/checkers on sonnet
|
||||||
|
- Total execution: ~1.3 hours for 13 plans across 4 phases
|
||||||
|
- Notable: Verification gates averaged 2 min — very efficient for the confidence they provide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Milestone Trends
|
||||||
|
|
||||||
|
### Process Evolution
|
||||||
|
|
||||||
|
| Milestone | Phases | Plans | Key Change |
|
||||||
|
|-----------|--------|-------|------------|
|
||||||
|
| v1.0 | 4 | 13 | Initial project — established all patterns |
|
||||||
|
|
||||||
|
### Cumulative Quality
|
||||||
|
|
||||||
|
| Milestone | Tests | Key Metric |
|
||||||
|
|-----------|-------|------------|
|
||||||
|
| v1.0 | 89 | dart analyze clean, 0 issues |
|
||||||
|
|
||||||
|
### Top Lessons (Verified Across Milestones)
|
||||||
|
|
||||||
|
1. (Single milestone — lessons above will be cross-validated as more milestones ship)
|
||||||
@@ -1,100 +1,81 @@
|
|||||||
# Roadmap: HouseHoldKeaper
|
# Roadmap: HouseHoldKeaper
|
||||||
|
|
||||||
## Overview
|
## Milestones
|
||||||
|
|
||||||
Four phases build the app bottom-up along its natural dependency chain. Phase 1 lays the technical foundation every subsequent phase relies on. Phase 2 delivers complete room and task management — the core scheduling loop. Phase 3 surfaces that data as the daily plan view (the primary user experience) and adds the cleanliness indicator. Phase 4 adds notifications and completes the v1 feature set. After Phase 3, the app is usable daily. After Phase 4, it is releasable.
|
- **v1.0 MVP** — Phases 1-4 (shipped 2026-03-16)
|
||||||
|
- **v1.1 Calendar & Polish** — Phases 5-7 (in progress)
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
**Phase Numbering:**
|
<details>
|
||||||
- Integer phases (1, 2, 3): Planned milestone work
|
<summary>v1.0 MVP (Phases 1-4) — SHIPPED 2026-03-16</summary>
|
||||||
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
|
||||||
|
|
||||||
Decimal phases appear between their surrounding integers in numeric order.
|
- [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
|
||||||
|
|
||||||
- [x] **Phase 1: Foundation** - Project scaffold, database, state management, theme, and localization infrastructure (completed 2026-03-15)
|
See `milestones/v1.0-ROADMAP.md` for full phase details.
|
||||||
- [x] **Phase 2: Rooms and Tasks** - Complete room CRUD, task CRUD with auto-scheduling, and bundled templates (completed 2026-03-15)
|
|
||||||
- [x] **Phase 3: Daily Plan and Cleanliness** - Primary daily plan screen with overdue/today/upcoming, cleanliness indicators per room (completed 2026-03-16)
|
</details>
|
||||||
- [x] **Phase 4: Notifications** - Daily summary notification with configurable time and Android permission handling (completed 2026-03-16)
|
|
||||||
|
**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 Details
|
||||||
|
|
||||||
### Phase 1: Foundation
|
### Phase 5: Calendar Strip
|
||||||
**Goal**: The app compiles, opens, and enforces correct architecture patterns — ready to receive features without accumulating technical debt
|
**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**: Nothing (first phase)
|
**Depends on**: Phase 4 (v1.0 shipped — all data layer and scheduling in place)
|
||||||
**Requirements**: FOUND-01, FOUND-02, FOUND-03, FOUND-04, THEME-01, THEME-02
|
**Requirements**: CAL-01, CAL-02, CAL-03, CAL-04, CAL-05
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. App launches on Android without errors and shows a bottom navigation bar with Home, Rooms, and Settings tabs
|
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. Light and dark themes work correctly and follow the system setting by default, using the calm Material 3 palette (muted greens, warm grays, gentle blues)
|
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. All UI strings are loaded from ARB localization files — no hardcoded German text in Dart code
|
3. On app launch the strip auto-scrolls so today's card is centered and selected by default
|
||||||
4. The Drift database opens on first launch with schemaVersion 1 and the migration workflow is established (drift_dev make-migrations runs without errors)
|
4. When two adjacent day cards span a month boundary, a subtle color shift or divider makes the boundary visible without extra chrome
|
||||||
5. riverpod_lint is active and flags ref.watch usage outside build() as an analysis error
|
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 plans
|
**Plans:** 2/2 plans complete
|
||||||
Plans:
|
Plans:
|
||||||
- [x] 01-01-PLAN.md — Scaffold Flutter project and build core infrastructure (database, providers, theme, localization)
|
- [ ] 05-01-PLAN.md — Data layer: CalendarDao, CalendarDayState model, Riverpod providers, localization, DAO tests
|
||||||
- [x] 01-02-PLAN.md — Navigation shell, placeholder screens, Settings, and full app wiring
|
- [ ] 05-02-PLAN.md — UI: CalendarStrip, CalendarDayList, CalendarTaskRow widgets, HomeScreen replacement
|
||||||
|
|
||||||
### Phase 2: Rooms and Tasks
|
### Phase 6: Task History
|
||||||
**Goal**: Users can create and manage rooms and tasks, mark tasks done, and trust the app to schedule the next occurrence automatically
|
**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 1
|
**Depends on**: Phase 5
|
||||||
**Requirements**: ROOM-01, ROOM-02, ROOM-03, ROOM-04, ROOM-05, TASK-01, TASK-02, TASK-03, TASK-04, TASK-05, TASK-06, TASK-07, TASK-08, TMPL-01, TMPL-02
|
**Requirements**: HIST-01, HIST-02
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. User can create a room with a name and icon, edit it, reorder rooms via drag-and-drop, and delete it (with confirmation that removes all associated tasks)
|
1. Every task completion (tap done in any view) is recorded in the database with a precise timestamp — data persists across app restarts
|
||||||
2. User can create a task in a room with name, description, frequency interval (daily through yearly and custom), and effort level; tasks can be edited and deleted with confirmation
|
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. When creating a room, user can select from bundled German-language task templates for the chosen room type (all 14 room types covered) and they are added to the room as tasks
|
3. The history view shows a meaningful empty state if the task has never been completed
|
||||||
4. User can mark a task done (tap or swipe), which records the completion and sets the next due date correctly based on the interval
|
**Plans:** 1/1 plans complete
|
||||||
5. Overdue tasks are visually highlighted with a distinct color or badge on room cards and in task lists; tasks within a room are sorted by due date by default
|
|
||||||
6. Each room card shows its name, icon, count of due tasks, and cleanliness indicator
|
|
||||||
**Plans**: 5 plans
|
|
||||||
Plans:
|
Plans:
|
||||||
- [x] 02-01-PLAN.md — Data layer: Drift tables, migration v1->v2, DAOs, scheduling utility, domain models, templates, tests
|
- [ ] 06-01-PLAN.md — DAO query + history bottom sheet + TaskFormScreen integration + CalendarTaskRow navigation
|
||||||
- [x] 02-02-PLAN.md — Room CRUD UI: 2-column card grid, room form, icon picker, drag-and-drop reorder, providers
|
|
||||||
- [x] 02-03-PLAN.md — Task CRUD UI: task list, task row with completion, task form, overdue highlighting, providers
|
|
||||||
- [x] 02-04-PLAN.md — Template selection: template picker bottom sheet, room type detection, integration with room creation
|
|
||||||
- [x] 02-05-PLAN.md — Visual and functional verification checkpoint
|
|
||||||
|
|
||||||
### Phase 3: Daily Plan and Cleanliness
|
### Phase 7: Task Sorting
|
||||||
**Goal**: Users can open the app and immediately see what needs doing today, act on tasks directly from the plan view, and see a room-level health indicator
|
**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 2
|
**Depends on**: Phase 5
|
||||||
**Requirements**: PLAN-01, PLAN-02, PLAN-03, PLAN-04, PLAN-05, PLAN-06, CLEAN-01
|
**Requirements**: SORT-01, SORT-02, SORT-03
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. The Home tab shows today's tasks grouped by room, with a separate highlighted section at the top for overdue tasks
|
1. A sort control (dropdown, segmented button, or similar) is visible on task list screens and persists the chosen sort across app restarts
|
||||||
2. User can mark a task done directly from the daily plan view via swipe or checkbox without navigating to the room
|
2. Selecting alphabetical sort orders tasks A-Z by name within the visible list
|
||||||
3. User can see upcoming tasks (tomorrow and this week) from the daily plan screen
|
3. Selecting interval sort orders tasks from most-frequent (daily) to least-frequent (yearly/custom) intervals
|
||||||
4. A progress indicator shows completed vs total tasks for today (e.g., "5 von 12 erledigt")
|
4. Selecting effort sort orders tasks from lowest effort to highest effort level
|
||||||
5. When no tasks are due, an encouraging "all clear" empty state is shown instead of an empty list
|
**Plans:** 2/2 plans complete
|
||||||
6. Each room card displays a cleanliness indicator derived from the ratio of overdue tasks to total tasks in that room
|
|
||||||
**Plans**: 3 plans
|
|
||||||
Plans:
|
Plans:
|
||||||
- [x] 03-01-PLAN.md — Data layer: DailyPlanDao with cross-room join query, providers, and localization keys
|
- [ ] 07-01-PLAN.md — Sort model, persistence notifier, localization, provider integration
|
||||||
- [x] 03-02-PLAN.md — Daily plan UI: HomeScreen rewrite with progress card, task sections, animated completion, empty state
|
- [ ] 07-02-PLAN.md — Sort dropdown widget, HomeScreen AppBar, TaskListScreen integration, tests
|
||||||
- [x] 03-03-PLAN.md — Visual and functional verification checkpoint
|
|
||||||
|
|
||||||
### Phase 4: Notifications
|
|
||||||
**Goal**: Users receive a daily summary notification reminding them of today's task count, and can control notification behavior from settings
|
|
||||||
**Depends on**: Phase 2
|
|
||||||
**Requirements**: NOTF-01, NOTF-02
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
1. User receives one daily notification showing the count of tasks due today, scheduled at a configurable time
|
|
||||||
2. User can enable or disable notifications from the Settings tab, and the change takes effect immediately
|
|
||||||
3. Notifications are correctly rescheduled after device reboot (RECEIVE_BOOT_COMPLETED receiver active)
|
|
||||||
4. On Android API 33+, the app requests POST_NOTIFICATIONS permission at the appropriate moment and degrades gracefully if denied
|
|
||||||
**Plans**: 3 plans
|
|
||||||
Plans:
|
|
||||||
- [ ] 04-01-PLAN.md — Infrastructure: packages, Android config, NotificationService, NotificationSettingsNotifier, DAO queries, timezone init, tests
|
|
||||||
- [ ] 04-02-PLAN.md — Settings UI: Benachrichtigungen section with toggle, time picker, permission flow, scheduling wiring, tests
|
|
||||||
- [ ] 04-03-PLAN.md — Verification gate: dart analyze + full test suite + requirement confirmation
|
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
**Execution Order:**
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||||
Phases execute in numeric order: 1 -> 2 -> 3 -> 4
|
|-------|-----------|----------------|--------|-----------|
|
||||||
|
| 1. Foundation | v1.0 | 2/2 | Complete | 2026-03-15 |
|
||||||
Note: Phase 4 depends on Phase 2 (needs scheduling data) but can be developed in parallel with Phase 3.
|
| 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 |
|
||||||
| Phase | Plans Complete | Status | Completed |
|
| 4. Notifications | v1.0 | 3/3 | Complete | 2026-03-16 |
|
||||||
|-------|----------------|--------|-----------|
|
| 5. Calendar Strip | 2/2 | Complete | 2026-03-16 | - |
|
||||||
| 1. Foundation | 2/2 | Complete | 2026-03-15 |
|
| 6. Task History | 1/1 | Complete | 2026-03-16 | - |
|
||||||
| 2. Rooms and Tasks | 5/5 | Complete | 2026-03-15 |
|
| 7. Task Sorting | 2/2 | Complete | 2026-03-16 | - |
|
||||||
| 3. Daily Plan and Cleanliness | 3/3 | Complete | 2026-03-16 |
|
|
||||||
| 4. Notifications | 3/3 | Complete | 2026-03-16 |
|
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: milestone
|
milestone_name: milestone
|
||||||
status: executing
|
status: completed
|
||||||
stopped_at: Completed 04-03-PLAN.md (Phase 4 Verification Gate)
|
stopped_at: Completed 07-task-sorting/07-02-PLAN.md
|
||||||
last_updated: "2026-03-16T14:20:25.850Z"
|
last_updated: "2026-03-16T21:43:23.009Z"
|
||||||
last_activity: 2026-03-16 — Completed 04-01-PLAN.md (Notification infrastructure)
|
last_activity: 2026-03-16 — Completed Phase 6 Plan 01 (task completion history)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 3
|
||||||
completed_phases: 4
|
completed_phases: 3
|
||||||
total_plans: 13
|
total_plans: 5
|
||||||
completed_plans: 13
|
completed_plans: 5
|
||||||
percent: 100
|
percent: 100
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,113 +18,70 @@ progress:
|
|||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
|
|
||||||
See: .planning/PROJECT.md (updated 2026-03-15)
|
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.
|
**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:** Phase 4: Notifications (Phase 3 complete)
|
**Current focus:** v1.1 Calendar & Polish — Phase 6: Task History
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 4 of 4 (Notifications)
|
Phase: 6 — Task History
|
||||||
Plan: 1 of 2 in current phase -- COMPLETE
|
Plan: 1/1 complete (Phase 6 done)
|
||||||
Status: Phase 4 in progress — plan 1 complete, plan 2 (Settings UI) pending
|
Status: Phase Complete
|
||||||
Last activity: 2026-03-16 — Completed 04-01-PLAN.md (Notification infrastructure)
|
Last activity: 2026-03-16 — Completed Phase 6 Plan 01 (task completion history)
|
||||||
|
|
||||||
Progress: [██████████] 100%
|
```
|
||||||
|
Progress: [██████████] 100% (1/1 plans in Phase 6)
|
||||||
|
```
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
| Metric | v1.0 | v1.1 |
|
||||||
- Total plans completed: 10
|
|--------|------|------|
|
||||||
- Average duration: 6.1 min
|
| Phases | 4 | 3 planned |
|
||||||
- Total execution time: 1.0 hours
|
| Plans | 13 | TBD |
|
||||||
|
| LOC (lib) | 7,773 | TBD |
|
||||||
**By Phase:**
|
| Tests | 89 | TBD |
|
||||||
|
| Phase 05-calendar-strip P01 | 5 | 2 tasks | 10 files |
|
||||||
| Phase | Plans | Total | Avg/Plan |
|
| Phase 05-calendar-strip P02 | 8 | 3 tasks | 9 files |
|
||||||
|-------|-------|-------|----------|
|
| Phase 06-task-history P01 | 5 | 2 tasks | 9 files |
|
||||||
| 1 - Foundation | 2 | 15 min | 7.5 min |
|
| Phase 07-task-sorting P01 | 4 | 2 tasks | 9 files |
|
||||||
| 2 - Rooms and Tasks | 5 | 35 min | 7.0 min |
|
| Phase 07-task-sorting P02 | 4 | 2 tasks | 5 files |
|
||||||
| 3 - Daily Plan and Cleanliness | 3 | 11 min | 3.7 min |
|
|
||||||
|
|
||||||
**Recent Trend:**
|
|
||||||
- Last 5 plans: 02-04 (3 min), 02-05 (1 min), 03-01 (5 min), 03-02 (4 min), 03-03 (2 min)
|
|
||||||
- Trend: Verification gates consistently fast (1-2 min)
|
|
||||||
|
|
||||||
*Updated after each plan completion*
|
|
||||||
| Phase 02 P01 | 8 | 2 tasks | 16 files |
|
|
||||||
| Phase 02 P02 | 11 | 2 tasks | 14 files |
|
|
||||||
| Phase 02 P03 | 12 | 2 tasks | 8 files |
|
|
||||||
| Phase 02 P04 | 3 | 2 tasks | 5 files |
|
|
||||||
| Phase 02 P05 | 1 | 1 task | 0 files |
|
|
||||||
| Phase 03 P01 | 5 | 2 tasks | 10 files |
|
|
||||||
| Phase 03 P02 | 4 | 2 tasks | 5 files |
|
|
||||||
| Phase 03 P03 | 2 | 2 tasks | 0 files |
|
|
||||||
| Phase 04-notifications P01 | 9 | 2 tasks | 11 files |
|
|
||||||
| Phase 04-notifications P02 | 5 | 2 tasks | 5 files |
|
|
||||||
| Phase 04-notifications P03 | 2 | 1 tasks | 0 files |
|
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
### Decisions
|
### Decisions
|
||||||
|
|
||||||
Decisions are logged in PROJECT.md Key Decisions table.
|
| Decision | Rationale |
|
||||||
Recent decisions affecting current work:
|
|----------|-----------|
|
||||||
|
| Calendar strip replaces daily plan home screen | v1.1 goal per PROJECT.md — not additive, the stacked overdue/today/upcoming sections are removed |
|
||||||
- [Pre-phase]: Riverpod 3.3 requires Flutter 3.41+ — verify before scaffolding
|
| Phase 5 before Phase 6 and 7 | Calendar strip is the primary UI surface; history and sorting operate within or alongside it |
|
||||||
- [Pre-phase]: All due dates stored as date-only (calendar day), not DateTime — enforce from first migration
|
| Phase 6 and 7 both depend on Phase 5 only | History and sorting are independent of each other — could execute in either order |
|
||||||
- [Pre-phase]: German-only UI for MVP; localization infrastructure (ARB + AppLocalizations) required from Phase 1 even with one locale
|
| 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 |
|
||||||
- [Pre-phase]: riverpod_lint must be active before any feature code — catches ref.watch outside build() at analysis time
|
| Used NotifierProvider<SelectedDateNotifier> instead of deprecated StateProvider | Riverpod 3.x removed StateProvider; NotifierProvider is the correct replacement |
|
||||||
- [Pre-phase]: drift_dev make-migrations workflow must be established in Phase 1 — recovery cost is data loss
|
| calendarDayProvider fetches overdue tasks with .first in asyncMap when isToday | Consistent with dailyPlanProvider pattern; avoids combining two streams |
|
||||||
- [01-01]: Pinned drift/drift_dev to 2.31.0 for analyzer ^9.0.0 compatibility with riverpod_generator 4.0.3
|
| watchTasksForDate sorts alphabetically by task name | Same-day tasks have no meaningful time-based order; alpha sort is deterministic and user-friendly |
|
||||||
- [01-01]: Generated Riverpod 3 provider named themeProvider (not themeNotifierProvider) per new naming convention
|
| CalendarStripController as VoidCallback holder | Avoids GlobalKey for single imperative scroll-to-today action — simpler |
|
||||||
- [Phase 01-02]: Used themeProvider (Riverpod 3 naming) instead of themeNotifierProvider referenced in plan
|
| Tests use pump()+pump(Duration) instead of pumpAndSettle() | CalendarStrip animation controllers cause pumpAndSettle timeout — fixed-duration pump steps are reliable |
|
||||||
- [02-01]: Scheduling functions are top-level pure functions with DateTime today parameter for testability
|
| No separate Riverpod provider for history sheet | ref.read(appDatabaseProvider) directly in ConsumerWidget — one-shot modals do not need a dedicated provider |
|
||||||
- [02-01]: Calendar-anchored intervals use anchorDay nullable field for month-clamping memory
|
| CalendarTaskRow onTap navigates to task edit form | Makes history accessible in one tap from home screen, consistent with GoRouter route patterns |
|
||||||
- [02-01]: RoomWithStats computed via asyncMap on watchAllRooms stream, not a custom SQL join
|
- [Phase 07-task-sorting]: Default sort is alphabetical — continuity with existing A-Z SQL sort in CalendarDayList
|
||||||
- [02-01]: Templates stored as Dart const map for type safety, not JSON assets
|
- [Phase 07-task-sorting]: overdueTasks are NOT sorted — pinned at top in existing order per design decision
|
||||||
- [02-01]: detectRoomType uses contains-based matching with alias map
|
- [Phase 07-task-sorting]: Sort preference stored as enum.name string in SharedPreferences (not intEnum) — enum reordering safe
|
||||||
- [Phase 02]: Scheduling functions are top-level pure functions with DateTime today parameter for testability
|
- [Phase 07-task-sorting]: Used PopupMenuButton for SortDropdown in AppBar — menu overlay vs inline expansion, Material 3 pattern
|
||||||
- [Phase 02]: Calendar-anchored intervals use anchorDay nullable field for month-clamping memory
|
- [Phase 07-task-sorting]: HomeScreen uses nested Scaffold for AppBar — standard StatefulShellRoute.indexedStack per-tab AppBar pattern
|
||||||
- [Phase 02]: Templates stored as Dart const map for type safety, not JSON assets
|
|
||||||
- [02-02]: ReorderableBuilder<Widget> with typed onReorder callback for drag-and-drop grid
|
|
||||||
- [02-02]: Long-press context menu (bottom sheet) for edit/delete on room cards
|
|
||||||
- [02-02]: Provider override pattern in tests to decouple from database dependency
|
|
||||||
- [02-03]: tasksInRoomProvider defined as manual StreamProvider.family due to riverpod_generator InvalidTypeException with drift Task type
|
|
||||||
- [02-03]: Frequency selector uses ChoiceChip Wrap layout for 10 presets plus custom option
|
|
||||||
- [02-03]: TaskRow uses ListTile with middle-dot separator between relative date and frequency label
|
|
||||||
- [02-04]: Template picker uses StatefulWidget (not Consumer) receiving data via constructor
|
|
||||||
- [02-04]: Room creation navigates to /rooms/$roomId (context.go) instead of context.pop to show new room
|
|
||||||
- [02-04]: Calendar-anchored intervals set anchorDay to today's day-of-month; day-count intervals set null
|
|
||||||
- [02-05]: Auto-approved verification checkpoint: dart analyze clean, 59/59 tests passing, all Phase 2 code integrated
|
|
||||||
- [03-01]: DailyPlanDao uses innerJoin (not leftOuterJoin) since tasks always have a room
|
|
||||||
- [03-01]: watchCompletionsToday uses customSelect with readsFrom for proper stream invalidation
|
|
||||||
- [03-01]: dailyPlanProvider uses manual StreamProvider.autoDispose (not @riverpod) due to drift Task type issue
|
|
||||||
- [03-01]: Progress total = remaining overdue + remaining today + completedTodayCount for stable denominator
|
|
||||||
- [03-02]: Used stream-driven completion with local _completingTaskIds Set for animation instead of AnimatedList
|
|
||||||
- [03-02]: DailyPlanTaskRow is StatelessWidget (not ConsumerWidget) -- completion callback passed in from parent
|
|
||||||
- [03-02]: No-tasks empty state uses dailyPlanNoTasks key for clearer daily plan context messaging
|
|
||||||
- [03-03]: Phase 3 verification gate passed: dart analyze clean, 72/72 tests, all 7 requirements confirmed functional
|
|
||||||
- [Phase 04-01]: timezone constraint upgraded to ^0.11.0 — flutter_local_notifications v21 requires ^0.11.0, plan specified ^0.9.4
|
|
||||||
- [Phase 04-01]: flutter_local_notifications v21 uses named parameters in initialize() and zonedSchedule() — positional API removed in v20+
|
|
||||||
- [Phase 04-01]: Generated Riverpod 3 provider named notificationSettingsProvider (not notificationSettingsNotifierProvider) — consistent with themeProvider naming convention
|
|
||||||
- [Phase 04-01]: nextInstanceOf exposed as @visibleForTesting public method to enable TZ logic unit testing without native dispatch mocking
|
|
||||||
- [Phase Phase 04-02]: openNotificationSettings() not available in flutter_local_notifications v21 — simplified to informational SnackBar (no action button)
|
|
||||||
- [Phase Phase 04-02]: ConsumerStatefulWidget for SettingsScreen — async permission callbacks require mounted guards after every await
|
|
||||||
- [Phase 04-notifications]: Phase 4 verification gate passed: dart analyze --fatal-infos zero issues, 89/89 tests passing — all NOTF-01 and NOTF-02 requirements confirmed functional
|
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
None yet.
|
None.
|
||||||
|
|
||||||
### Blockers/Concerns
|
### Blockers/Concerns
|
||||||
|
|
||||||
- ~~[Research]: Recurrence policy edge cases not fully specified~~ — **RESOLVED** in 2-CONTEXT.md: calendar-anchored intervals clamp to last day with anchor memory, day-count intervals roll forward. Next due from original due date. Catch-up skips to next future date.
|
- 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.
|
||||||
- [Research]: Notification time configuration (user-adjustable vs hardcoded) not resolved. Decide before Phase 4 planning.
|
|
||||||
- ~~[Research]: First-launch template seeding UX (silent vs prompted) not resolved~~ — **RESOLVED** in 2-CONTEXT.md: post-creation prompt with all templates unchecked. Room type is optional, detected from name. Custom rooms skip templates entirely.
|
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-16T14:13:32.148Z
|
Last session: 2026-03-16T21:40:24.556Z
|
||||||
Stopped at: Completed 04-03-PLAN.md (Phase 4 Verification Gate)
|
Stopped at: Completed 07-task-sorting/07-02-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
Next action: Phase 7 (task sorting) or release
|
||||||
|
|||||||
169
.planning/milestones/v1.0-REQUIREMENTS.md
Normal file
169
.planning/milestones/v1.0-REQUIREMENTS.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Requirements Archive: v1.0 MVP
|
||||||
|
|
||||||
|
**Archived:** 2026-03-16
|
||||||
|
**Status:** SHIPPED
|
||||||
|
|
||||||
|
For current requirements, see `.planning/REQUIREMENTS.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements: HouseHoldKeaper
|
||||||
|
|
||||||
|
**Defined:** 2026-03-15
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## v1 Requirements
|
||||||
|
|
||||||
|
Requirements for initial release. Each maps to roadmap phases.
|
||||||
|
|
||||||
|
### Room Management
|
||||||
|
|
||||||
|
- [x] **ROOM-01**: User can create a room with a name and an icon from a curated Material Icons set
|
||||||
|
- [x] **ROOM-02**: User can edit a room's name and icon
|
||||||
|
- [x] **ROOM-03**: User can delete a room with confirmation (cascades to associated tasks)
|
||||||
|
- [x] **ROOM-04**: User can reorder rooms via drag-and-drop on the rooms screen
|
||||||
|
- [x] **ROOM-05**: User can view all rooms as cards showing name, icon, due task count, and cleanliness indicator
|
||||||
|
|
||||||
|
### Task Management
|
||||||
|
|
||||||
|
- [x] **TASK-01**: User can create a task within a room with name, optional description, frequency interval, and effort level
|
||||||
|
- [x] **TASK-02**: User can edit a task's name, description, frequency interval, and effort level
|
||||||
|
- [x] **TASK-03**: User can delete a task with confirmation
|
||||||
|
- [x] **TASK-04**: User can set frequency interval from: daily, every 2 days, every 3 days, weekly, biweekly, monthly, every 2 months, quarterly, every 6 months, yearly, or custom (every N days)
|
||||||
|
- [x] **TASK-05**: User can set effort level (low / medium / high) on a task
|
||||||
|
- [x] **TASK-06**: User can sort tasks within a room by due date (default sort order)
|
||||||
|
- [x] **TASK-07**: User can mark a task as done via tap or swipe, which records a completion timestamp and auto-calculates the next due date based on the interval
|
||||||
|
- [x] **TASK-08**: Overdue tasks are visually highlighted with distinct color/badge on room cards and in task lists
|
||||||
|
|
||||||
|
### Task Templates
|
||||||
|
|
||||||
|
- [x] **TMPL-01**: When creating a room, user can select from bundled German-language task templates appropriate for that room type
|
||||||
|
- [x] **TMPL-02**: Preset room types with templates include: Küche, Badezimmer, Schlafzimmer, Wohnzimmer, Flur, Büro, Garage, Balkon, Waschküche, Keller, Kinderzimmer, Gästezimmer, Esszimmer, Garten/Außenbereich
|
||||||
|
|
||||||
|
### Daily Plan
|
||||||
|
|
||||||
|
- [x] **PLAN-01**: User sees all tasks due today grouped by room on the daily plan screen (primary/default screen)
|
||||||
|
- [x] **PLAN-02**: Overdue tasks appear in a separate highlighted section at the top of the daily plan
|
||||||
|
- [x] **PLAN-03**: User can preview upcoming tasks (tomorrow / this week)
|
||||||
|
- [x] **PLAN-04**: User can swipe-to-complete or tap checkbox to mark tasks done directly from the daily plan view
|
||||||
|
- [x] **PLAN-05**: User sees a progress indicator showing completed vs total tasks for today (e.g. "5 of 12 tasks done")
|
||||||
|
- [x] **PLAN-06**: When no tasks are due, user sees an encouraging "all clear" empty state
|
||||||
|
|
||||||
|
### Cleanliness Indicator
|
||||||
|
|
||||||
|
- [x] **CLEAN-01**: Each room card displays a cleanliness indicator derived from the ratio of overdue tasks to total tasks in that room
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
- [x] **NOTF-01**: User receives a daily summary notification showing today's task count at a configurable time
|
||||||
|
- [x] **NOTF-02**: User can enable/disable notifications in settings
|
||||||
|
|
||||||
|
### Theme & UI
|
||||||
|
|
||||||
|
- [x] **THEME-01**: App supports light and dark themes, following the system setting by default
|
||||||
|
- [x] **THEME-02**: App uses a calm Material 3 palette with muted greens, warm grays, and gentle blues
|
||||||
|
|
||||||
|
### Foundation
|
||||||
|
|
||||||
|
- [x] **FOUND-01**: App uses Drift for local SQLite storage with proper schema migration workflow
|
||||||
|
- [x] **FOUND-02**: App uses Riverpod 3 for state management with code generation
|
||||||
|
- [x] **FOUND-03**: App uses localization infrastructure (ARB files + AppLocalizations) with German locale, even though only one language ships in v1
|
||||||
|
- [x] **FOUND-04**: Bottom navigation with tabs: Home (Daily Plan), Rooms, Settings
|
||||||
|
|
||||||
|
## v2 Requirements
|
||||||
|
|
||||||
|
Deferred to future release. Tracked but not in current roadmap.
|
||||||
|
|
||||||
|
### v1.1 — Near-Term
|
||||||
|
|
||||||
|
- **EXPORT-01**: User can export all data as JSON file
|
||||||
|
- **EXPORT-02**: User can import data from a JSON file
|
||||||
|
- **I18N-01**: App supports English as a second language
|
||||||
|
- **PHOTO-01**: User can add a cover photo to a room from camera or gallery
|
||||||
|
- **HIST-01**: User can view a completion history log per task (scrollable timeline of completion dates)
|
||||||
|
- **SORT-01**: User can sort tasks by alphabetical order, interval length, or effort level (in addition to due date)
|
||||||
|
|
||||||
|
### v1.2 — Medium-Term
|
||||||
|
|
||||||
|
- **PROJ-01**: User can create one-time organization projects with sub-task steps
|
||||||
|
- **PROJ-02**: User can attach before/after photos to a project
|
||||||
|
- **PROF-01**: User can create named local profiles for household members
|
||||||
|
- **PROF-02**: User can assign tasks to one or more profiles
|
||||||
|
- **PROF-03**: User can enable task rotation (round-robin) for shared recurring tasks
|
||||||
|
- **PROF-04**: User can filter the daily plan view by profile ("My tasks" vs "All tasks")
|
||||||
|
- **WIDG-01**: Home screen widget showing today's due tasks and overdue count
|
||||||
|
- **CAL-01**: User can view a weekly overview with task load per day
|
||||||
|
- **CAL-02**: User can view a monthly calendar heatmap showing task density
|
||||||
|
- **VAC-01**: User can pause/freeze all task due dates during vacation and resume on return
|
||||||
|
|
||||||
|
### v2.0 — Future
|
||||||
|
|
||||||
|
- **STAT-01**: User can view completion rate (% on time this week/month)
|
||||||
|
- **STAT-02**: User can view streak of consecutive days with all tasks completed
|
||||||
|
- **STAT-03**: User can view per-room health scores over time
|
||||||
|
- **ONBRD-01**: First-launch wizard walks user through creating first room and adding tasks
|
||||||
|
- **COLOR-01**: User can pick a custom accent color for the app theme
|
||||||
|
- **SYNC-01**: User can optionally sync data via self-hosted infrastructure
|
||||||
|
- **TABLET-01**: App provides a tablet-optimized layout with adaptive breakpoints
|
||||||
|
- **NOTF-03**: Optional evening nudge notification if overdue tasks remain
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
Explicitly excluded. Documented to prevent scope creep.
|
||||||
|
|
||||||
|
| Feature | Reason |
|
||||||
|
|---------|--------|
|
||||||
|
| User accounts & cloud sync | Local-only by design — zero backend, zero data leaves device |
|
||||||
|
| Leaderboards & points ranking | Anti-feature — gamification causes app burnout and embeds unequal labor dynamics |
|
||||||
|
| Subscription model / in-app purchases | Free forever by design — paywalls are the #2 complaint in chore app reviews |
|
||||||
|
| Family profile sharing across devices | Single-device app; cross-device requires cloud infrastructure |
|
||||||
|
| AI-powered task suggestions | Requires network/ML; overkill for personal app with curated templates |
|
||||||
|
| Per-task push notifications | Causes notification fatigue; daily summary is more effective for habit formation |
|
||||||
|
| Focus timer / Pomodoro | Not a productivity timer app; out of domain |
|
||||||
|
| Firebase or any Google cloud services | Contradicts local-first, privacy-first design |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
Which phases cover which requirements. Updated during roadmap creation.
|
||||||
|
|
||||||
|
| Requirement | Phase | Status |
|
||||||
|
|-------------|-------|--------|
|
||||||
|
| FOUND-01 | Phase 1: Foundation | Complete |
|
||||||
|
| FOUND-02 | Phase 1: Foundation | Complete |
|
||||||
|
| FOUND-03 | Phase 1: Foundation | Complete |
|
||||||
|
| FOUND-04 | Phase 1: Foundation | Complete |
|
||||||
|
| THEME-01 | Phase 1: Foundation | Complete |
|
||||||
|
| THEME-02 | Phase 1: Foundation | Complete |
|
||||||
|
| ROOM-01 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| ROOM-02 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| ROOM-03 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| ROOM-04 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| ROOM-05 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TASK-01 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TASK-02 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TASK-03 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TASK-04 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TASK-05 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TASK-06 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TASK-07 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TASK-08 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TMPL-01 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| TMPL-02 | Phase 2: Rooms and Tasks | Complete |
|
||||||
|
| PLAN-01 | Phase 3: Daily Plan and Cleanliness | Complete |
|
||||||
|
| PLAN-02 | Phase 3: Daily Plan and Cleanliness | Complete |
|
||||||
|
| PLAN-03 | Phase 3: Daily Plan and Cleanliness | Complete |
|
||||||
|
| PLAN-04 | Phase 3: Daily Plan and Cleanliness | Complete |
|
||||||
|
| PLAN-05 | Phase 3: Daily Plan and Cleanliness | Complete |
|
||||||
|
| PLAN-06 | Phase 3: Daily Plan and Cleanliness | Complete |
|
||||||
|
| CLEAN-01 | Phase 3: Daily Plan and Cleanliness | Complete |
|
||||||
|
| NOTF-01 | Phase 4: Notifications | Complete |
|
||||||
|
| NOTF-02 | Phase 4: Notifications | Complete |
|
||||||
|
|
||||||
|
**Coverage:**
|
||||||
|
- v1 requirements: 30 total
|
||||||
|
- Mapped to phases: 30
|
||||||
|
- Unmapped: 0
|
||||||
|
|
||||||
|
---
|
||||||
|
*Requirements defined: 2026-03-15*
|
||||||
|
*Last updated: 2026-03-15 after roadmap creation*
|
||||||
100
.planning/milestones/v1.0-ROADMAP.md
Normal file
100
.planning/milestones/v1.0-ROADMAP.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Roadmap: HouseHoldKeaper
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Four phases build the app bottom-up along its natural dependency chain. Phase 1 lays the technical foundation every subsequent phase relies on. Phase 2 delivers complete room and task management — the core scheduling loop. Phase 3 surfaces that data as the daily plan view (the primary user experience) and adds the cleanliness indicator. Phase 4 adds notifications and completes the v1 feature set. After Phase 3, the app is usable daily. After Phase 4, it is releasable.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
**Phase Numbering:**
|
||||||
|
- Integer phases (1, 2, 3): Planned milestone work
|
||||||
|
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
||||||
|
|
||||||
|
Decimal phases appear between their surrounding integers in numeric order.
|
||||||
|
|
||||||
|
- [x] **Phase 1: Foundation** - Project scaffold, database, state management, theme, and localization infrastructure (completed 2026-03-15)
|
||||||
|
- [x] **Phase 2: Rooms and Tasks** - Complete room CRUD, task CRUD with auto-scheduling, and bundled templates (completed 2026-03-15)
|
||||||
|
- [x] **Phase 3: Daily Plan and Cleanliness** - Primary daily plan screen with overdue/today/upcoming, cleanliness indicators per room (completed 2026-03-16)
|
||||||
|
- [x] **Phase 4: Notifications** - Daily summary notification with configurable time and Android permission handling (completed 2026-03-16)
|
||||||
|
|
||||||
|
## Phase Details
|
||||||
|
|
||||||
|
### Phase 1: Foundation
|
||||||
|
**Goal**: The app compiles, opens, and enforces correct architecture patterns — ready to receive features without accumulating technical debt
|
||||||
|
**Depends on**: Nothing (first phase)
|
||||||
|
**Requirements**: FOUND-01, FOUND-02, FOUND-03, FOUND-04, THEME-01, THEME-02
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. App launches on Android without errors and shows a bottom navigation bar with Home, Rooms, and Settings tabs
|
||||||
|
2. Light and dark themes work correctly and follow the system setting by default, using the calm Material 3 palette (muted greens, warm grays, gentle blues)
|
||||||
|
3. All UI strings are loaded from ARB localization files — no hardcoded German text in Dart code
|
||||||
|
4. The Drift database opens on first launch with schemaVersion 1 and the migration workflow is established (drift_dev make-migrations runs without errors)
|
||||||
|
5. riverpod_lint is active and flags ref.watch usage outside build() as an analysis error
|
||||||
|
**Plans**: 2 plans
|
||||||
|
Plans:
|
||||||
|
- [x] 01-01-PLAN.md — Scaffold Flutter project and build core infrastructure (database, providers, theme, localization)
|
||||||
|
- [x] 01-02-PLAN.md — Navigation shell, placeholder screens, Settings, and full app wiring
|
||||||
|
|
||||||
|
### Phase 2: Rooms and Tasks
|
||||||
|
**Goal**: Users can create and manage rooms and tasks, mark tasks done, and trust the app to schedule the next occurrence automatically
|
||||||
|
**Depends on**: Phase 1
|
||||||
|
**Requirements**: ROOM-01, ROOM-02, ROOM-03, ROOM-04, ROOM-05, TASK-01, TASK-02, TASK-03, TASK-04, TASK-05, TASK-06, TASK-07, TASK-08, TMPL-01, TMPL-02
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. User can create a room with a name and icon, edit it, reorder rooms via drag-and-drop, and delete it (with confirmation that removes all associated tasks)
|
||||||
|
2. User can create a task in a room with name, description, frequency interval (daily through yearly and custom), and effort level; tasks can be edited and deleted with confirmation
|
||||||
|
3. When creating a room, user can select from bundled German-language task templates for the chosen room type (all 14 room types covered) and they are added to the room as tasks
|
||||||
|
4. User can mark a task done (tap or swipe), which records the completion and sets the next due date correctly based on the interval
|
||||||
|
5. Overdue tasks are visually highlighted with a distinct color or badge on room cards and in task lists; tasks within a room are sorted by due date by default
|
||||||
|
6. Each room card shows its name, icon, count of due tasks, and cleanliness indicator
|
||||||
|
**Plans**: 5 plans
|
||||||
|
Plans:
|
||||||
|
- [x] 02-01-PLAN.md — Data layer: Drift tables, migration v1->v2, DAOs, scheduling utility, domain models, templates, tests
|
||||||
|
- [x] 02-02-PLAN.md — Room CRUD UI: 2-column card grid, room form, icon picker, drag-and-drop reorder, providers
|
||||||
|
- [x] 02-03-PLAN.md — Task CRUD UI: task list, task row with completion, task form, overdue highlighting, providers
|
||||||
|
- [x] 02-04-PLAN.md — Template selection: template picker bottom sheet, room type detection, integration with room creation
|
||||||
|
- [x] 02-05-PLAN.md — Visual and functional verification checkpoint
|
||||||
|
|
||||||
|
### Phase 3: Daily Plan and Cleanliness
|
||||||
|
**Goal**: Users can open the app and immediately see what needs doing today, act on tasks directly from the plan view, and see a room-level health indicator
|
||||||
|
**Depends on**: Phase 2
|
||||||
|
**Requirements**: PLAN-01, PLAN-02, PLAN-03, PLAN-04, PLAN-05, PLAN-06, CLEAN-01
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. The Home tab shows today's tasks grouped by room, with a separate highlighted section at the top for overdue tasks
|
||||||
|
2. User can mark a task done directly from the daily plan view via swipe or checkbox without navigating to the room
|
||||||
|
3. User can see upcoming tasks (tomorrow and this week) from the daily plan screen
|
||||||
|
4. A progress indicator shows completed vs total tasks for today (e.g., "5 von 12 erledigt")
|
||||||
|
5. When no tasks are due, an encouraging "all clear" empty state is shown instead of an empty list
|
||||||
|
6. Each room card displays a cleanliness indicator derived from the ratio of overdue tasks to total tasks in that room
|
||||||
|
**Plans**: 3 plans
|
||||||
|
Plans:
|
||||||
|
- [x] 03-01-PLAN.md — Data layer: DailyPlanDao with cross-room join query, providers, and localization keys
|
||||||
|
- [x] 03-02-PLAN.md — Daily plan UI: HomeScreen rewrite with progress card, task sections, animated completion, empty state
|
||||||
|
- [x] 03-03-PLAN.md — Visual and functional verification checkpoint
|
||||||
|
|
||||||
|
### Phase 4: Notifications
|
||||||
|
**Goal**: Users receive a daily summary notification reminding them of today's task count, and can control notification behavior from settings
|
||||||
|
**Depends on**: Phase 2
|
||||||
|
**Requirements**: NOTF-01, NOTF-02
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. User receives one daily notification showing the count of tasks due today, scheduled at a configurable time
|
||||||
|
2. User can enable or disable notifications from the Settings tab, and the change takes effect immediately
|
||||||
|
3. Notifications are correctly rescheduled after device reboot (RECEIVE_BOOT_COMPLETED receiver active)
|
||||||
|
4. On Android API 33+, the app requests POST_NOTIFICATIONS permission at the appropriate moment and degrades gracefully if denied
|
||||||
|
**Plans**: 3 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 04-01-PLAN.md — Infrastructure: packages, Android config, NotificationService, NotificationSettingsNotifier, DAO queries, timezone init, tests
|
||||||
|
- [ ] 04-02-PLAN.md — Settings UI: Benachrichtigungen section with toggle, time picker, permission flow, scheduling wiring, tests
|
||||||
|
- [ ] 04-03-PLAN.md — Verification gate: dart analyze + full test suite + requirement confirmation
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
**Execution Order:**
|
||||||
|
Phases execute in numeric order: 1 -> 2 -> 3 -> 4
|
||||||
|
|
||||||
|
Note: Phase 4 depends on Phase 2 (needs scheduling data) but can be developed in parallel with Phase 3.
|
||||||
|
|
||||||
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|
|-------|----------------|--------|-----------|
|
||||||
|
| 1. Foundation | 2/2 | Complete | 2026-03-15 |
|
||||||
|
| 2. Rooms and Tasks | 5/5 | Complete | 2026-03-15 |
|
||||||
|
| 3. Daily Plan and Cleanliness | 3/3 | Complete | 2026-03-16 |
|
||||||
|
| 4. Notifications | 3/3 | Complete | 2026-03-16 |
|
||||||
262
.planning/phases/05-calendar-strip/05-01-PLAN.md
Normal file
262
.planning/phases/05-calendar-strip/05-01-PLAN.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
---
|
||||||
|
phase: 05-calendar-strip
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- lib/features/home/data/calendar_dao.dart
|
||||||
|
- lib/features/home/data/calendar_dao.g.dart
|
||||||
|
- lib/features/home/domain/calendar_models.dart
|
||||||
|
- lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- lib/core/database/database.dart
|
||||||
|
- lib/core/database/database.g.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- test/features/home/data/calendar_dao_test.dart
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- CAL-02
|
||||||
|
- CAL-05
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Querying tasks for any arbitrary date returns exactly the tasks whose nextDueDate falls on that day"
|
||||||
|
- "Querying overdue tasks for today returns all tasks whose nextDueDate is strictly before today"
|
||||||
|
- "Querying a future date returns only tasks due that day, no overdue carry-over"
|
||||||
|
- "CalendarState model holds selectedDate, overdue tasks, and day tasks as separate lists"
|
||||||
|
- "Localization strings for calendar UI exist in ARB and generated files"
|
||||||
|
artifacts:
|
||||||
|
- path: "lib/features/home/data/calendar_dao.dart"
|
||||||
|
provides: "Date-parameterized task queries"
|
||||||
|
exports: ["CalendarDao"]
|
||||||
|
- path: "lib/features/home/domain/calendar_models.dart"
|
||||||
|
provides: "CalendarState and reuse of TaskWithRoom"
|
||||||
|
exports: ["CalendarState"]
|
||||||
|
- path: "lib/features/home/presentation/calendar_providers.dart"
|
||||||
|
provides: "Riverpod provider for calendar state"
|
||||||
|
exports: ["calendarProvider", "selectedDateProvider"]
|
||||||
|
- path: "test/features/home/data/calendar_dao_test.dart"
|
||||||
|
provides: "DAO unit tests"
|
||||||
|
min_lines: 50
|
||||||
|
key_links:
|
||||||
|
- from: "lib/features/home/data/calendar_dao.dart"
|
||||||
|
to: "lib/core/database/database.dart"
|
||||||
|
via: "DAO registered in @DriftDatabase annotation"
|
||||||
|
pattern: "CalendarDao"
|
||||||
|
- from: "lib/features/home/presentation/calendar_providers.dart"
|
||||||
|
to: "lib/features/home/data/calendar_dao.dart"
|
||||||
|
via: "Provider reads CalendarDao from AppDatabase"
|
||||||
|
pattern: "db\\.calendarDao"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the data layer, domain models, Riverpod providers, and localization strings for the calendar strip feature.
|
||||||
|
|
||||||
|
Purpose: The calendar strip UI (Plan 02) needs a data foundation that can answer "what tasks are due on date X?" and "what tasks are overdue relative to today?" without the old overdue/today/tomorrow bucketing. This plan builds that foundation and tests it.
|
||||||
|
|
||||||
|
Output: CalendarDao with date-parameterized queries, CalendarState model, Riverpod providers (selectedDateProvider + calendarProvider), new l10n strings, DAO unit tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/05-calendar-strip/5-CONTEXT.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
From lib/core/database/database.dart:
|
||||||
|
```dart
|
||||||
|
// Tables: Rooms, Tasks, TaskCompletions
|
||||||
|
// Existing DAOs: RoomsDao, TasksDao, DailyPlanDao
|
||||||
|
// CalendarDao must be added to the @DriftDatabase annotation daos list
|
||||||
|
// and imported at the top of database.dart
|
||||||
|
|
||||||
|
@DriftDatabase(
|
||||||
|
tables: [Rooms, Tasks, TaskCompletions],
|
||||||
|
daos: [RoomsDao, TasksDao, DailyPlanDao], // ADD CalendarDao here
|
||||||
|
)
|
||||||
|
class AppDatabase extends _$AppDatabase { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/domain/daily_plan_models.dart:
|
||||||
|
```dart
|
||||||
|
class TaskWithRoom {
|
||||||
|
final Task task;
|
||||||
|
final String roomName;
|
||||||
|
final int roomId;
|
||||||
|
const TaskWithRoom({required this.task, required this.roomName, required this.roomId});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/presentation/daily_plan_providers.dart:
|
||||||
|
```dart
|
||||||
|
// Pattern to follow: StreamProvider.autoDispose, manual (not @riverpod)
|
||||||
|
// because of drift's generated Task type
|
||||||
|
final dailyPlanProvider = StreamProvider.autoDispose<DailyPlanState>((ref) {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/data/daily_plan_dao.dart:
|
||||||
|
```dart
|
||||||
|
// Pattern: @DriftAccessor with tables, extends DatabaseAccessor<AppDatabase>
|
||||||
|
// Uses query.watch() for reactive streams
|
||||||
|
@DriftAccessor(tables: [Tasks, Rooms, TaskCompletions])
|
||||||
|
class DailyPlanDao extends DatabaseAccessor<AppDatabase> with _$DailyPlanDaoMixin { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/core/providers/database_provider.dart:
|
||||||
|
```dart
|
||||||
|
// appDatabaseProvider gives access to the database singleton
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create CalendarDao with date-parameterized queries and tests</name>
|
||||||
|
<files>
|
||||||
|
lib/features/home/data/calendar_dao.dart,
|
||||||
|
lib/core/database/database.dart,
|
||||||
|
test/features/home/data/calendar_dao_test.dart
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- watchTasksForDate(date): returns tasks whose nextDueDate falls on the given calendar day (same year/month/day), joined with room name, sorted by task name alphabetically
|
||||||
|
- watchOverdueTasks(referenceDate): returns tasks whose nextDueDate is strictly before referenceDate (start of day), joined with room name, sorted by nextDueDate ascending
|
||||||
|
- watchTasksForDate for a date with no tasks returns empty list
|
||||||
|
- watchOverdueTasks returns empty when no tasks are overdue
|
||||||
|
- watchOverdueTasks does NOT include tasks due on the referenceDate itself
|
||||||
|
- watchTasksForDate for a past date returns only tasks originally due that day (does NOT include overdue carry-over)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `lib/features/home/data/calendar_dao.dart`:
|
||||||
|
- Class `CalendarDao` extends `DatabaseAccessor<AppDatabase>` with `_$CalendarDaoMixin`
|
||||||
|
- Annotated `@DriftAccessor(tables: [Tasks, Rooms, TaskCompletions])`
|
||||||
|
- `part 'calendar_dao.g.dart';`
|
||||||
|
- Method `Stream<List<TaskWithRoom>> watchTasksForDate(DateTime date)`:
|
||||||
|
Compute startOfDay and endOfDay (startOfDay + 1 day). Join tasks with rooms. Filter `tasks.nextDueDate >= startOfDay AND tasks.nextDueDate < endOfDay`. Order by `tasks.name` ascending. Map to `TaskWithRoom`.
|
||||||
|
- Method `Stream<List<TaskWithRoom>> watchOverdueTasks(DateTime referenceDate)`:
|
||||||
|
Compute startOfReferenceDay. Join tasks with rooms. Filter `tasks.nextDueDate < startOfReferenceDay`. Order by `tasks.nextDueDate` ascending. Map to `TaskWithRoom`.
|
||||||
|
- Import `daily_plan_models.dart` for `TaskWithRoom` (reuse, don't duplicate).
|
||||||
|
|
||||||
|
2. Register CalendarDao in `lib/core/database/database.dart`:
|
||||||
|
- Add import: `import '../../features/home/data/calendar_dao.dart';`
|
||||||
|
- Add `CalendarDao` to the `daos:` list in `@DriftDatabase`
|
||||||
|
|
||||||
|
3. Run `dart run build_runner build --delete-conflicting-outputs` to generate `calendar_dao.g.dart` and updated `database.g.dart`.
|
||||||
|
|
||||||
|
4. Write `test/features/home/data/calendar_dao_test.dart` following the pattern in `test/features/home/data/daily_plan_dao_test.dart`:
|
||||||
|
- Use in-memory database: `AppDatabase(NativeDatabase.memory())`
|
||||||
|
- Create test rooms in setUp
|
||||||
|
- Test group for watchTasksForDate:
|
||||||
|
- Empty when no tasks
|
||||||
|
- Returns only tasks due on the queried date (not before, not after)
|
||||||
|
- Returns tasks from multiple rooms
|
||||||
|
- Sorted alphabetically by name
|
||||||
|
- Test group for watchOverdueTasks:
|
||||||
|
- Empty when no overdue tasks
|
||||||
|
- Returns tasks due before reference date
|
||||||
|
- Does NOT include tasks due ON the reference date
|
||||||
|
- Sorted by nextDueDate ascending
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test test/features/home/data/calendar_dao_test.dart</automated>
|
||||||
|
</verify>
|
||||||
|
<done>CalendarDao registered in AppDatabase, both query methods return correct results for arbitrary dates, all DAO tests pass</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create CalendarState model, Riverpod providers, and localization strings</name>
|
||||||
|
<files>
|
||||||
|
lib/features/home/domain/calendar_models.dart,
|
||||||
|
lib/features/home/presentation/calendar_providers.dart,
|
||||||
|
lib/l10n/app_de.arb
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Create `lib/features/home/domain/calendar_models.dart`:
|
||||||
|
```dart
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
|
||||||
|
/// State for the calendar day view: tasks for the selected date + overdue tasks.
|
||||||
|
class CalendarDayState {
|
||||||
|
final DateTime selectedDate;
|
||||||
|
final List<TaskWithRoom> dayTasks;
|
||||||
|
final List<TaskWithRoom> overdueTasks;
|
||||||
|
|
||||||
|
const CalendarDayState({
|
||||||
|
required this.selectedDate,
|
||||||
|
required this.dayTasks,
|
||||||
|
required this.overdueTasks,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True when viewing today and all tasks (day + overdue) have been completed
|
||||||
|
/// (lists are empty but completions exist). Determined by the UI layer.
|
||||||
|
bool get isEmpty => dayTasks.isEmpty && overdueTasks.isEmpty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `lib/features/home/presentation/calendar_providers.dart`:
|
||||||
|
- Import Riverpod, database_provider, calendar_dao, calendar_models, daily_plan_models
|
||||||
|
- `final selectedDateProvider = StateProvider<DateTime>((ref) { final now = DateTime.now(); return DateTime(now.year, now.month, now.day); });`
|
||||||
|
This is NOT autoDispose -- the selected date persists as long as the app is alive (resets on restart naturally).
|
||||||
|
- `final calendarDayProvider = StreamProvider.autoDispose<CalendarDayState>((ref) { ... });`
|
||||||
|
Manual definition (not @riverpod) following dailyPlanProvider pattern.
|
||||||
|
Reads `selectedDateProvider` to get the current date.
|
||||||
|
Reads `appDatabaseProvider` to get the DB.
|
||||||
|
Determines if selectedDate is today: `isToday = selectedDate == DateTime(now.year, now.month, now.day)`.
|
||||||
|
Determines if selectedDate is in the future: `isFuture = selectedDate.isAfter(today)`.
|
||||||
|
Watches `db.calendarDao.watchTasksForDate(selectedDate)`.
|
||||||
|
For overdue: if `isToday`, also watch `db.calendarDao.watchOverdueTasks(selectedDate)`.
|
||||||
|
If viewing a past date or future date, overdueTasks = empty.
|
||||||
|
Per user decision: "When viewing past days: show what was due that day. When viewing future days: show only tasks due that day, no overdue carry-over."
|
||||||
|
Combine both streams using `Rx.combineLatest2` or simply use `asyncMap` on the day tasks stream and fetch overdue as a secondary query.
|
||||||
|
|
||||||
|
Implementation approach: Use the dayTasks stream as the primary, and inside asyncMap call the overdue stream's `.first` when isToday. This keeps it simple and follows the existing `dailyPlanProvider` pattern of `stream.asyncMap()`.
|
||||||
|
|
||||||
|
3. Add new l10n strings to `lib/l10n/app_de.arb` (add before the closing `}`):
|
||||||
|
- `"calendarNoTasks": "Keine Aufgaben"` — shown when a day has no tasks at all
|
||||||
|
- `"calendarAllDone": "Alles erledigt!"` — celebration when all tasks for a day are done
|
||||||
|
- `"calendarOverdueSection": "Uberfaellig"` — No, reuse existing `dailyPlanSectionOverdue` ("Uberfaellig") for the overdue section header
|
||||||
|
- `"calendarTodayButton": "Heute"` — floating today button label
|
||||||
|
|
||||||
|
Actually, we can reuse `dailyPlanSectionOverdue` for the overdue header, `dailyPlanNoTasks` for no-tasks-at-all, and `dailyPlanAllClearTitle`/`dailyPlanAllClearMessage` for celebration. The only truly new string needed is for the Today button:
|
||||||
|
- Add `"calendarTodayButton": "Heute"` to the ARB file
|
||||||
|
|
||||||
|
4. Run `flutter gen-l10n` to regenerate localization files.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter analyze --no-fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>CalendarDayState model exists with selectedDate/dayTasks/overdueTasks fields. selectedDateProvider and calendarDayProvider are defined. calendarDayProvider returns overdue tasks only when viewing today. New l10n string "calendarTodayButton" exists. No analysis errors.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `flutter test test/features/home/data/calendar_dao_test.dart` — all DAO tests pass
|
||||||
|
- `flutter analyze --no-fatal-infos` — no errors in new or modified files
|
||||||
|
- `flutter test` — full test suite still passes (existing tests not broken by database.dart changes)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- CalendarDao is registered in AppDatabase and has two working query methods
|
||||||
|
- CalendarDayState model correctly separates day tasks from overdue tasks
|
||||||
|
- calendarDayProvider returns overdue only for today, not for past/future dates
|
||||||
|
- All existing tests still pass after database.dart modification
|
||||||
|
- New DAO tests cover core query behaviors
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/05-calendar-strip/05-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
142
.planning/phases/05-calendar-strip/05-01-SUMMARY.md
Normal file
142
.planning/phases/05-calendar-strip/05-01-SUMMARY.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
phase: 05-calendar-strip
|
||||||
|
plan: 01
|
||||||
|
subsystem: database
|
||||||
|
tags: [drift, riverpod, dart, flutter, localization, tdd]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- CalendarDao with watchTasksForDate and watchOverdueTasks date-parameterized queries
|
||||||
|
- CalendarDayState domain model with selectedDate/dayTasks/overdueTasks
|
||||||
|
- selectedDateProvider (NotifierProvider, persists while app is alive)
|
||||||
|
- calendarDayProvider (StreamProvider.autoDispose, overdue only for today)
|
||||||
|
- calendarTodayButton l10n string in ARB and generated dart files
|
||||||
|
- 11 DAO unit tests covering all query behaviors
|
||||||
|
affects:
|
||||||
|
- 05-calendar-strip plan 02 (calendar strip UI uses these providers and state model)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "CalendarDao follows @DriftAccessor pattern with DatabaseAccessor<AppDatabase>"
|
||||||
|
- "Manual NotifierProvider<SelectedDateNotifier, DateTime> instead of @riverpod (Riverpod 3.x pattern)"
|
||||||
|
- "StreamProvider.autoDispose with asyncMap for combining day + overdue streams"
|
||||||
|
- "TDD: failing test commit, then implementation commit"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- lib/features/home/data/calendar_dao.dart
|
||||||
|
- lib/features/home/data/calendar_dao.g.dart
|
||||||
|
- lib/features/home/domain/calendar_models.dart
|
||||||
|
- lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- test/features/home/data/calendar_dao_test.dart
|
||||||
|
modified:
|
||||||
|
- lib/core/database/database.dart
|
||||||
|
- lib/core/database/database.g.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Used NotifierProvider<SelectedDateNotifier, DateTime> instead of deprecated StateProvider — Riverpod 3.x removed StateProvider in favour of Notifier-based providers"
|
||||||
|
- "calendarDayProvider fetches overdue tasks with .first when isToday, keeping asyncMap pattern consistent with dailyPlanProvider"
|
||||||
|
- "watchTasksForDate sorts alphabetically by name (not by due time) — arbitrary due time on same day has no meaningful sort order"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "CalendarDao: @DriftAccessor with join + where filter + orderBy, mapped to TaskWithRoom — same shape as DailyPlanDao"
|
||||||
|
- "Manual Notifier subclass for simple value-holding state provider (not @riverpod) to avoid code gen constraints"
|
||||||
|
|
||||||
|
requirements-completed: [CAL-02, CAL-05]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 5min
|
||||||
|
completed: 2026-03-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 5 Plan 01: Calendar Data Layer Summary
|
||||||
|
|
||||||
|
**CalendarDao with date-exact and overdue-before-date Drift queries, CalendarDayState model, Riverpod providers for selected date and day state, and "Heute" l10n string — full data foundation for the calendar strip UI**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 5 min
|
||||||
|
- **Started:** 2026-03-16T20:18:55Z
|
||||||
|
- **Completed:** 2026-03-16T20:24:12Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 10
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- CalendarDao registered in AppDatabase with two reactive Drift streams: `watchTasksForDate` (exact day, sorted by name) and `watchOverdueTasks` (strictly before reference date, sorted by due date)
|
||||||
|
- CalendarDayState domain model separating dayTasks and overdueTasks with isEmpty helper
|
||||||
|
- selectedDateProvider (NotifierProvider, keeps alive) + calendarDayProvider (StreamProvider.autoDispose) following existing Riverpod patterns
|
||||||
|
- 11 unit tests passing via TDD red-green cycle; full 100-test suite passes with no regressions
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: RED - CalendarDao tests** - `f5c4b49` (test)
|
||||||
|
2. **Task 1: GREEN - CalendarDao implementation** - `c666f9a` (feat)
|
||||||
|
3. **Task 2: CalendarDayState, providers, l10n** - `68ba7c6` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `lib/features/home/data/calendar_dao.dart` - CalendarDao with watchTasksForDate and watchOverdueTasks
|
||||||
|
- `lib/features/home/data/calendar_dao.g.dart` - Generated Drift mixin for CalendarDao
|
||||||
|
- `lib/features/home/domain/calendar_models.dart` - CalendarDayState model
|
||||||
|
- `lib/features/home/presentation/calendar_providers.dart` - selectedDateProvider and calendarDayProvider
|
||||||
|
- `test/features/home/data/calendar_dao_test.dart` - 11 DAO unit tests (TDD RED phase)
|
||||||
|
- `lib/core/database/database.dart` - Added CalendarDao import and registration in @DriftDatabase
|
||||||
|
- `lib/core/database/database.g.dart` - Regenerated with CalendarDao accessor
|
||||||
|
- `lib/l10n/app_de.arb` - Added calendarTodayButton: "Heute"
|
||||||
|
- `lib/l10n/app_localizations.dart` - Regenerated with calendarTodayButton getter
|
||||||
|
- `lib/l10n/app_localizations_de.dart` - Regenerated with calendarTodayButton implementation
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- **NotifierProvider instead of StateProvider:** Riverpod 3.x dropped `StateProvider` — replaced with `NotifierProvider<SelectedDateNotifier, DateTime>` pattern (manual, not @riverpod) to keep consistent with the codebase's non-generated providers.
|
||||||
|
- **Overdue fetched with .first inside asyncMap:** When isToday, the overdue tasks stream's first emission is awaited inside asyncMap on the day tasks stream. This avoids combining two streams and stays consistent with the `dailyPlanProvider` pattern.
|
||||||
|
- **watchTasksForDate sorts alphabetically by name:** Tasks due on the same calendar day have no meaningful relative order by time. Alphabetical name sort gives deterministic, user-friendly ordering.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] StateProvider unavailable in Riverpod 3.x**
|
||||||
|
- **Found during:** Task 2 (calendar providers)
|
||||||
|
- **Issue:** Plan specified `StateProvider<DateTime>` but flutter_riverpod 3.3.1 removed StateProvider; analyzer reported `undefined_function`
|
||||||
|
- **Fix:** Replaced with `NotifierProvider<SelectedDateNotifier, DateTime>` using a minimal `Notifier` subclass with a `selectDate(DateTime)` method
|
||||||
|
- **Files modified:** lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- **Verification:** `flutter analyze --no-fatal-infos` reports no issues
|
||||||
|
- **Committed in:** 68ba7c6 (Task 2 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (Rule 1 - Bug)
|
||||||
|
**Impact on plan:** Fix was required for compilation. The API surface is equivalent — consumers call `ref.watch(selectedDateProvider)` to read the date and `ref.read(selectedDateProvider.notifier).selectDate(date)` to update it. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- None beyond the StateProvider API change documented above.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- CalendarDao, CalendarDayState, selectedDateProvider, and calendarDayProvider are all ready for consumption by Plan 02 (calendar strip UI)
|
||||||
|
- The `selectDate` method on SelectedDateNotifier is the correct way to update the selected date from the UI
|
||||||
|
- Existing dailyPlanProvider is unchanged — Plan 02 will decide whether to replace or retain it in the HomeScreen
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 05-calendar-strip*
|
||||||
|
*Completed: 2026-03-16*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: lib/features/home/data/calendar_dao.dart
|
||||||
|
- FOUND: lib/features/home/domain/calendar_models.dart
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- FOUND: test/features/home/data/calendar_dao_test.dart
|
||||||
|
- FOUND: .planning/phases/05-calendar-strip/05-01-SUMMARY.md
|
||||||
|
- FOUND: commit f5c4b49 (test RED phase)
|
||||||
|
- FOUND: commit c666f9a (feat GREEN phase)
|
||||||
|
- FOUND: commit 68ba7c6 (feat Task 2)
|
||||||
316
.planning/phases/05-calendar-strip/05-02-PLAN.md
Normal file
316
.planning/phases/05-calendar-strip/05-02-PLAN.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
---
|
||||||
|
phase: 05-calendar-strip
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["05-01"]
|
||||||
|
files_modified:
|
||||||
|
- lib/features/home/presentation/home_screen.dart
|
||||||
|
- lib/features/home/presentation/calendar_strip.dart
|
||||||
|
- lib/features/home/presentation/calendar_task_row.dart
|
||||||
|
- lib/features/home/presentation/calendar_day_list.dart
|
||||||
|
autonomous: false
|
||||||
|
requirements:
|
||||||
|
- CAL-01
|
||||||
|
- CAL-03
|
||||||
|
- CAL-04
|
||||||
|
- CAL-05
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Home screen shows a horizontal scrollable strip of day cards with German abbreviation (Mo, Di, Mi...) and date number"
|
||||||
|
- "Tapping a day card updates the task list below to show that day's tasks"
|
||||||
|
- "On app launch the strip auto-scrolls so today's card is centered"
|
||||||
|
- "A subtle wider gap and month label appears at month boundaries"
|
||||||
|
- "Overdue tasks appear in a separate coral-accented section when viewing today"
|
||||||
|
- "Overdue tasks do NOT appear when viewing past or future days"
|
||||||
|
- "Completing a task via checkbox triggers slide-out animation"
|
||||||
|
- "Floating Today button appears when scrolled away from today, hidden when today is visible"
|
||||||
|
- "First-run empty state (no rooms/tasks) still shows the create-room prompt"
|
||||||
|
- "Celebration state shows when all tasks for the selected day are done"
|
||||||
|
artifacts:
|
||||||
|
- path: "lib/features/home/presentation/calendar_strip.dart"
|
||||||
|
provides: "Horizontal scrollable date strip widget"
|
||||||
|
min_lines: 100
|
||||||
|
- path: "lib/features/home/presentation/calendar_day_list.dart"
|
||||||
|
provides: "Day task list with overdue section, empty, and celebration states"
|
||||||
|
min_lines: 80
|
||||||
|
- path: "lib/features/home/presentation/calendar_task_row.dart"
|
||||||
|
provides: "Task row adapted for calendar (no relative date, has room tag + checkbox)"
|
||||||
|
min_lines: 30
|
||||||
|
- path: "lib/features/home/presentation/home_screen.dart"
|
||||||
|
provides: "Rewritten HomeScreen composing strip + day list"
|
||||||
|
min_lines: 40
|
||||||
|
key_links:
|
||||||
|
- from: "lib/features/home/presentation/home_screen.dart"
|
||||||
|
to: "lib/features/home/presentation/calendar_strip.dart"
|
||||||
|
via: "HomeScreen composes CalendarStrip widget"
|
||||||
|
pattern: "CalendarStrip"
|
||||||
|
- from: "lib/features/home/presentation/home_screen.dart"
|
||||||
|
to: "lib/features/home/presentation/calendar_day_list.dart"
|
||||||
|
via: "HomeScreen composes CalendarDayList widget"
|
||||||
|
pattern: "CalendarDayList"
|
||||||
|
- from: "lib/features/home/presentation/calendar_strip.dart"
|
||||||
|
to: "lib/features/home/presentation/calendar_providers.dart"
|
||||||
|
via: "Strip reads and writes selectedDateProvider"
|
||||||
|
pattern: "selectedDateProvider"
|
||||||
|
- from: "lib/features/home/presentation/calendar_day_list.dart"
|
||||||
|
to: "lib/features/home/presentation/calendar_providers.dart"
|
||||||
|
via: "Day list watches calendarDayProvider for reactive task data"
|
||||||
|
pattern: "calendarDayProvider"
|
||||||
|
- from: "lib/features/home/presentation/calendar_day_list.dart"
|
||||||
|
to: "lib/features/tasks/presentation/task_providers.dart"
|
||||||
|
via: "Task completion uses taskActionsProvider.completeTask()"
|
||||||
|
pattern: "taskActionsProvider"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the complete calendar strip UI and replace the old HomeScreen with it.
|
||||||
|
|
||||||
|
Purpose: This is the user-facing deliverable of Phase 5 -- the horizontal date strip with day-task list that replaces the stacked overdue/today/tomorrow daily plan.
|
||||||
|
|
||||||
|
Output: CalendarStrip widget, CalendarDayList widget, CalendarTaskRow widget, rewritten HomeScreen that composes them.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/05-calendar-strip/5-CONTEXT.md
|
||||||
|
@.planning/phases/05-calendar-strip/05-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From Plan 01 outputs (CalendarDao, models, providers) -->
|
||||||
|
|
||||||
|
From lib/features/home/domain/calendar_models.dart:
|
||||||
|
```dart
|
||||||
|
class CalendarDayState {
|
||||||
|
final DateTime selectedDate;
|
||||||
|
final List<TaskWithRoom> dayTasks;
|
||||||
|
final List<TaskWithRoom> overdueTasks;
|
||||||
|
const CalendarDayState({required this.selectedDate, required this.dayTasks, required this.overdueTasks});
|
||||||
|
bool get isEmpty => dayTasks.isEmpty && overdueTasks.isEmpty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/presentation/calendar_providers.dart:
|
||||||
|
```dart
|
||||||
|
final selectedDateProvider = StateProvider<DateTime>(...); // read/write selected date
|
||||||
|
final calendarDayProvider = StreamProvider.autoDispose<CalendarDayState>(...); // reactive day data
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/domain/daily_plan_models.dart:
|
||||||
|
```dart
|
||||||
|
class TaskWithRoom {
|
||||||
|
final Task task;
|
||||||
|
final String roomName;
|
||||||
|
final int roomId;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/presentation/task_providers.dart:
|
||||||
|
```dart
|
||||||
|
// Use to complete tasks:
|
||||||
|
ref.read(taskActionsProvider.notifier).completeTask(taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/core/theme/app_theme.dart:
|
||||||
|
```dart
|
||||||
|
// Seed color: Color(0xFF7A9A6D) -- sage green
|
||||||
|
// The "light sage/green tint" for day cards should derive from the theme's primary/seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing reusable constants:
|
||||||
|
```dart
|
||||||
|
const _overdueColor = Color(0xFFE07A5F); // warm coral for overdue
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing l10n strings to reuse:
|
||||||
|
```dart
|
||||||
|
l10n.dailyPlanSectionOverdue // "Uberfaellig"
|
||||||
|
l10n.dailyPlanNoTasks // "Noch keine Aufgaben angelegt"
|
||||||
|
l10n.dailyPlanAllClearTitle // "Alles erledigt!"
|
||||||
|
l10n.dailyPlanAllClearMessage // "Keine Aufgaben fuer heute..."
|
||||||
|
l10n.homeEmptyMessage // "Lege zuerst einen Raum an..."
|
||||||
|
l10n.homeEmptyAction // "Raum erstellen"
|
||||||
|
l10n.calendarTodayButton // "Heute" (added in Plan 01)
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Build CalendarStrip, CalendarTaskRow, CalendarDayList widgets</name>
|
||||||
|
<files>
|
||||||
|
lib/features/home/presentation/calendar_strip.dart,
|
||||||
|
lib/features/home/presentation/calendar_task_row.dart,
|
||||||
|
lib/features/home/presentation/calendar_day_list.dart
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
**CalendarStrip** (`lib/features/home/presentation/calendar_strip.dart`):
|
||||||
|
|
||||||
|
A ConsumerStatefulWidget that renders a horizontal scrollable row of day cards.
|
||||||
|
|
||||||
|
Scroll range: 90 days in the past and 90 days in the future (181 total items). This gives enough past for review and future for planning without performance concerns.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
- Uses a `ScrollController` with `initialScrollOffset` calculated to center today's card on first build.
|
||||||
|
- Each day card is a fixed-width container (~56px wide, ~72px tall). Cards show:
|
||||||
|
- Top: German day abbreviation using `DateFormat('E', 'de').format(date)` which gives "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So". Import `package:intl/intl.dart`.
|
||||||
|
- Bottom: Date number (day of month) as text.
|
||||||
|
- Card styling per user decisions:
|
||||||
|
- All cards: light sage/green tint background. Use `theme.colorScheme.primaryContainer.withValues(alpha: 0.3)` or similar to get a subtle green wash.
|
||||||
|
- Selected card: stronger green (`theme.colorScheme.primaryContainer`) and border with `theme.colorScheme.primary`. The strip scrolls to center the selected card using `animateTo()`.
|
||||||
|
- Today's card (when not selected): bold text + a small accent underline bar below the date number (2px, primary color).
|
||||||
|
- Today + selected: both treatments combined.
|
||||||
|
- Spacing: cards have 4px horizontal margin by default. At month boundaries (where card N is the last day of a month and card N+1 is the first of the next month), the gap is 16px, and a small Text widget showing the new month abbreviation (e.g., "Apr") in `theme.textTheme.labelSmall` is inserted between them.
|
||||||
|
- On tap: update `ref.read(selectedDateProvider.notifier).state = tappedDate` and animate the scroll to center the tapped card.
|
||||||
|
- Auto-scroll on init: In `initState`, after the first frame (using `WidgetsBinding.instance.addPostFrameCallback`), animate to center today's card with a 200ms duration using `Curves.easeOut`.
|
||||||
|
|
||||||
|
Controller pattern for scroll-to-today:
|
||||||
|
```dart
|
||||||
|
class CalendarStripController {
|
||||||
|
VoidCallback? _scrollToToday;
|
||||||
|
void scrollToToday() => _scrollToToday?.call();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
CalendarStrip takes `CalendarStripController controller` parameter and sets `controller._scrollToToday` in initState. Parent calls `controller.scrollToToday()` from the Today button.
|
||||||
|
|
||||||
|
Today visibility callback: Expose `onTodayVisibilityChanged(bool isVisible)`. Determine visibility by checking if today's card offset is within the viewport bounds during scroll events.
|
||||||
|
|
||||||
|
**CalendarTaskRow** (`lib/features/home/presentation/calendar_task_row.dart`):
|
||||||
|
|
||||||
|
Adapted from `DailyPlanTaskRow` but simplified per user decisions:
|
||||||
|
- Shows: task name, tappable room tag (navigates to room via `context.go('/rooms/$roomId')`), checkbox
|
||||||
|
- Does NOT show relative date (strip already communicates which day)
|
||||||
|
- Same room tag styling as DailyPlanTaskRow (secondaryContainer chip with borderRadius 4)
|
||||||
|
- Checkbox visible, onChanged triggers `onCompleted` callback
|
||||||
|
- Overdue variant: if `isOverdue` flag is true, task name text color uses `_overdueColor` for visual distinction
|
||||||
|
|
||||||
|
**CalendarDayList** (`lib/features/home/presentation/calendar_day_list.dart`):
|
||||||
|
|
||||||
|
A ConsumerStatefulWidget that shows the task list for the selected day.
|
||||||
|
|
||||||
|
Watches `calendarDayProvider`. Manages `Set<int> _completingTaskIds` for animation state.
|
||||||
|
|
||||||
|
Handles these states:
|
||||||
|
|
||||||
|
a) **Loading**: `CircularProgressIndicator` centered.
|
||||||
|
|
||||||
|
b) **Error**: Error text centered.
|
||||||
|
|
||||||
|
c) **First-run empty** (no rooms/tasks at all): Same pattern as current `_buildNoTasksState` -- checklist icon, "Noch keine Aufgaben angelegt" message, "Lege zuerst einen Raum an" subtitle, "Raum erstellen" FilledButton.tonal navigating to `/rooms`. Detect by checking if `state.isEmpty && state.totalTaskCount == 0` (requires adding `totalTaskCount` field to CalendarDayState and computing it in the provider -- see NOTE below).
|
||||||
|
|
||||||
|
d) **Empty day** (tasks exist elsewhere but not this day, and not today): show centered subtle icon (Icons.event_available) + "Keine Aufgaben" text.
|
||||||
|
|
||||||
|
e) **Celebration** (today is selected, tasks exist elsewhere, but today's tasks are all done): show celebration icon + "Alles erledigt!" title + "Keine Aufgaben fuer heute. Geniesse den Moment!" message. Compact layout (no ProgressCard).
|
||||||
|
|
||||||
|
f) **Has tasks**: Render a ListView with:
|
||||||
|
- If overdue tasks exist (only present when viewing today): Section header "Uberfaellig" in coral color (`_overdueColor`), followed by overdue CalendarTaskRow items with `isOverdue: true` and interactive checkboxes.
|
||||||
|
- Day tasks: CalendarTaskRow items with interactive checkboxes.
|
||||||
|
- Task completion: on checkbox tap, add taskId to `_completingTaskIds`, call `ref.read(taskActionsProvider.notifier).completeTask(taskId)`. Render completing tasks with the `_CompletingTaskRow` animation (SizeTransition + SlideTransition, 300ms, Curves.easeInOut) -- recreate this private widget in calendar_day_list.dart.
|
||||||
|
|
||||||
|
NOTE for executor: Plan 01 creates CalendarDayState with selectedDate, dayTasks, overdueTasks. This task needs a `totalTaskCount` int field on CalendarDayState to distinguish first-run from celebration. When implementing, add `final int totalTaskCount` to CalendarDayState in calendar_models.dart and compute it in the calendarDayProvider via a simple `SELECT COUNT(*) FROM tasks` query (one line in CalendarDao: `Future<int> getTaskCount() async { final r = await (selectOnly(tasks)..addColumns([tasks.id.count()])).getSingle(); return r.read(tasks.id.count()) ?? 0; }`).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter analyze --no-fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>CalendarStrip renders 181 day cards with German abbreviations, highlights selected/today cards, shows month boundary labels. CalendarTaskRow shows name + room tag + checkbox without relative date. CalendarDayList shows overdue section (today only), day tasks, empty states, and celebration state. All compile without analysis errors.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Replace HomeScreen with calendar composition and floating Today button</name>
|
||||||
|
<files>
|
||||||
|
lib/features/home/presentation/home_screen.dart
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Rewrite `lib/features/home/presentation/home_screen.dart` entirely. The old content (DailyPlanState, overdue/today/tomorrow sections, ProgressCard) is fully replaced.
|
||||||
|
|
||||||
|
New HomeScreen is a `ConsumerStatefulWidget`:
|
||||||
|
|
||||||
|
State fields:
|
||||||
|
- `late final CalendarStripController _stripController = CalendarStripController();`
|
||||||
|
- `bool _showTodayButton = false;`
|
||||||
|
|
||||||
|
Build method returns a Stack with:
|
||||||
|
1. A Column containing:
|
||||||
|
- `CalendarStrip(controller: _stripController, onTodayVisibilityChanged: (visible) { setState(() => _showTodayButton = !visible); })`
|
||||||
|
- `Expanded(child: CalendarDayList())`
|
||||||
|
2. Conditionally, a Positioned floating "Heute" button at bottom-center:
|
||||||
|
- `FloatingActionButton.extended` with `Icons.today` icon and `l10n.calendarTodayButton` label
|
||||||
|
- onPressed: set `selectedDateProvider` to today's date-only DateTime, call `_stripController.scrollToToday()`
|
||||||
|
|
||||||
|
Imports needed:
|
||||||
|
- `flutter/material.dart`
|
||||||
|
- `flutter_riverpod/flutter_riverpod.dart`
|
||||||
|
- `calendar_strip.dart`
|
||||||
|
- `calendar_day_list.dart`
|
||||||
|
- `calendar_providers.dart` (for selectedDateProvider)
|
||||||
|
- `app_localizations.dart`
|
||||||
|
|
||||||
|
Do NOT delete old files (`daily_plan_providers.dart`, `daily_plan_task_row.dart`, `progress_card.dart`, `daily_plan_dao.dart`). DailyPlanDao is still used by the notification service. Old presentation files become dead code -- safe to clean up in a future phase.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter analyze --no-fatal-infos && flutter test</automated>
|
||||||
|
</verify>
|
||||||
|
<done>HomeScreen renders CalendarStrip at top and CalendarDayList below. Floating Today button appears when scrolled away from today. Old overdue/today/tomorrow sections are gone. Full test suite passes. No analysis errors.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 3: Verify calendar strip home screen visually and functionally</name>
|
||||||
|
<files>lib/features/home/presentation/home_screen.dart</files>
|
||||||
|
<action>
|
||||||
|
Human verifies the complete calendar strip experience on a running device/emulator.
|
||||||
|
|
||||||
|
Launch the app with `flutter run` (or hot-restart). Walk through all key behaviors:
|
||||||
|
1. Strip appearance: day cards with German abbreviations and date numbers
|
||||||
|
2. Today highlighting: centered, stronger green, bold + underline
|
||||||
|
3. Day selection: tap a card, task list updates
|
||||||
|
4. Month boundaries: wider gap with month label
|
||||||
|
5. Today button: appears when scrolled away, snaps back on tap
|
||||||
|
6. Overdue section: coral header on today only
|
||||||
|
7. Task completion: checkbox triggers slide-out animation
|
||||||
|
8. Empty/celebration states
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter analyze --no-fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>User has confirmed the calendar strip looks correct, day selection works, overdue behavior is right, and all states render properly.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `flutter analyze --no-fatal-infos` -- zero errors
|
||||||
|
- `flutter test` -- full test suite passes (existing + new DAO tests)
|
||||||
|
- Visual: calendar strip is horizontally scrollable with day cards
|
||||||
|
- Visual: selected day highlighted, today has bold + underline treatment
|
||||||
|
- Visual: month boundaries have wider gaps and month name labels
|
||||||
|
- Functional: tapping a day card updates the task list below
|
||||||
|
- Functional: overdue tasks appear only when viewing today
|
||||||
|
- Functional: floating Today button appears/disappears correctly
|
||||||
|
- Functional: task completion animation works
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Home screen replaced: no more stacked overdue/today/tomorrow sections
|
||||||
|
- Horizontal date strip scrolls smoothly with 181 day range
|
||||||
|
- Day cards show German abbreviations and date numbers
|
||||||
|
- Tapping a card selects it and shows that day's tasks
|
||||||
|
- Today auto-centers on launch with smooth animation
|
||||||
|
- Month boundaries visually distinct with labels
|
||||||
|
- Overdue carry-over only on today's view with coral accent
|
||||||
|
- Floating Today button for quick navigation
|
||||||
|
- Empty and celebration states work correctly
|
||||||
|
- All existing tests pass, no regressions
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/05-calendar-strip/05-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
148
.planning/phases/05-calendar-strip/05-02-SUMMARY.md
Normal file
148
.planning/phases/05-calendar-strip/05-02-SUMMARY.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
phase: 05-calendar-strip
|
||||||
|
plan: 02
|
||||||
|
subsystem: ui
|
||||||
|
tags: [flutter, riverpod, dart, intl, animation, calendar]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 05-calendar-strip plan 01
|
||||||
|
provides: CalendarDao, CalendarDayState, selectedDateProvider, calendarDayProvider
|
||||||
|
provides:
|
||||||
|
- CalendarStrip widget (181-day horizontal scroll, German abbreviations, month boundary labels)
|
||||||
|
- CalendarTaskRow widget (task name + room tag chip + checkbox, no relative date)
|
||||||
|
- CalendarDayList widget (loading/empty/celebration/tasks states, overdue section today-only)
|
||||||
|
- Rewritten HomeScreen composing strip + day list with floating Today button
|
||||||
|
- totalTaskCount field on CalendarDayState and getTaskCount() on CalendarDao
|
||||||
|
- Updated home screen and app shell tests for new calendar providers
|
||||||
|
affects:
|
||||||
|
- 06-task-history (uses CalendarStrip as the navigation surface)
|
||||||
|
- 07-task-sorting (task display within CalendarDayList)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "CalendarStrip uses CalendarStripController (simple VoidCallback holder) for parent-to-child imperative scrolling"
|
||||||
|
- "CalendarDayList manages _completingTaskIds Set<int> for slide-out animation the same way as old HomeScreen"
|
||||||
|
- "Tests use tester.pump() + pump(Duration) instead of pumpAndSettle() to avoid timeout from animation controllers"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- lib/features/home/presentation/calendar_strip.dart
|
||||||
|
- lib/features/home/presentation/calendar_task_row.dart
|
||||||
|
- lib/features/home/presentation/calendar_day_list.dart
|
||||||
|
modified:
|
||||||
|
- lib/features/home/presentation/home_screen.dart
|
||||||
|
- lib/features/home/domain/calendar_models.dart
|
||||||
|
- lib/features/home/data/calendar_dao.dart
|
||||||
|
- lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- test/features/home/presentation/home_screen_test.dart
|
||||||
|
- test/shell/app_shell_test.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "CalendarStripController holds a VoidCallback instead of using GlobalKey — simpler for this one-direction imperative call"
|
||||||
|
- "totalTaskCount fetched via getTaskCount() inside calendarDayProvider asyncMap — avoids a third stream, consistent with existing pattern"
|
||||||
|
- "Tests use pump() + pump(Duration) instead of pumpAndSettle() — CalendarStrip's ScrollController postFrameCallback and animation controllers cause pumpAndSettle to timeout"
|
||||||
|
- "month label height always reserved with SizedBox(height:16) on non-boundary cards — prevents strip height jitter as you scroll through months"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "ImperativeController pattern: class with VoidCallback? _action; void action() => _action?.call(); widget sets _action in initState"
|
||||||
|
- "CalendarDayList state machine: first-run (totalTaskCount==0) > celebration (isToday + isEmpty + totalTaskCount>0) > emptyDay (isEmpty) > hasTasks"
|
||||||
|
|
||||||
|
requirements-completed: [CAL-01, CAL-03, CAL-04, CAL-05]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 8min
|
||||||
|
completed: 2026-03-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 5 Plan 02: Calendar Strip UI Summary
|
||||||
|
|
||||||
|
**Horizontal 181-day calendar strip with German day cards, month boundaries, floating Today button, and day task list with overdue section — replaces the stacked daily-plan HomeScreen**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 8 min
|
||||||
|
- **Started:** 2026-03-16T20:27:39Z
|
||||||
|
- **Completed:** 2026-03-16T20:35:55Z
|
||||||
|
- **Tasks:** 3 (Task 3 auto-approved in auto-advance mode)
|
||||||
|
- **Files modified:** 9
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- CalendarStrip: horizontal ListView with 181 day cards (90 past + today + 90 future), German abbreviations via `DateFormat('E', 'de')`, selected card highlighted (stronger primaryContainer + border), today card with bold text + 2px accent underline, month boundary wider gap + month label, auto-scrolls to center today on init, CalendarStripController enables Today-button → strip communication
|
||||||
|
- CalendarDayList: five-state machine (loading, first-run empty, celebration, empty day, has tasks) with overdue section when viewing today, slide-out completion animation reusing the same SizeTransition + SlideTransition pattern from the old HomeScreen
|
||||||
|
- CalendarTaskRow: simplified from DailyPlanTaskRow — no relative date, name + room chip + checkbox, coral text when isOverdue
|
||||||
|
- HomeScreen rewritten: Stack with Column(CalendarStrip + Expanded(CalendarDayList)) and conditionally-visible FloatingActionButton.extended for "Heute" navigation
|
||||||
|
- Added totalTaskCount to CalendarDayState and getTaskCount() SELECT COUNT to CalendarDao for first-run vs. celebration disambiguation
|
||||||
|
- Updated 2 test files (home_screen_test.dart, app_shell_test.dart) to test new providers; test count grew from 100 to 101
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Build CalendarStrip, CalendarTaskRow, CalendarDayList widgets** - `f718ee8` (feat)
|
||||||
|
2. **Task 2: Replace HomeScreen with calendar composition** - `88ef248` (feat)
|
||||||
|
3. **Task 3: Verify calendar strip visually** - auto-approved (checkpoint:human-verify in auto-advance mode)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `lib/features/home/presentation/calendar_strip.dart` - 181-day horizontal scrollable strip with German abbreviations, today/selected highlights, month boundary labels
|
||||||
|
- `lib/features/home/presentation/calendar_task_row.dart` - Task row: name + room chip + checkbox, isOverdue coral styling, no relative date
|
||||||
|
- `lib/features/home/presentation/calendar_day_list.dart` - Day task list with 5-state machine, overdue section (today only), slide-out animation
|
||||||
|
- `lib/features/home/presentation/home_screen.dart` - Rewritten: CalendarStrip + CalendarDayList + floating Today FAB
|
||||||
|
- `lib/features/home/domain/calendar_models.dart` - Added totalTaskCount field
|
||||||
|
- `lib/features/home/data/calendar_dao.dart` - Added getTaskCount() query
|
||||||
|
- `lib/features/home/presentation/calendar_providers.dart` - calendarDayProvider now fetches and includes totalTaskCount
|
||||||
|
- `test/features/home/presentation/home_screen_test.dart` - Rewritten for CalendarDayState / calendarDayProvider
|
||||||
|
- `test/shell/app_shell_test.dart` - Updated from dailyPlanProvider to calendarDayProvider
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- **CalendarStripController as simple VoidCallback holder:** Avoids GlobalKey complexity for a single imperative scroll-to-today action; parent holds controller, widget registers its implementation in initState.
|
||||||
|
- **totalTaskCount fetched in asyncMap:** Consistent with existing calendarDayProvider asyncMap pattern; avoids a third reactive stream just for a count.
|
||||||
|
- **Tests use pump() + pump(Duration) instead of pumpAndSettle():** ScrollController's postFrameCallback animation and _completingTaskIds AnimationController keep the tester busy indefinitely; fixed-duration pump steps are reliable.
|
||||||
|
- **Month label height always reserved:** Non-boundary cards get `SizedBox(height: 16)` to match the label row height — prevents strip height from changing as you scroll across month edges.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Updated existing tests broken by the HomeScreen rewrite**
|
||||||
|
- **Found during:** Task 2 verification (flutter test)
|
||||||
|
- **Issue:** `home_screen_test.dart` and `app_shell_test.dart` both imported `dailyPlanProvider` and `DailyPlanState` and used `pumpAndSettle()`, which now times out because CalendarStrip animation controllers never settle
|
||||||
|
- **Fix:** Rewrote both test files to use `calendarDayProvider`/`CalendarDayState` and replaced `pumpAndSettle()` with `pump() + pump(Duration(milliseconds: 500))`; updated all assertions to match new UI (removed progress card / tomorrow section assertions, added strip-visible assertion)
|
||||||
|
- **Files modified:** test/features/home/presentation/home_screen_test.dart, test/shell/app_shell_test.dart
|
||||||
|
- **Verification:** `flutter test` — 101 tests all pass; `flutter analyze --no-fatal-infos` — zero issues
|
||||||
|
- **Committed in:** f718ee8 (Task 1 commit, as tests were fixed alongside widget creation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (Rule 1 - Bug)
|
||||||
|
**Impact on plan:** Required to maintain working test suite. The new tests cover the same behaviors (empty state, overdue section, celebration, checkboxes) but against the calendar API. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- None beyond the test migration documented above.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- HomeScreen is fully replaced; CalendarStrip and CalendarDayList are composable widgets ready for Phase 6/7 integration
|
||||||
|
- The old 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 is still used by the notification service and must NOT be deleted
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 05-calendar-strip*
|
||||||
|
*Completed: 2026-03-16*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_strip.dart
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_task_row.dart
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_day_list.dart
|
||||||
|
- FOUND: lib/features/home/presentation/home_screen.dart (rewritten)
|
||||||
|
- FOUND: lib/features/home/domain/calendar_models.dart (updated)
|
||||||
|
- FOUND: lib/features/home/data/calendar_dao.dart (updated)
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_providers.dart (updated)
|
||||||
|
- FOUND: .planning/phases/05-calendar-strip/05-02-SUMMARY.md
|
||||||
|
- FOUND: commit f718ee8 (Task 1)
|
||||||
|
- FOUND: commit 88ef248 (Task 2)
|
||||||
202
.planning/phases/05-calendar-strip/05-VERIFICATION.md
Normal file
202
.planning/phases/05-calendar-strip/05-VERIFICATION.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
phase: 05-calendar-strip
|
||||||
|
verified: 2026-03-16T21:00:00Z
|
||||||
|
status: human_needed
|
||||||
|
score: 10/10 must-haves verified
|
||||||
|
human_verification:
|
||||||
|
- test: "Launch the app on a device or emulator and confirm the calendar strip renders correctly"
|
||||||
|
expected: "Horizontal row of day cards with German abbreviations (Mo, Di, Mi...) and date number; today's card is bold with a 2px green underline accent; all cards have a light sage tint; selected card has stronger green background and border"
|
||||||
|
why_human: "Visual appearance, color fidelity, and card proportions cannot be verified programmatically"
|
||||||
|
- test: "Tap several day cards and verify the task list below updates"
|
||||||
|
expected: "Tapping a card selects it (green highlight, centered), and the task list below immediately shows that day's tasks"
|
||||||
|
why_human: "Interactive tap-to-select flow and reactive list update require a running device"
|
||||||
|
- test: "Scroll the strip far from today, then tap the floating Today button"
|
||||||
|
expected: "Floating 'Heute' FAB appears when today is scrolled out of view; tapping it re-centers today's card and resets the task list to today"
|
||||||
|
why_human: "Visibility toggle of FAB and imperative scroll-back behavior require real scroll interaction"
|
||||||
|
- test: "Verify month boundary treatment"
|
||||||
|
expected: "At every month boundary a slightly wider gap appears between the last card of one month and the first card of the next, with a small month label (e.g. 'Mrz', 'Apr') in the gap"
|
||||||
|
why_human: "Month label rendering and gap width are visual properties that require visual inspection"
|
||||||
|
- test: "With tasks overdue (nextDueDate before today), view today in the strip"
|
||||||
|
expected: "An 'Uberfaellig' section header in coral appears above the overdue tasks; switching to yesterday or tomorrow hides the overdue section entirely"
|
||||||
|
why_human: "Requires a device with test data in the database and navigation between days"
|
||||||
|
- test: "Complete a task via its checkbox"
|
||||||
|
expected: "The task slides out with a SizeTransition + SlideTransition animation (300ms); it disappears from the list after the animation"
|
||||||
|
why_human: "Animation quality and timing require visual observation on a running device"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 5: Calendar Strip Verification Report
|
||||||
|
|
||||||
|
**Phase 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
|
||||||
|
**Verified:** 2026-03-16T21:00:00Z
|
||||||
|
**Status:** human_needed — all automated checks pass; 6 visual/interactive behaviors need human confirmation
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|----|-------|--------|---------|
|
||||||
|
| 1 | Home screen shows a horizontal scrollable strip of day cards with German abbreviation (Mo, Di...) and date number | VERIFIED | `calendar_strip.dart` L265: `DateFormat('E', 'de').format(date)` produces German abbreviations; 181-card `ListView.builder` scroll direction horizontal |
|
||||||
|
| 2 | Tapping a day card updates the task list below to show that day's tasks | VERIFIED | `_onCardTapped` calls `ref.read(selectedDateProvider.notifier).selectDate(tappedDate)`; `CalendarDayList` watches `calendarDayProvider` which watches `selectedDateProvider` |
|
||||||
|
| 3 | On app launch the strip auto-scrolls so today's card is centered | VERIFIED | `initState` calls `WidgetsBinding.instance.addPostFrameCallback` → `_animateToToday()` which calls `_scrollController.animateTo(..., duration: 200ms, curve: Curves.easeOut)` |
|
||||||
|
| 4 | A subtle wider gap and month label appears at month boundaries | VERIFIED | `_kMonthBoundaryGap = 16.0` vs `_kCardMargin = 4.0`; `_isFirstOfMonth` triggers `DateFormat('MMM', 'de').format(date)` text label; non-boundary cards reserve `SizedBox(height: 16)` to prevent jitter |
|
||||||
|
| 5 | Overdue tasks appear in a separate coral-accented section when viewing today | VERIFIED | `calendarDayProvider` fetches `watchOverdueTasks` only when `isToday`; `_buildTaskList` renders a coral-colored "Uberfaellig" header via `_overdueColor = Color(0xFFE07A5F)` when `state.overdueTasks.isNotEmpty` |
|
||||||
|
| 6 | Overdue tasks do NOT appear when viewing past or future days | VERIFIED | `calendarDayProvider`: `isToday` guard — past/future sets `overdueTasks = const []`; 101-test suite includes `does not show overdue section for non-today date` test passing |
|
||||||
|
| 7 | Completing a task via checkbox triggers slide-out animation | VERIFIED | `_CompletingTaskRow` in `calendar_day_list.dart` implements `SizeTransition` + `SlideTransition` (300ms, `Curves.easeInOut`); `_onTaskCompleted` adds to `_completingTaskIds` and calls `taskActionsProvider.notifier.completeTask` |
|
||||||
|
| 8 | Floating Today button appears when scrolled away from today, hidden when today is visible | VERIFIED | `CalendarStrip.onTodayVisibilityChanged` callback drives `_showTodayButton` in `HomeScreen`; `_onScroll` computes viewport bounds vs today card position |
|
||||||
|
| 9 | First-run empty state (no rooms/tasks) still shows the create-room prompt | VERIFIED | `CalendarDayList._buildFirstRunEmpty` shows checklist icon + `l10n.dailyPlanNoTasks` + `l10n.homeEmptyAction` FilledButton.tonal navigating to `/rooms`; gated by `totalTaskCount == 0` |
|
||||||
|
| 10 | Celebration state shows when all tasks for the selected day are done | VERIFIED | `_buildCelebration` renders `Icons.celebration_outlined` + `dailyPlanAllClearTitle` + `dailyPlanAllClearMessage`; triggered by `isToday && dayTasks.isEmpty && overdueTasks.isEmpty && totalTaskCount > 0` |
|
||||||
|
|
||||||
|
**Score: 10/10 truths verified**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Artifacts
|
||||||
|
|
||||||
|
### Plan 01 Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Lines | Status | Details |
|
||||||
|
|----------|----------|-------|--------|---------|
|
||||||
|
| `lib/features/home/data/calendar_dao.dart` | Date-parameterized task queries | 87 | VERIFIED | `watchTasksForDate`, `watchOverdueTasks`, `getTaskCount` all implemented; `@DriftAccessor(tables: [Tasks, Rooms, TaskCompletions])` annotation present |
|
||||||
|
| `lib/features/home/data/calendar_dao.g.dart` | Generated Drift mixin | 25 | VERIFIED | `_$CalendarDaoMixin` generated, part of `calendar_dao.dart` |
|
||||||
|
| `lib/features/home/domain/calendar_models.dart` | CalendarDayState model | 25 | VERIFIED | `CalendarDayState` with `selectedDate`, `dayTasks`, `overdueTasks`, `totalTaskCount`, `isEmpty` getter |
|
||||||
|
| `lib/features/home/presentation/calendar_providers.dart` | Riverpod providers | 69 | VERIFIED | `selectedDateProvider` (NotifierProvider), `calendarDayProvider` (StreamProvider.autoDispose) with overdue-today-only logic |
|
||||||
|
| `test/features/home/data/calendar_dao_test.dart` | DAO unit tests (min 50 lines) | 286 | VERIFIED | 11 tests: 5 for `watchTasksForDate`, 6 for `watchOverdueTasks`; all pass |
|
||||||
|
|
||||||
|
### Plan 02 Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Lines | Status | Details |
|
||||||
|
|----------|----------|-------|--------|---------|
|
||||||
|
| `lib/features/home/presentation/calendar_strip.dart` | Horizontal scrollable date strip (min 100 lines) | 348 | VERIFIED | 181-card ListView, German abbreviations, CalendarStripController, today-visibility callback, month boundary labels |
|
||||||
|
| `lib/features/home/presentation/calendar_day_list.dart` | Day task list with states (min 80 lines) | 310 | VERIFIED | 5-state machine (loading/first-run/celebration/empty/tasks), overdue section, `_CompletingTaskRow` animation |
|
||||||
|
| `lib/features/home/presentation/calendar_task_row.dart` | Task row (min 30 lines) | 69 | VERIFIED | Name + room chip + checkbox; `isOverdue` coral styling; no relative date |
|
||||||
|
| `lib/features/home/presentation/home_screen.dart` | Rewritten HomeScreen (min 40 lines) | 69 | VERIFIED | Stack with Column(CalendarStrip + CalendarDayList) + conditional floating FAB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Link Verification
|
||||||
|
|
||||||
|
### Plan 01 Links
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `calendar_dao.dart` | `database.dart` | CalendarDao registered in @DriftDatabase daos | WIRED | `database.dart` L49: `daos: [RoomsDao, TasksDao, DailyPlanDao, CalendarDao]`; `database.g.dart` L1249: `late final CalendarDao calendarDao = CalendarDao(this as AppDatabase)` |
|
||||||
|
| `calendar_providers.dart` | `calendar_dao.dart` | Provider reads CalendarDao from AppDatabase via `db.calendarDao` | WIRED | `calendar_providers.dart` L46: `db.calendarDao.watchTasksForDate(selectedDate)`; L53–54: `db.calendarDao.watchOverdueTasks(selectedDate).first`; L60: `db.calendarDao.getTaskCount()` |
|
||||||
|
|
||||||
|
### Plan 02 Links
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `home_screen.dart` | `calendar_strip.dart` | HomeScreen composes CalendarStrip | WIRED | `home_screen.dart` L37: `CalendarStrip(controller: _stripController, ...)` |
|
||||||
|
| `home_screen.dart` | `calendar_day_list.dart` | HomeScreen composes CalendarDayList | WIRED | `home_screen.dart` L43: `const Expanded(child: CalendarDayList())` |
|
||||||
|
| `calendar_strip.dart` | `calendar_providers.dart` | Strip reads/writes selectedDateProvider | WIRED | `calendar_strip.dart` L193: `ref.read(selectedDateProvider.notifier).selectDate(tappedDate)`; L199: `ref.watch(selectedDateProvider)` |
|
||||||
|
| `calendar_day_list.dart` | `calendar_providers.dart` | Day list watches calendarDayProvider | WIRED | `calendar_day_list.dart` L46: `final dayState = ref.watch(calendarDayProvider)` |
|
||||||
|
| `calendar_day_list.dart` | `task_providers.dart` | Task completion via taskActionsProvider | WIRED | `calendar_day_list.dart` L39: `ref.read(taskActionsProvider.notifier).completeTask(taskId)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|-------------|-------------|--------|---------|
|
||||||
|
| CAL-01 | Plan 02 | User sees horizontal scrollable date-strip with day abbreviation (Mo, Di...) and date number per card | SATISFIED | `calendar_strip.dart`: 181-card horizontal ListView; `DateFormat('E', 'de')` for German abbreviations; `date.day.toString()` for date number |
|
||||||
|
| CAL-02 | Plan 01 | User can tap a day card to see that day's tasks in a list below the strip | SATISFIED | `_onCardTapped` → `selectedDateProvider` → `calendarDayProvider` → `CalendarDayList` reactive update |
|
||||||
|
| CAL-03 | Plan 02 | User sees a subtle color shift at month boundaries for visual orientation | SATISFIED | `_isFirstOfMonth` check triggers `_kMonthBoundaryGap = 16.0` (vs 4px normal) and `DateFormat('MMM', 'de')` month label in `theme.colorScheme.primary` |
|
||||||
|
| CAL-04 | Plan 02 | Calendar strip auto-scrolls to today on app launch | SATISFIED | `addPostFrameCallback` → `_animateToToday()` → `animateTo(200ms, Curves.easeOut)` centered on today's index |
|
||||||
|
| CAL-05 | Plans 01+02 | Undone tasks carry over to the next day with red/orange color accent | SATISFIED | `watchOverdueTasks` returns tasks with `nextDueDate < today`; `calendarDayProvider` includes them only for `isToday`; `_overdueColor = Color(0xFFE07A5F)` applied to section header and task name text |
|
||||||
|
|
||||||
|
**All 5 CAL requirements: SATISFIED**
|
||||||
|
|
||||||
|
No orphaned requirements — REQUIREMENTS.md maps CAL-01 through CAL-05 exclusively to Phase 5, all accounted for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns Found
|
||||||
|
|
||||||
|
No anti-patterns detected. Scan of all 7 phase-created/modified files found:
|
||||||
|
- No TODO/FIXME/XXX/HACK/PLACEHOLDER comments
|
||||||
|
- No empty implementations (`return null`, `return {}`, `return []`)
|
||||||
|
- No stub handlers (`() => {}` or `() => console.log(...)`)
|
||||||
|
- No unimplemented API routes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
| Suite | Result | Count |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `flutter test test/features/home/data/calendar_dao_test.dart` | All passed | 11/11 |
|
||||||
|
| `flutter test` (full suite) | All passed | 101/101 |
|
||||||
|
| `flutter analyze --no-fatal-infos` | No issues | 0 errors, 0 warnings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Verification
|
||||||
|
|
||||||
|
All 5 commits documented in SUMMARY files confirmed to exist in git history:
|
||||||
|
|
||||||
|
| Hash | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `f5c4b49` | test(05-01): add failing tests for CalendarDao |
|
||||||
|
| `c666f9a` | feat(05-01): implement CalendarDao with date-parameterized task queries |
|
||||||
|
| `68ba7c6` | feat(05-01): add CalendarDayState model, Riverpod providers, and l10n strings |
|
||||||
|
| `f718ee8` | feat(05-02): build CalendarStrip, CalendarTaskRow, CalendarDayList widgets |
|
||||||
|
| `88ef248` | feat(05-02): replace HomeScreen with calendar composition and floating Today button |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Human Verification Required
|
||||||
|
|
||||||
|
All automated checks pass. The following items require a device or emulator to confirm:
|
||||||
|
|
||||||
|
### 1. Calendar Strip Visual Rendering
|
||||||
|
|
||||||
|
**Test:** Launch the app, navigate to the home tab.
|
||||||
|
**Expected:** Horizontal row of day cards each showing a German day abbreviation (Mo, Di, Mi, Do, Fr, Sa, So) and a date number. All cards have a light sage/green tint background. Today's card has bold text and a 2px green underline accent bar below the date number.
|
||||||
|
**Why human:** Color fidelity, card proportions, and font weight treatment are visual properties.
|
||||||
|
|
||||||
|
### 2. Day Selection Updates Task List
|
||||||
|
|
||||||
|
**Test:** Tap several different day cards in the strip.
|
||||||
|
**Expected:** The tapped card becomes highlighted (stronger green background + border, centered in the strip), and the task list below immediately updates to show that day's scheduled tasks.
|
||||||
|
**Why human:** Interactive responsiveness and smooth centering animation require a running device.
|
||||||
|
|
||||||
|
### 3. Floating Today Button Behavior
|
||||||
|
|
||||||
|
**Test:** Scroll the strip well past today (e.g., 30+ days forward). Then tap the floating "Heute" button.
|
||||||
|
**Expected:** The "Heute" FAB appears when today's card is no longer in the viewport. Tapping it re-centers today's card with a smooth scroll animation and resets the task list to today's tasks. The FAB then disappears.
|
||||||
|
**Why human:** FAB visibility toggling based on scroll position and imperative scroll-back require real interaction.
|
||||||
|
|
||||||
|
### 4. Month Boundary Labels
|
||||||
|
|
||||||
|
**Test:** Scroll through a month boundary in the strip.
|
||||||
|
**Expected:** At the boundary, a small month name label (e.g., "Apr") appears above the first card of the new month, and the gap between the last card of the old month and the first card of the new month is visibly wider than the normal gap.
|
||||||
|
**Why human:** Gap width and label placement are visual properties.
|
||||||
|
|
||||||
|
### 5. Overdue Section Today-Only
|
||||||
|
|
||||||
|
**Test:** With at least one task whose nextDueDate is before today in the database, view the home screen on today's date, then tap a past or future date.
|
||||||
|
**Expected:** On today's view, a coral-colored "Uberfaellig" section header appears above the overdue task(s) with coral-colored task names. Switching to any other day hides the overdue section entirely — only that day's scheduled tasks appear.
|
||||||
|
**Why human:** Requires real data in the database and navigation between dates.
|
||||||
|
|
||||||
|
### 6. Task Completion Slide-Out Animation
|
||||||
|
|
||||||
|
**Test:** Tap a checkbox on any task in the day list.
|
||||||
|
**Expected:** The task row slides out to the right while simultaneously collapsing its height to zero, over approximately 300ms, then disappears from the list.
|
||||||
|
**Why human:** Animation smoothness, duration, and visual quality require observation on a running device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 5 goal is **fully achieved at the code level**. The horizontal calendar strip replaces the stacked daily plan, the data layer correctly handles date-parameterized queries and overdue isolation, all UI widgets are substantive and properly wired, all key links are connected, all 5 CAL requirements are satisfied, and the full 101-test suite passes with zero analysis issues.
|
||||||
|
|
||||||
|
The `human_needed` status reflects that 6 visual and interactive behaviors (strip appearance, tap selection, Today button scroll-back, month boundary labels, overdue section isolation, and task completion animation) require a running device to confirm their real-world quality. No code gaps were found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-16T21:00:00Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
312
.planning/phases/06-task-history/06-01-PLAN.md
Normal file
312
.planning/phases/06-task-history/06-01-PLAN.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
---
|
||||||
|
phase: 06-task-history
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- lib/features/tasks/data/tasks_dao.dart
|
||||||
|
- lib/features/tasks/data/tasks_dao.g.dart
|
||||||
|
- lib/features/tasks/presentation/task_history_sheet.dart
|
||||||
|
- lib/features/tasks/presentation/task_form_screen.dart
|
||||||
|
- lib/features/home/presentation/calendar_task_row.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
- test/features/tasks/data/task_history_dao_test.dart
|
||||||
|
autonomous: true
|
||||||
|
requirements: [HIST-01, HIST-02]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Every task completion is recorded with a timestamp and persists across app restarts"
|
||||||
|
- "User can open a history view from the task edit form showing all past completion dates in reverse-chronological order"
|
||||||
|
- "History view shows a meaningful empty state if the task has never been completed"
|
||||||
|
artifacts:
|
||||||
|
- path: "lib/features/tasks/data/tasks_dao.dart"
|
||||||
|
provides: "watchCompletionsForTask(int taskId) stream method"
|
||||||
|
contains: "watchCompletionsForTask"
|
||||||
|
- path: "lib/features/tasks/presentation/task_history_sheet.dart"
|
||||||
|
provides: "Bottom sheet displaying task completion history"
|
||||||
|
exports: ["showTaskHistorySheet"]
|
||||||
|
- path: "lib/features/tasks/presentation/task_form_screen.dart"
|
||||||
|
provides: "Verlauf button in edit mode opening history sheet"
|
||||||
|
contains: "showTaskHistorySheet"
|
||||||
|
- path: "lib/features/home/presentation/calendar_task_row.dart"
|
||||||
|
provides: "onTap navigation to task edit form"
|
||||||
|
contains: "context.go"
|
||||||
|
- path: "test/features/tasks/data/task_history_dao_test.dart"
|
||||||
|
provides: "Tests for completion history DAO query"
|
||||||
|
min_lines: 30
|
||||||
|
key_links:
|
||||||
|
- from: "lib/features/tasks/presentation/task_form_screen.dart"
|
||||||
|
to: "lib/features/tasks/presentation/task_history_sheet.dart"
|
||||||
|
via: "showTaskHistorySheet call in Verlauf button onTap"
|
||||||
|
pattern: "showTaskHistorySheet"
|
||||||
|
- from: "lib/features/tasks/presentation/task_history_sheet.dart"
|
||||||
|
to: "lib/features/tasks/data/tasks_dao.dart"
|
||||||
|
via: "watchCompletionsForTask stream consumption"
|
||||||
|
pattern: "watchCompletionsForTask"
|
||||||
|
- from: "lib/features/home/presentation/calendar_task_row.dart"
|
||||||
|
to: "TaskFormScreen"
|
||||||
|
via: "GoRouter navigation on row tap"
|
||||||
|
pattern: "context\\.go.*tasks"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add task completion history: a DAO query to fetch completions, a bottom sheet to display them, integration into the task edit form, and CalendarTaskRow onTap navigation.
|
||||||
|
|
||||||
|
Purpose: Users can see exactly when each task was completed in the past, building trust that the scheduling loop is working correctly.
|
||||||
|
Output: Working history view accessible from task edit form, completion data surfaced from existing TaskCompletions table.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/06-task-history/06-CONTEXT.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
<!-- Executor should use these directly -- no codebase exploration needed. -->
|
||||||
|
|
||||||
|
From lib/core/database/database.dart:
|
||||||
|
```dart
|
||||||
|
/// TaskCompletions table: records when a task was completed.
|
||||||
|
class TaskCompletions extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get taskId => integer().references(Tasks, #id)();
|
||||||
|
DateTimeColumn get completedAt => dateTime()();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftDatabase(
|
||||||
|
tables: [Rooms, Tasks, TaskCompletions],
|
||||||
|
daos: [RoomsDao, TasksDao, DailyPlanDao, CalendarDao],
|
||||||
|
)
|
||||||
|
class AppDatabase extends _$AppDatabase { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/data/tasks_dao.dart:
|
||||||
|
```dart
|
||||||
|
@DriftAccessor(tables: [Tasks, TaskCompletions])
|
||||||
|
class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
|
||||||
|
TasksDao(super.attachedDatabase);
|
||||||
|
|
||||||
|
Stream<List<Task>> watchTasksInRoom(int roomId) { ... }
|
||||||
|
Future<int> insertTask(TasksCompanion task) => into(tasks).insert(task);
|
||||||
|
Future<bool> updateTask(Task task) => update(tasks).replace(task);
|
||||||
|
Future<void> deleteTask(int taskId) { ... }
|
||||||
|
Future<void> completeTask(int taskId, {DateTime? now}) { ... }
|
||||||
|
Future<int> getOverdueTaskCount(int roomId, {DateTime? today}) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/presentation/task_form_screen.dart:
|
||||||
|
```dart
|
||||||
|
class TaskFormScreen extends ConsumerStatefulWidget {
|
||||||
|
final int? roomId;
|
||||||
|
final int? taskId;
|
||||||
|
const TaskFormScreen({super.key, this.roomId, this.taskId});
|
||||||
|
bool get isEditing => taskId != null;
|
||||||
|
}
|
||||||
|
// build() returns Scaffold with AppBar + Form > ListView with fields
|
||||||
|
// In edit mode: _existingTask is loaded via _loadExistingTask()
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/presentation/calendar_task_row.dart:
|
||||||
|
```dart
|
||||||
|
class CalendarTaskRow extends StatelessWidget {
|
||||||
|
const CalendarTaskRow({
|
||||||
|
super.key,
|
||||||
|
required this.taskWithRoom,
|
||||||
|
required this.onCompleted,
|
||||||
|
this.isOverdue = false,
|
||||||
|
});
|
||||||
|
final TaskWithRoom taskWithRoom;
|
||||||
|
final VoidCallback onCompleted;
|
||||||
|
final bool isOverdue;
|
||||||
|
}
|
||||||
|
// TaskWithRoom has: task (Task), roomName (String), roomId (int)
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/domain/daily_plan_models.dart:
|
||||||
|
```dart
|
||||||
|
class TaskWithRoom {
|
||||||
|
final Task task;
|
||||||
|
final String roomName;
|
||||||
|
final int roomId;
|
||||||
|
const TaskWithRoom({required this.task, required this.roomName, required this.roomId});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bottom sheet pattern from lib/features/rooms/presentation/icon_picker_sheet.dart:
|
||||||
|
```dart
|
||||||
|
Future<String?> showIconPickerSheet({
|
||||||
|
required BuildContext context,
|
||||||
|
String? selectedIconName,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => IconPickerSheet(...),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sheet uses SafeArea > Padding > Column(mainAxisSize: MainAxisSize.min) with drag handle
|
||||||
|
```
|
||||||
|
|
||||||
|
Router pattern from lib/core/router/router.dart:
|
||||||
|
```dart
|
||||||
|
// Task edit route: /rooms/:roomId/tasks/:taskId
|
||||||
|
GoRoute(
|
||||||
|
path: 'tasks/:taskId',
|
||||||
|
builder: (context, state) {
|
||||||
|
final taskId = int.parse(state.pathParameters['taskId']!);
|
||||||
|
return TaskFormScreen(taskId: taskId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Add DAO query, provider, localization, and tests for completion history</name>
|
||||||
|
<files>
|
||||||
|
lib/features/tasks/data/tasks_dao.dart,
|
||||||
|
lib/features/tasks/data/tasks_dao.g.dart,
|
||||||
|
lib/l10n/app_de.arb,
|
||||||
|
lib/l10n/app_localizations.dart,
|
||||||
|
lib/l10n/app_localizations_de.dart,
|
||||||
|
test/features/tasks/data/task_history_dao_test.dart
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- watchCompletionsForTask(taskId) returns Stream of TaskCompletion list ordered by completedAt DESC (newest first)
|
||||||
|
- Empty list returned when no completions exist for a given taskId
|
||||||
|
- After completeTask(taskId) is called, watchCompletionsForTask(taskId) emits a list containing the new completion with correct timestamp
|
||||||
|
- Completions for different tasks are isolated (taskId=1 completions do not appear in taskId=2 stream)
|
||||||
|
- Multiple completions for the same task are all returned in reverse-chronological order
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
RED phase:
|
||||||
|
Create test/features/tasks/data/task_history_dao_test.dart with tests for the behaviors above.
|
||||||
|
Use the existing in-memory database test pattern: AppDatabase(NativeDatabase.memory()), get TasksDao, insert a room and tasks, then test.
|
||||||
|
Run tests -- they MUST fail (watchCompletionsForTask does not exist yet).
|
||||||
|
|
||||||
|
GREEN phase:
|
||||||
|
1. In lib/features/tasks/data/tasks_dao.dart, add:
|
||||||
|
```dart
|
||||||
|
/// Watch all completions for a task, newest first.
|
||||||
|
Stream<List<TaskCompletion>> watchCompletionsForTask(int taskId) {
|
||||||
|
return (select(taskCompletions)
|
||||||
|
..where((c) => c.taskId.equals(taskId))
|
||||||
|
..orderBy([(c) => OrderingTerm.desc(c.completedAt)]))
|
||||||
|
.watch();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. Run `dart run build_runner build --delete-conflicting-outputs` to regenerate tasks_dao.g.dart.
|
||||||
|
3. Run tests -- they MUST pass.
|
||||||
|
|
||||||
|
Then add localization strings to lib/l10n/app_de.arb:
|
||||||
|
- "taskHistoryTitle": "Verlauf"
|
||||||
|
- "taskHistoryEmpty": "Noch nie erledigt"
|
||||||
|
- "taskHistoryCount": "{count} Mal erledigt" with @taskHistoryCount placeholder for count (int)
|
||||||
|
|
||||||
|
Run `flutter gen-l10n` to regenerate app_localizations.dart and app_localizations_de.dart.
|
||||||
|
|
||||||
|
NOTE: No separate Riverpod provider is needed -- the bottom sheet will access the DAO directly via appDatabaseProvider (same pattern as _loadExistingTask in TaskFormScreen). This keeps it simple since the sheet is a one-shot modal, not a long-lived screen.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test test/features/tasks/data/task_history_dao_test.dart -r expanded && flutter analyze --no-fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
watchCompletionsForTask method exists on TasksDao, returns Stream of completions sorted newest-first.
|
||||||
|
All new DAO tests pass. All 101+ existing tests still pass.
|
||||||
|
Three German localization strings (taskHistoryTitle, taskHistoryEmpty, taskHistoryCount) are available via AppLocalizations.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Build history bottom sheet, wire into TaskFormScreen, add CalendarTaskRow navigation</name>
|
||||||
|
<files>
|
||||||
|
lib/features/tasks/presentation/task_history_sheet.dart,
|
||||||
|
lib/features/tasks/presentation/task_form_screen.dart,
|
||||||
|
lib/features/home/presentation/calendar_task_row.dart
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Create lib/features/tasks/presentation/task_history_sheet.dart:
|
||||||
|
- Export a top-level function: `Future<void> showTaskHistorySheet({required BuildContext context, required int taskId})`
|
||||||
|
- Uses `showModalBottomSheet` with `isScrollControlled: true` following icon_picker_sheet.dart pattern
|
||||||
|
- The sheet widget is a ConsumerWidget (needs ref to access DAO)
|
||||||
|
- Uses `ref.read(appDatabaseProvider).tasksDao.watchCompletionsForTask(taskId)` wrapped in a StreamBuilder
|
||||||
|
- Layout: SafeArea > Padding(16) > Column(mainAxisSize: min):
|
||||||
|
a. Drag handle (same as icon_picker_sheet: Container 32x4, onSurfaceVariant 0.4 alpha, rounded)
|
||||||
|
b. Title: AppLocalizations.of(context).taskHistoryTitle (i.e. "Verlauf"), titleMedium style
|
||||||
|
c. Optional: completion count summary below title using taskHistoryCount string -- show only when count > 0
|
||||||
|
d. SizedBox(height: 16)
|
||||||
|
e. StreamBuilder on watchCompletionsForTask:
|
||||||
|
- Loading: Center(CircularProgressIndicator())
|
||||||
|
- Empty data: centered Column with Icon(Icons.history, size: 48, color: onSurfaceVariant) + SizedBox(8) + Text(taskHistoryEmpty), style: bodyLarge, color: onSurfaceVariant
|
||||||
|
- Has data: ConstrainedBox(maxHeight: MediaQuery.of(context).size.height * 0.4) > ListView.builder:
|
||||||
|
Each item: ListTile with leading Icon(Icons.check_circle_outline, color: primary), title: DateFormat('dd.MM.yyyy', 'de').format(completion.completedAt), subtitle: DateFormat('HH:mm', 'de').format(completion.completedAt)
|
||||||
|
f. SizedBox(height: 8) at bottom
|
||||||
|
|
||||||
|
2. Modify lib/features/tasks/presentation/task_form_screen.dart:
|
||||||
|
- Import task_history_sheet.dart
|
||||||
|
- In the build() method's ListView children, AFTER the due date picker section and ONLY when `widget.isEditing` is true, add:
|
||||||
|
```
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.history),
|
||||||
|
title: Text(l10n.taskHistoryTitle),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => showTaskHistorySheet(context: context, taskId: widget.taskId!),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- This adds a "Verlauf" row that opens the history bottom sheet
|
||||||
|
|
||||||
|
3. Modify lib/features/home/presentation/calendar_task_row.dart:
|
||||||
|
- Add an onTap callback to the ListTile that navigates to the task edit form
|
||||||
|
- The CalendarTaskRow already has access to taskWithRoom.task.id and taskWithRoom.roomId
|
||||||
|
- Add to ListTile: `onTap: () => context.go('/rooms/${taskWithRoom.roomId}/tasks/${taskWithRoom.task.id}')`
|
||||||
|
- This enables: CalendarTaskRow tap -> TaskFormScreen (edit mode) -> "Verlauf" button -> history sheet
|
||||||
|
- Keep the existing onCompleted checkbox behavior unchanged
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test && flutter analyze --no-fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
History bottom sheet opens from TaskFormScreen in edit mode via "Verlauf" row.
|
||||||
|
Sheet shows completion dates in dd.MM.yyyy + HH:mm format, reverse-chronological.
|
||||||
|
Empty state shows Icons.history + "Noch nie erledigt" message.
|
||||||
|
CalendarTaskRow tapping navigates to TaskFormScreen for that task.
|
||||||
|
All existing tests still pass. dart analyze clean.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Phase 6 verification checks:
|
||||||
|
1. `flutter test` -- all tests pass (101 existing + new DAO tests)
|
||||||
|
2. `flutter analyze --no-fatal-infos` -- zero issues
|
||||||
|
3. Manual flow: Open app > tap a task in calendar > task edit form opens > "Verlauf" row visible > tap it > bottom sheet shows history or empty state
|
||||||
|
4. Manual flow: Complete a task via checkbox > navigate to that task's edit form > tap "Verlauf" > new completion entry appears with timestamp
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- HIST-01: Task completion recording verified via DAO tests (completions already written by completeTask; new query surfaces them)
|
||||||
|
- HIST-02: History bottom sheet accessible from task edit form, shows all past completions reverse-chronologically with German date/time formatting, shows meaningful empty state
|
||||||
|
- CalendarTaskRow tapping navigates to task edit form (history one tap away)
|
||||||
|
- Zero regressions: all existing tests pass, dart analyze clean
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/06-task-history/06-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
131
.planning/phases/06-task-history/06-01-SUMMARY.md
Normal file
131
.planning/phases/06-task-history/06-01-SUMMARY.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
phase: 06-task-history
|
||||||
|
plan: 01
|
||||||
|
subsystem: database, ui
|
||||||
|
tags: [drift, flutter, riverpod, go_router, intl, bottom-sheet, stream]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 05-calendar-strip
|
||||||
|
provides: CalendarTaskRow widget and CalendarDayList that render tasks in the home screen
|
||||||
|
provides:
|
||||||
|
- watchCompletionsForTask(taskId) DAO stream on TasksDao — sorted newest-first
|
||||||
|
- task_history_sheet.dart with showTaskHistorySheet() function
|
||||||
|
- Verlauf ListTile in TaskFormScreen (edit mode) opening history bottom sheet
|
||||||
|
- CalendarTaskRow onTap navigation to TaskFormScreen for the tapped task
|
||||||
|
affects: [07-task-sorting, future-phases-using-TaskFormScreen]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Bottom sheet follows icon_picker_sheet pattern: showModalBottomSheet with isScrollControlled, ConsumerWidget inside, SafeArea > Padding > Column(mainAxisSize.min)"
|
||||||
|
- "StreamBuilder on DAO stream directly accessed via ref.read(appDatabaseProvider).tasksDao.methodName (no separate Riverpod provider for one-shot modals)"
|
||||||
|
- "TDD: RED test commit followed by GREEN implementation commit"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- lib/features/tasks/presentation/task_history_sheet.dart
|
||||||
|
- test/features/tasks/data/task_history_dao_test.dart
|
||||||
|
modified:
|
||||||
|
- lib/features/tasks/data/tasks_dao.dart
|
||||||
|
- lib/features/tasks/data/tasks_dao.g.dart
|
||||||
|
- lib/features/tasks/presentation/task_form_screen.dart
|
||||||
|
- lib/features/home/presentation/calendar_task_row.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "No separate Riverpod provider for history sheet — ref.read(appDatabaseProvider) directly in ConsumerWidget keeps it simple for a one-shot modal"
|
||||||
|
- "CalendarTaskRow onTap routes to /rooms/:roomId/tasks/:taskId so history is always one tap away from the home screen"
|
||||||
|
- "Count summary line shown above list when completions > 0; not shown for empty state"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "History sheet: showModalBottomSheet returning Future<void>, ConsumerWidget sheet with StreamBuilder on DAO stream"
|
||||||
|
- "Edit-mode-only ListTile pattern: if (widget.isEditing) [...] in TaskFormScreen ListView children"
|
||||||
|
|
||||||
|
requirements-completed: [HIST-01, HIST-02]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 5min
|
||||||
|
completed: 2026-03-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6 Plan 1: Task History Summary
|
||||||
|
|
||||||
|
**Drift DAO stream for task completion history, bottom sheet with reverse-chronological German-formatted dates, wired from CalendarTaskRow tap through TaskFormScreen Verlauf button**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 5 min
|
||||||
|
- **Started:** 2026-03-16T20:52:49Z
|
||||||
|
- **Completed:** 2026-03-16T20:57:19Z
|
||||||
|
- **Tasks:** 2 (Task 1 TDD: RED + GREEN + localization; Task 2: sheet + wiring + navigation)
|
||||||
|
- **Files modified:** 9
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- `watchCompletionsForTask(int taskId)` added to TasksDao: returns `Stream<List<TaskCompletion>>` sorted by completedAt DESC
|
||||||
|
- Task history bottom sheet (`task_history_sheet.dart`) with StreamBuilder, empty state, German date/time formatting via intl
|
||||||
|
- Verlauf ListTile added to TaskFormScreen edit mode, opens history sheet on tap
|
||||||
|
- CalendarTaskRow gains `onTap` that navigates via GoRouter to the task edit form, making history one tap away from the calendar
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **RED - Failing DAO tests** - `2687f5e` (test)
|
||||||
|
2. **Task 1: DAO method, localization** - `ceae7d7` (feat)
|
||||||
|
3. **Task 2: History sheet, form wiring, navigation** - `9f902ff` (feat)
|
||||||
|
|
||||||
|
**Plan metadata:** (docs commit — see below)
|
||||||
|
|
||||||
|
_Note: TDD tasks have separate RED (test) and GREEN (feat) commits_
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `lib/features/tasks/data/tasks_dao.dart` - Added watchCompletionsForTask stream method
|
||||||
|
- `lib/features/tasks/data/tasks_dao.g.dart` - Regenerated by build_runner
|
||||||
|
- `lib/features/tasks/presentation/task_history_sheet.dart` - New: bottom sheet with StreamBuilder, empty state, completion list
|
||||||
|
- `lib/features/tasks/presentation/task_form_screen.dart` - Added Verlauf ListTile in edit mode
|
||||||
|
- `lib/features/home/presentation/calendar_task_row.dart` - Added onTap navigation to task edit form
|
||||||
|
- `lib/l10n/app_de.arb` - Added taskHistoryTitle, taskHistoryEmpty, taskHistoryCount strings
|
||||||
|
- `lib/l10n/app_localizations.dart` - Regenerated (abstract class updated)
|
||||||
|
- `lib/l10n/app_localizations_de.dart` - Regenerated (German implementation updated)
|
||||||
|
- `test/features/tasks/data/task_history_dao_test.dart` - New: 5 tests covering empty state, single/multiple completions, task isolation, stream reactivity
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- No separate Riverpod provider for history sheet: `ref.read(appDatabaseProvider).tasksDao.watchCompletionsForTask(taskId)` directly in the ConsumerWidget. One-shot modals do not need a dedicated provider.
|
||||||
|
- CalendarTaskRow navigation uses `context.go('/rooms/.../tasks/...')` consistent with existing GoRouter route patterns.
|
||||||
|
- Removed unused `import 'package:drift/drift.dart'` from test file (Rule 1 auto-fix during GREEN verification).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Removed unused import from test file**
|
||||||
|
- **Found during:** Task 1 (GREEN phase, flutter analyze)
|
||||||
|
- **Issue:** `import 'package:drift/drift.dart'` was copied from the existing tasks_dao_test.dart pattern but not needed in the new history test file (no `Value()` usage)
|
||||||
|
- **Fix:** Removed the unused import line
|
||||||
|
- **Files modified:** test/features/tasks/data/task_history_dao_test.dart
|
||||||
|
- **Verification:** flutter analyze reports zero issues
|
||||||
|
- **Committed in:** ceae7d7 (Task 1 feat commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (1 bug — unused import)
|
||||||
|
**Impact on plan:** Trivial cleanup. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
None — plan executed smoothly. All 106 tests pass (101 pre-existing + 5 new DAO tests), zero analyze issues.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- Phase 6 Plan 1 complete. Task history is fully functional.
|
||||||
|
- Phase 7 (task sorting) can proceed independently.
|
||||||
|
- No blockers.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 06-task-history*
|
||||||
|
*Completed: 2026-03-16*
|
||||||
81
.planning/phases/06-task-history/06-CONTEXT.md
Normal file
81
.planning/phases/06-task-history/06-CONTEXT.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Phase 6: Task History - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-03-16
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Let users view past completion dates for any individual task. The data layer already records completions (TaskCompletions table + completeTask writes timestamps). This phase adds a DAO query and a UI to surface that data. Requirements: HIST-01 (verify recording works), HIST-02 (view history).
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Entry point
|
||||||
|
- From the task edit form (TaskFormScreen) in edit mode: add a "Verlauf" (History) button/row that opens the history view
|
||||||
|
- From CalendarTaskRow: add onTap to navigate to the task edit form (currently only has checkbox) — history is then one tap away
|
||||||
|
- No long-press or context menu — keep interaction model simple and consistent
|
||||||
|
|
||||||
|
### History view format
|
||||||
|
- Bottom sheet (showModalBottomSheet) — consistent with existing template_picker_sheet and icon_picker_sheet patterns
|
||||||
|
- Each entry shows: date formatted as "dd.MM.yyyy" and time as "HH:mm" — German locale
|
||||||
|
- Entries listed reverse-chronological (newest first)
|
||||||
|
- No grouping or pagination — household tasks won't have thousands of completions; simple ListView is sufficient
|
||||||
|
|
||||||
|
### Empty state
|
||||||
|
- When task has never been completed: centered icon (e.g., Icons.history) + "Noch nie erledigt" message — meaningful, not just blank
|
||||||
|
- No special state for many completions — just scroll
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Exact bottom sheet height and styling
|
||||||
|
- Whether to show a completion count summary at the top of the sheet
|
||||||
|
- Animation and transition details
|
||||||
|
- DAO query structure (single method returning List<TaskCompletion>)
|
||||||
|
- Whether CalendarTaskRow onTap goes to edit form or directly to history
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
No specific requirements — user chose "You decide." Open to standard approaches that match existing app patterns.
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `TaskCompletions` table: Already exists in database.dart (id, taskId, completedAt) — no schema change needed
|
||||||
|
- `TasksDao.completeTask()`: Already inserts into taskCompletions on every completion — HIST-01 data recording is done
|
||||||
|
- `showModalBottomSheet`: Used by template_picker_sheet.dart and icon_picker_sheet.dart — established pattern for overlays
|
||||||
|
- `AppLocalizations` + `.arb` files: German-only localization pipeline in place
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- DAOs extend `DatabaseAccessor<AppDatabase>` with `@DriftAccessor` annotation
|
||||||
|
- Riverpod `StreamProvider.autoDispose` or `FutureProvider` for reactive data
|
||||||
|
- Feature folder structure: `features/tasks/data/`, `domain/`, `presentation/`
|
||||||
|
- Bottom sheets use `showModalBottomSheet` with `DraggableScrollableSheet` or simple `Column`
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `TaskFormScreen` (edit mode): Entry point for history — add a row/button when `isEditing`
|
||||||
|
- `TasksDao`: Add `watchCompletionsForTask(int taskId)` or `getCompletionsForTask(int taskId)` method
|
||||||
|
- `CalendarTaskRow`: Currently no onTap — needs navigation to task edit form for history access
|
||||||
|
- `router.dart`: Route `/rooms/:roomId/tasks/:taskId` already exists for TaskFormScreen — no new route needed if using bottom sheet
|
||||||
|
- `app_de.arb`: Add localization strings for history UI labels
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 06-task-history*
|
||||||
|
*Context gathered: 2026-03-16*
|
||||||
114
.planning/phases/06-task-history/6-VERIFICATION.md
Normal file
114
.planning/phases/06-task-history/6-VERIFICATION.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
phase: 06-task-history
|
||||||
|
verified: 2026-03-16T22:15:00Z
|
||||||
|
status: passed
|
||||||
|
score: 3/3 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6: Task History Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Users can see exactly when each task was completed in the past, building trust that the scheduling loop is working correctly
|
||||||
|
**Verified:** 2026-03-16T22:15:00Z
|
||||||
|
**Status:** PASSED
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Every task completion is recorded with a timestamp and persists across app restarts | VERIFIED | `watchCompletionsForTask` reads from `TaskCompletions` table (persistent SQLite); `completeTask` already wrote timestamps; 5 DAO tests confirm stream returns correct data including stream reactivity test |
|
||||||
|
| 2 | User can open a history view from the task edit form showing all past completion dates in reverse-chronological order | VERIFIED | `task_form_screen.dart` lines 192-204: `if (widget.isEditing)` guard shows `ListTile` with `onTap: () => showTaskHistorySheet(...)`. Sheet uses `StreamBuilder` on `watchCompletionsForTask` with `..orderBy([(c) => OrderingTerm.desc(c.completedAt)])`, renders dates as `dd.MM.yyyy` + `HH:mm` via intl |
|
||||||
|
| 3 | History view shows a meaningful empty state if the task has never been completed | VERIFIED | `task_history_sheet.dart` lines 70-87: `if (completions.isEmpty)` branch renders `Icon(Icons.history, size: 48)` + `Text(l10n.taskHistoryEmpty)` ("Noch nie erledigt") |
|
||||||
|
|
||||||
|
**Score:** 3/3 truths verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Provides | Status | Details |
|
||||||
|
|----------|---------|--------|---------|
|
||||||
|
| `lib/features/tasks/data/tasks_dao.dart` | `watchCompletionsForTask(int taskId)` stream method | VERIFIED | Method exists at line 85, returns `Stream<List<TaskCompletion>>`, ordered by `completedAt DESC`, 110 lines total |
|
||||||
|
| `lib/features/tasks/presentation/task_history_sheet.dart` | Bottom sheet displaying task completion history | VERIFIED | 137 lines, exports top-level `showTaskHistorySheet()`, `_TaskHistorySheet` is a `ConsumerWidget` with full StreamBuilder, empty state, date list |
|
||||||
|
| `lib/features/tasks/presentation/task_form_screen.dart` | Verlauf button in edit mode opening history sheet | VERIFIED | Imports `task_history_sheet.dart` (line 13), `showTaskHistorySheet` called at line 199, guarded by `if (widget.isEditing)` |
|
||||||
|
| `lib/features/home/presentation/calendar_task_row.dart` | onTap navigation to task edit form | VERIFIED | `ListTile.onTap` at line 39 calls `context.go('/rooms/${taskWithRoom.roomId}/tasks/${taskWithRoom.task.id}')` |
|
||||||
|
| `test/features/tasks/data/task_history_dao_test.dart` | Tests for completion history DAO query | VERIFIED | 158 lines, 5 tests: empty state, single completion, multiple reverse-chronological, task isolation, stream reactivity — all pass |
|
||||||
|
| `lib/features/tasks/data/tasks_dao.g.dart` | Drift-generated mixin (build_runner output) | VERIFIED | Exists, 25 lines, regenerated with `taskCompletions` table accessor present |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `task_form_screen.dart` | `task_history_sheet.dart` | `showTaskHistorySheet` call in Verlauf `onTap` | WIRED | Import at line 13; called at line 199 inside `if (widget.isEditing)` block |
|
||||||
|
| `task_history_sheet.dart` | `tasks_dao.dart` | `watchCompletionsForTask` stream consumption | WIRED | `ref.read(appDatabaseProvider).tasksDao.watchCompletionsForTask(taskId)` at lines 59-62; stream result consumed by `StreamBuilder` builder |
|
||||||
|
| `calendar_task_row.dart` | `TaskFormScreen` | GoRouter navigation on row tap | WIRED | `context.go('/rooms/${taskWithRoom.roomId}/tasks/${taskWithRoom.task.id}')` at line 39-41; route `/rooms/:roomId/tasks/:taskId` resolves to `TaskFormScreen` per router.dart |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|------------|-------------|--------|----------|
|
||||||
|
| HIST-01 | 06-01-PLAN.md | Each task completion is recorded with a timestamp | SATISFIED | `TasksDao.completeTask()` inserts into `TaskCompletions` (pre-existing); `watchCompletionsForTask` surfaces data; 5 DAO tests confirm timestamps are stored and retrieved correctly |
|
||||||
|
| HIST-02 | 06-01-PLAN.md | User can view past completion dates for any individual task | SATISFIED | Full UI chain: `CalendarTaskRow.onTap` -> `TaskFormScreen` (edit mode) -> "Verlauf" `ListTile` -> `showTaskHistorySheet` -> `_TaskHistorySheet` StreamBuilder showing reverse-chronological German-formatted dates |
|
||||||
|
|
||||||
|
No orphaned requirements — REQUIREMENTS.md Traceability table shows only HIST-01 and HIST-02 mapped to Phase 6, both accounted for and marked Complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
None. No TODOs, FIXMEs, placeholder returns, empty handlers, or stub implementations found in any of the 5 modified source files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
#### 1. Tap-to-edit navigation in running app
|
||||||
|
|
||||||
|
**Test:** Launch app, ensure at least one task exists on the calendar, tap the task row (not the checkbox).
|
||||||
|
**Expected:** App navigates to `TaskFormScreen` in edit mode showing the task's fields and a "Verlauf" row at the bottom.
|
||||||
|
**Why human:** GoRouter navigation with `context.go` cannot be verified by static analysis; requires runtime rendering.
|
||||||
|
|
||||||
|
#### 2. History sheet opens with correct content
|
||||||
|
|
||||||
|
**Test:** In `TaskFormScreen` edit mode, tap the "Verlauf" ListTile.
|
||||||
|
**Expected:** Bottom sheet slides up showing either: (a) the empty state with a history icon and "Noch nie erledigt", or (b) a list of past completions with `dd.MM.yyyy` dates as titles and `HH:mm` times as subtitles, newest first.
|
||||||
|
**Why human:** `showModalBottomSheet` rendering and visual layout cannot be verified by static analysis.
|
||||||
|
|
||||||
|
#### 3. Live update after completing a task
|
||||||
|
|
||||||
|
**Test:** Complete a task via checkbox in the calendar, then navigate to that task's edit form and tap "Verlauf".
|
||||||
|
**Expected:** The newly recorded completion appears at the top of the history sheet with today's date and approximate current time.
|
||||||
|
**Why human:** Real-time stream reactivity through the full UI stack (checkbox -> DAO write -> stream emit -> sheet UI update) requires runtime observation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verification Summary
|
||||||
|
|
||||||
|
All automated checks passed with no gaps found.
|
||||||
|
|
||||||
|
**Test suite:** 106/106 tests pass (101 pre-existing + 5 new DAO tests covering all specified behaviors).
|
||||||
|
**Static analysis:** `flutter analyze --no-fatal-infos` — zero issues.
|
||||||
|
**Commits verified:** All three phase commits exist (`2687f5e`, `ceae7d7`, `9f902ff`) with expected file changes.
|
||||||
|
|
||||||
|
The full feature chain is intact:
|
||||||
|
- `TaskCompletions` table stores timestamps (HIST-01, pre-existing from data layer)
|
||||||
|
- `watchCompletionsForTask` surfaces completions as a live Drift stream
|
||||||
|
- `task_history_sheet.dart` renders them in German locale with reverse-chronological ordering and a meaningful empty state
|
||||||
|
- `TaskFormScreen` (edit mode only) provides the "Verlauf" entry point
|
||||||
|
- `CalendarTaskRow` onTap makes history reachable from the home calendar in two taps
|
||||||
|
|
||||||
|
Three human-only items remain for final sign-off: tap navigation, sheet rendering, and live update after completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-16T22:15:00Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
276
.planning/phases/07-task-sorting/07-01-PLAN.md
Normal file
276
.planning/phases/07-task-sorting/07-01-PLAN.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
---
|
||||||
|
phase: 07-task-sorting
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- lib/features/tasks/domain/task_sort_option.dart
|
||||||
|
- lib/features/tasks/presentation/sort_preference_notifier.dart
|
||||||
|
- lib/features/tasks/presentation/sort_preference_notifier.g.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
- lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- lib/features/tasks/presentation/task_providers.dart
|
||||||
|
- test/features/tasks/presentation/sort_preference_notifier_test.dart
|
||||||
|
autonomous: true
|
||||||
|
requirements: [SORT-01, SORT-02, SORT-03]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Sort preference persists across app restarts"
|
||||||
|
- "CalendarDayList tasks are sorted according to the active sort preference"
|
||||||
|
- "TaskListScreen tasks are sorted according to the active sort preference"
|
||||||
|
- "Default sort is alphabetical (matches current CalendarDayList behavior)"
|
||||||
|
artifacts:
|
||||||
|
- path: "lib/features/tasks/domain/task_sort_option.dart"
|
||||||
|
provides: "TaskSortOption enum with alphabetical, interval, effort values"
|
||||||
|
exports: ["TaskSortOption"]
|
||||||
|
- path: "lib/features/tasks/presentation/sort_preference_notifier.dart"
|
||||||
|
provides: "SortPreferenceNotifier with SharedPreferences persistence"
|
||||||
|
exports: ["SortPreferenceNotifier", "sortPreferenceProvider"]
|
||||||
|
- path: "lib/features/home/presentation/calendar_providers.dart"
|
||||||
|
provides: "calendarDayProvider sorts dayTasks by active sort preference"
|
||||||
|
contains: "sortPreferenceProvider"
|
||||||
|
- path: "lib/features/tasks/presentation/task_providers.dart"
|
||||||
|
provides: "tasksInRoomProvider sorts tasks by active sort preference"
|
||||||
|
contains: "sortPreferenceProvider"
|
||||||
|
- path: "test/features/tasks/presentation/sort_preference_notifier_test.dart"
|
||||||
|
provides: "Unit tests for sort preference persistence and default"
|
||||||
|
key_links:
|
||||||
|
- from: "lib/features/home/presentation/calendar_providers.dart"
|
||||||
|
to: "sortPreferenceProvider"
|
||||||
|
via: "ref.watch in calendarDayProvider"
|
||||||
|
pattern: "ref\\.watch\\(sortPreferenceProvider\\)"
|
||||||
|
- from: "lib/features/tasks/presentation/task_providers.dart"
|
||||||
|
to: "sortPreferenceProvider"
|
||||||
|
via: "ref.watch in tasksInRoomProvider"
|
||||||
|
pattern: "ref\\.watch\\(sortPreferenceProvider\\)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the task sort domain model, SharedPreferences-backed persistence provider, and integrate sort logic into both task list providers (calendarDayProvider and tasksInRoomProvider).
|
||||||
|
|
||||||
|
Purpose: Establishes the data layer and sort logic so that task lists react to sort preference changes. The UI plan (07-02) will add the dropdown widget that writes to this provider.
|
||||||
|
|
||||||
|
Output: TaskSortOption enum, SortPreferenceNotifier, updated calendarDayProvider and tasksInRoomProvider with in-memory sorting, German localization strings for sort labels.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/07-task-sorting/07-CONTEXT.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
From lib/features/tasks/domain/effort_level.dart:
|
||||||
|
```dart
|
||||||
|
enum EffortLevel {
|
||||||
|
low, // 0
|
||||||
|
medium, // 1
|
||||||
|
high, // 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/domain/frequency.dart:
|
||||||
|
```dart
|
||||||
|
enum IntervalType {
|
||||||
|
daily, // 0
|
||||||
|
everyNDays, // 1
|
||||||
|
weekly, // 2
|
||||||
|
biweekly, // 3
|
||||||
|
monthly, // 4
|
||||||
|
everyNMonths, // 5
|
||||||
|
quarterly, // 6
|
||||||
|
yearly, // 7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/domain/daily_plan_models.dart:
|
||||||
|
```dart
|
||||||
|
class TaskWithRoom {
|
||||||
|
final Task task;
|
||||||
|
final String roomName;
|
||||||
|
final int roomId;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/domain/calendar_models.dart:
|
||||||
|
```dart
|
||||||
|
class CalendarDayState {
|
||||||
|
final DateTime selectedDate;
|
||||||
|
final List<TaskWithRoom> dayTasks;
|
||||||
|
final List<TaskWithRoom> overdueTasks;
|
||||||
|
final int totalTaskCount;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/core/theme/theme_provider.dart (pattern to follow for SharedPreferences notifier):
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class ThemeNotifier extends _$ThemeNotifier {
|
||||||
|
@override
|
||||||
|
ThemeMode build() {
|
||||||
|
_loadPersistedThemeMode();
|
||||||
|
return ThemeMode.system; // sync default, async load overrides
|
||||||
|
}
|
||||||
|
Future<void> _loadPersistedThemeMode() async { ... }
|
||||||
|
Future<void> setThemeMode(ThemeMode mode) async {
|
||||||
|
state = mode;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_themeModeKey, _themeModeToString(mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/presentation/calendar_providers.dart:
|
||||||
|
```dart
|
||||||
|
final calendarDayProvider = StreamProvider.autoDispose<CalendarDayState>((ref) {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
final selectedDate = ref.watch(selectedDateProvider);
|
||||||
|
// ... fetches dayTasks, overdueTasks, totalTaskCount
|
||||||
|
// dayTasks come from watchTasksForDate which sorts alphabetically in SQL
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/presentation/task_providers.dart:
|
||||||
|
```dart
|
||||||
|
final tasksInRoomProvider = StreamProvider.family.autoDispose<List<Task>, int>((ref, roomId) {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
return db.tasksDao.watchTasksInRoom(roomId);
|
||||||
|
// watchTasksInRoom sorts by nextDueDate in SQL
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/core/database/database.dart (Task table columns relevant to sorting):
|
||||||
|
```dart
|
||||||
|
class Tasks extends Table {
|
||||||
|
TextColumn get name => text().withLength(min: 1, max: 200)();
|
||||||
|
IntColumn get intervalType => intEnum<IntervalType>()();
|
||||||
|
IntColumn get intervalDays => integer().withDefault(const Constant(1))();
|
||||||
|
IntColumn get effortLevel => intEnum<EffortLevel>()();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create TaskSortOption enum, SortPreferenceNotifier, and localization strings</name>
|
||||||
|
<files>
|
||||||
|
lib/features/tasks/domain/task_sort_option.dart,
|
||||||
|
lib/features/tasks/presentation/sort_preference_notifier.dart,
|
||||||
|
lib/features/tasks/presentation/sort_preference_notifier.g.dart,
|
||||||
|
lib/l10n/app_de.arb,
|
||||||
|
lib/l10n/app_localizations.dart,
|
||||||
|
lib/l10n/app_localizations_de.dart,
|
||||||
|
test/features/tasks/presentation/sort_preference_notifier_test.dart
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Default sort preference is TaskSortOption.alphabetical
|
||||||
|
- setSortOption(TaskSortOption.interval) updates state to interval
|
||||||
|
- Sort preference persists: after setSortOption(effort), a fresh notifier reads back effort from SharedPreferences
|
||||||
|
- TaskSortOption enum has exactly 3 values: alphabetical, interval, effort
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `lib/features/tasks/domain/task_sort_option.dart`:
|
||||||
|
- `enum TaskSortOption { alphabetical, interval, effort }` — three values only, no index stability concern since this is NOT stored as intEnum in drift (stored as string in SharedPreferences)
|
||||||
|
|
||||||
|
2. Create `lib/features/tasks/presentation/sort_preference_notifier.dart`:
|
||||||
|
- Follow the exact ThemeNotifier pattern from `lib/core/theme/theme_provider.dart`
|
||||||
|
- `@riverpod class SortPreferenceNotifier extends _$SortPreferenceNotifier`
|
||||||
|
- `build()` returns `TaskSortOption.alphabetical` synchronously (default = alphabetical per user decision for continuity with current A-Z sort in CalendarDayList), then calls `_loadPersisted()` async
|
||||||
|
- `_loadPersisted()` reads `SharedPreferences.getString('task_sort_option')` and maps to enum
|
||||||
|
- `setSortOption(TaskSortOption option)` sets state immediately then persists string to SharedPreferences
|
||||||
|
- Static helpers `_fromString` / `_toString` for serialization (use enum .name property)
|
||||||
|
- The generated provider will be named `sortPreferenceProvider` (Riverpod 3 naming convention, consistent with themeProvider)
|
||||||
|
|
||||||
|
3. Run `dart run build_runner build --delete-conflicting-outputs` to generate `.g.dart`
|
||||||
|
|
||||||
|
4. Add localization strings to `lib/l10n/app_de.arb`:
|
||||||
|
- `"sortAlphabetical": "A\u2013Z"` (A-Z with en-dash, concise label per user decision)
|
||||||
|
- `"sortInterval": "Intervall"` (German for interval/frequency)
|
||||||
|
- `"sortEffort": "Aufwand"` (German for effort, matches existing taskFormEffortLabel context)
|
||||||
|
- `"sortLabel": "Sortierung"` (label for accessibility/semantics on the dropdown)
|
||||||
|
|
||||||
|
5. Run `flutter gen-l10n` to regenerate localization files
|
||||||
|
|
||||||
|
6. Write tests in `test/features/tasks/presentation/sort_preference_notifier_test.dart`:
|
||||||
|
- Follow the pattern from notification_settings test: `makeContainer()` helper that creates ProviderContainer, awaits `Future.delayed(Duration.zero)` for async load
|
||||||
|
- `SharedPreferences.setMockInitialValues({})` in setUp
|
||||||
|
- Test: default is alphabetical
|
||||||
|
- Test: setSortOption updates state
|
||||||
|
- Test: persisted value is loaded on restart (set mock initial values with key, verify state after load)
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test test/features/tasks/presentation/sort_preference_notifier_test.dart && flutter analyze --no-fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>TaskSortOption enum exists with 3 values. SortPreferenceNotifier persists to SharedPreferences. 3+ unit tests pass. ARB file has 4 new sort strings. dart analyze clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Integrate sort logic into calendarDayProvider and tasksInRoomProvider</name>
|
||||||
|
<files>
|
||||||
|
lib/features/home/presentation/calendar_providers.dart,
|
||||||
|
lib/features/tasks/presentation/task_providers.dart
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Edit `lib/features/home/presentation/calendar_providers.dart`:
|
||||||
|
- Add import for `sort_preference_notifier.dart` and `task_sort_option.dart`
|
||||||
|
- Inside `calendarDayProvider`, add `final sortOption = ref.watch(sortPreferenceProvider);`
|
||||||
|
- After constructing `CalendarDayState`, apply in-memory sort to `dayTasks` list before returning. Do NOT sort overdueTasks (overdue section stays pinned at top in its existing order per user discretion decision).
|
||||||
|
- Sort implementation — create a top-level helper function `List<TaskWithRoom> _sortTasks(List<TaskWithRoom> tasks, TaskSortOption sortOption)` that returns a new sorted list:
|
||||||
|
- `alphabetical`: sort by `task.name.toLowerCase()` (case-insensitive A-Z)
|
||||||
|
- `interval`: sort by `task.intervalType.index` ascending (daily=0 is most frequent, yearly=7 is least), then by `task.intervalDays` ascending as tiebreaker
|
||||||
|
- `effort`: sort by `task.effortLevel.index` ascending (low=0, medium=1, high=2)
|
||||||
|
- Apply: `dayTasks: _sortTasks(dayTasks, sortOption)` in the CalendarDayState constructor call
|
||||||
|
- Note: The SQL `orderBy([OrderingTerm.asc(tasks.name)])` in CalendarDao.watchTasksForDate still runs, but the in-memory sort overrides it. This is intentional — the SQL sort provides a stable baseline, the in-memory sort applies the user's preference.
|
||||||
|
|
||||||
|
2. Edit `lib/features/tasks/presentation/task_providers.dart`:
|
||||||
|
- Add import for `sort_preference_notifier.dart` and `task_sort_option.dart`
|
||||||
|
- In `tasksInRoomProvider`, add `final sortOption = ref.watch(sortPreferenceProvider);`
|
||||||
|
- Map the stream to apply in-memory sorting: `return db.tasksDao.watchTasksInRoom(roomId).map((tasks) => _sortTasksRaw(tasks, sortOption));`
|
||||||
|
- Create a top-level helper `List<Task> _sortTasksRaw(List<Task> tasks, TaskSortOption sortOption)` that sorts raw Task objects (not TaskWithRoom):
|
||||||
|
- `alphabetical`: sort by `task.name.toLowerCase()`
|
||||||
|
- `interval`: sort by `task.intervalType.index`, then `task.intervalDays`
|
||||||
|
- `effort`: sort by `task.effortLevel.index`
|
||||||
|
- Returns a new sorted list (do not mutate the original)
|
||||||
|
|
||||||
|
3. Verify both providers react to sort preference changes by running existing tests (they should still pass since default sort is alphabetical and current data is already alphabetically sorted or test data is single-item).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test && flutter analyze --no-fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>calendarDayProvider watches sortPreferenceProvider and sorts dayTasks accordingly. tasksInRoomProvider watches sortPreferenceProvider and sorts tasks accordingly. All 106+ existing tests pass. dart analyze clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `flutter test` — all 106+ tests pass (existing + new sort preference tests)
|
||||||
|
- `flutter analyze --no-fatal-infos` — zero issues
|
||||||
|
- `sortPreferenceProvider` is watchable and defaults to alphabetical
|
||||||
|
- Both calendarDayProvider and tasksInRoomProvider react to sort preference changes
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- TaskSortOption enum exists with alphabetical, interval, effort values
|
||||||
|
- SortPreferenceNotifier persists sort preference to SharedPreferences
|
||||||
|
- Default sort is alphabetical (continuity with existing A-Z sort)
|
||||||
|
- calendarDayProvider sorts dayTasks by active sort (overdue section unsorted)
|
||||||
|
- tasksInRoomProvider sorts tasks by active sort
|
||||||
|
- All tests pass, analyze clean
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/07-task-sorting/07-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
138
.planning/phases/07-task-sorting/07-01-SUMMARY.md
Normal file
138
.planning/phases/07-task-sorting/07-01-SUMMARY.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
phase: 07-task-sorting
|
||||||
|
plan: 01
|
||||||
|
subsystem: ui
|
||||||
|
tags: [flutter, riverpod, shared_preferences, sorting, localization]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 05-calendar-strip
|
||||||
|
provides: calendarDayProvider and CalendarDayState used by sort integration
|
||||||
|
- phase: 06-task-history
|
||||||
|
provides: task domain model and CalendarTaskRow context
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- TaskSortOption enum (alphabetical, interval, effort)
|
||||||
|
- SortPreferenceNotifier with SharedPreferences persistence
|
||||||
|
- sortPreferenceProvider (keepAlive Riverpod provider)
|
||||||
|
- calendarDayProvider with in-memory sort of dayTasks
|
||||||
|
- tasksInRoomProvider with in-memory sort via stream.map
|
||||||
|
- German localization strings: sortAlphabetical, sortInterval, sortEffort, sortLabel
|
||||||
|
|
||||||
|
affects: [07-02-sort-ui, any phase using calendarDayProvider or tasksInRoomProvider]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "SortPreferenceNotifier: sync default return, async _loadPersisted() — same pattern as ThemeNotifier"
|
||||||
|
- "In-memory sort helper functions (_sortTasks, _sortTasksRaw) applied after DB stream emit"
|
||||||
|
- "overdueTasks intentionally unsorted — only dayTasks sorted"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- lib/features/tasks/domain/task_sort_option.dart
|
||||||
|
- lib/features/tasks/presentation/sort_preference_notifier.dart
|
||||||
|
- lib/features/tasks/presentation/sort_preference_notifier.g.dart
|
||||||
|
- test/features/tasks/presentation/sort_preference_notifier_test.dart
|
||||||
|
modified:
|
||||||
|
- lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- lib/features/tasks/presentation/task_providers.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Default sort is alphabetical — continuity with existing A-Z SQL sort in CalendarDayList"
|
||||||
|
- "overdueTasks are NOT sorted — they stay pinned at the top in existing order"
|
||||||
|
- "Sort stored as string (enum.name) in SharedPreferences — not intEnum, so reordering enum is safe"
|
||||||
|
- "SortPreferenceNotifier uses keepAlive: true — global preference should never be disposed"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "SortPreferenceNotifier pattern: sync default + async _loadPersisted() — matches ThemeNotifier"
|
||||||
|
- "In-memory sort via stream.map in StreamProvider — DB SQL sort provides stable baseline, in-memory overrides"
|
||||||
|
|
||||||
|
requirements-completed: [SORT-01, SORT-02, SORT-03]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 4min
|
||||||
|
completed: 2026-03-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 07 Plan 01: Task Sort Domain and Provider Summary
|
||||||
|
|
||||||
|
**TaskSortOption enum + SharedPreferences-backed SortPreferenceNotifier wired into calendarDayProvider and tasksInRoomProvider with in-memory alphabetical/interval/effort sorting**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 4 min
|
||||||
|
- **Started:** 2026-03-16T21:29:32Z
|
||||||
|
- **Completed:** 2026-03-16T21:33:37Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 9
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- TaskSortOption enum (alphabetical, interval, effort) with SharedPreferences persistence via SortPreferenceNotifier
|
||||||
|
- calendarDayProvider now watches sortPreferenceProvider and sorts dayTasks in-memory; overdueTasks intentionally unsorted
|
||||||
|
- tasksInRoomProvider now watches sortPreferenceProvider and applies sort via stream.map
|
||||||
|
- 7 new unit tests for SortPreferenceNotifier covering default, state update, persistence, and restart recovery
|
||||||
|
- 4 German localization strings added (sortAlphabetical, sortInterval, sortEffort, sortLabel)
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **TDD RED: Failing sort preference tests** - `a9f2983` (test)
|
||||||
|
2. **Task 1: TaskSortOption enum, SortPreferenceNotifier, localization** - `13c7d62` (feat)
|
||||||
|
3. **Task 2: Sort integration into calendarDayProvider and tasksInRoomProvider** - `3697e4e` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `lib/features/tasks/domain/task_sort_option.dart` - TaskSortOption enum with alphabetical/interval/effort values
|
||||||
|
- `lib/features/tasks/presentation/sort_preference_notifier.dart` - SortPreferenceNotifier with SharedPreferences persistence
|
||||||
|
- `lib/features/tasks/presentation/sort_preference_notifier.g.dart` - Generated Riverpod provider code
|
||||||
|
- `lib/features/home/presentation/calendar_providers.dart` - Added sortPreferenceProvider watch + _sortTasks helper
|
||||||
|
- `lib/features/tasks/presentation/task_providers.dart` - Added sortPreferenceProvider watch + _sortTasksRaw helper + stream.map
|
||||||
|
- `lib/l10n/app_de.arb` - Added sortAlphabetical, sortInterval, sortEffort, sortLabel strings
|
||||||
|
- `lib/l10n/app_localizations.dart` - Regenerated with sort string getters
|
||||||
|
- `lib/l10n/app_localizations_de.dart` - Regenerated with German sort string implementations
|
||||||
|
- `test/features/tasks/presentation/sort_preference_notifier_test.dart` - 7 unit tests for sort preference
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- Default sort is alphabetical for continuity with existing SQL A-Z sort in CalendarDayList
|
||||||
|
- overdueTasks section is explicitly NOT sorted — stays pinned at top in existing order
|
||||||
|
- Sort preference stored as enum.name string in SharedPreferences (not intEnum) so enum reordering is always safe
|
||||||
|
- SortPreferenceNotifier uses `keepAlive: true` — global app preference must not be disposed
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- sortPreferenceProvider is live and defaults to alphabetical
|
||||||
|
- Both task list providers react to sort preference changes immediately
|
||||||
|
- Ready for 07-02: sort UI (dropdown in AppBar) to write to sortPreferenceProvider
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 07-task-sorting*
|
||||||
|
*Completed: 2026-03-16*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: lib/features/tasks/domain/task_sort_option.dart
|
||||||
|
- FOUND: lib/features/tasks/presentation/sort_preference_notifier.dart
|
||||||
|
- FOUND: lib/features/tasks/presentation/sort_preference_notifier.g.dart
|
||||||
|
- FOUND: test/features/tasks/presentation/sort_preference_notifier_test.dart
|
||||||
|
- FOUND: .planning/phases/07-task-sorting/07-01-SUMMARY.md
|
||||||
|
- Commits a9f2983, 13c7d62, 3697e4e all verified in git log
|
||||||
214
.planning/phases/07-task-sorting/07-02-PLAN.md
Normal file
214
.planning/phases/07-task-sorting/07-02-PLAN.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
---
|
||||||
|
phase: 07-task-sorting
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["07-01"]
|
||||||
|
files_modified:
|
||||||
|
- lib/features/tasks/presentation/sort_dropdown.dart
|
||||||
|
- lib/features/home/presentation/home_screen.dart
|
||||||
|
- lib/features/tasks/presentation/task_list_screen.dart
|
||||||
|
- test/features/home/presentation/home_screen_test.dart
|
||||||
|
autonomous: true
|
||||||
|
requirements: [SORT-01, SORT-02, SORT-03]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A sort dropdown is visible in the HomeScreen AppBar showing the current sort label"
|
||||||
|
- "A sort dropdown is visible in the TaskListScreen AppBar showing the current sort label"
|
||||||
|
- "Tapping the dropdown shows three options: A-Z, Intervall, Aufwand"
|
||||||
|
- "Selecting a sort option updates the task list order immediately"
|
||||||
|
- "The sort preference persists across screen navigations and app restarts"
|
||||||
|
artifacts:
|
||||||
|
- path: "lib/features/tasks/presentation/sort_dropdown.dart"
|
||||||
|
provides: "Reusable SortDropdown ConsumerWidget"
|
||||||
|
exports: ["SortDropdown"]
|
||||||
|
- path: "lib/features/home/presentation/home_screen.dart"
|
||||||
|
provides: "HomeScreen with AppBar containing SortDropdown"
|
||||||
|
contains: "SortDropdown"
|
||||||
|
- path: "lib/features/tasks/presentation/task_list_screen.dart"
|
||||||
|
provides: "TaskListScreen AppBar with SortDropdown alongside edit/delete"
|
||||||
|
contains: "SortDropdown"
|
||||||
|
key_links:
|
||||||
|
- from: "lib/features/tasks/presentation/sort_dropdown.dart"
|
||||||
|
to: "sortPreferenceProvider"
|
||||||
|
via: "ref.watch for display, ref.read for mutation"
|
||||||
|
pattern: "ref\\.watch\\(sortPreferenceProvider\\)"
|
||||||
|
- from: "lib/features/home/presentation/home_screen.dart"
|
||||||
|
to: "lib/features/tasks/presentation/sort_dropdown.dart"
|
||||||
|
via: "SortDropdown widget in AppBar actions"
|
||||||
|
pattern: "SortDropdown"
|
||||||
|
- from: "lib/features/tasks/presentation/task_list_screen.dart"
|
||||||
|
to: "lib/features/tasks/presentation/sort_dropdown.dart"
|
||||||
|
via: "SortDropdown widget in AppBar actions"
|
||||||
|
pattern: "SortDropdown"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the sort dropdown widget and wire it into both task list screens (HomeScreen and TaskListScreen), adding an AppBar to HomeScreen.
|
||||||
|
|
||||||
|
Purpose: Gives users visible access to the sort controls. The data layer from Plan 01 already sorts reactively; this plan adds the UI trigger.
|
||||||
|
|
||||||
|
Output: SortDropdown reusable widget, updated HomeScreen with AppBar, updated TaskListScreen with dropdown in existing AppBar, updated tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/07-task-sorting/07-CONTEXT.md
|
||||||
|
@.planning/phases/07-task-sorting/07-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Interfaces from Plan 01 that this plan depends on -->
|
||||||
|
|
||||||
|
From lib/features/tasks/domain/task_sort_option.dart (created in 07-01):
|
||||||
|
```dart
|
||||||
|
enum TaskSortOption { alphabetical, interval, effort }
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/presentation/sort_preference_notifier.dart (created in 07-01):
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class SortPreferenceNotifier extends _$SortPreferenceNotifier {
|
||||||
|
TaskSortOption build(); // returns alphabetical by default
|
||||||
|
Future<void> setSortOption(TaskSortOption option);
|
||||||
|
}
|
||||||
|
// Generated as: sortPreferenceProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/l10n/app_de.arb (strings added in 07-01):
|
||||||
|
```
|
||||||
|
sortAlphabetical: "A-Z"
|
||||||
|
sortInterval: "Intervall"
|
||||||
|
sortEffort: "Aufwand"
|
||||||
|
sortLabel: "Sortierung"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Existing interfaces being modified -->
|
||||||
|
|
||||||
|
From lib/features/home/presentation/home_screen.dart:
|
||||||
|
```dart
|
||||||
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
|
// Currently: Stack with CalendarStrip + CalendarDayList + floating Today FAB
|
||||||
|
// No AppBar — body sits directly inside AppShell's Scaffold
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/presentation/task_list_screen.dart:
|
||||||
|
```dart
|
||||||
|
class TaskListScreen extends ConsumerWidget {
|
||||||
|
// Has its own Scaffold with AppBar containing edit + delete IconButtons
|
||||||
|
// AppBar actions: [edit, delete]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/presentation/calendar_strip.dart:
|
||||||
|
```dart
|
||||||
|
class CalendarStrip extends StatefulWidget {
|
||||||
|
const CalendarStrip({super.key, required this.controller, this.onTodayVisibilityChanged});
|
||||||
|
final CalendarStripController controller;
|
||||||
|
final ValueChanged<bool>? onTodayVisibilityChanged;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Build SortDropdown widget and integrate into HomeScreen and TaskListScreen</name>
|
||||||
|
<files>
|
||||||
|
lib/features/tasks/presentation/sort_dropdown.dart,
|
||||||
|
lib/features/home/presentation/home_screen.dart,
|
||||||
|
lib/features/tasks/presentation/task_list_screen.dart
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Create `lib/features/tasks/presentation/sort_dropdown.dart`:
|
||||||
|
- A `ConsumerWidget` named `SortDropdown`
|
||||||
|
- Uses `PopupMenuButton<TaskSortOption>` (Material 3, better than DropdownButton for AppBar trailing actions — it opens a menu overlay rather than inline expansion)
|
||||||
|
- `ref.watch(sortPreferenceProvider)` to get current sort option
|
||||||
|
- The button child shows the current sort label as a Text widget using l10n strings:
|
||||||
|
- `alphabetical` -> `l10n.sortAlphabetical` (A-Z)
|
||||||
|
- `interval` -> `l10n.sortInterval` (Intervall)
|
||||||
|
- `effort` -> `l10n.sortEffort` (Aufwand)
|
||||||
|
- Style the button child as a Row with `Icon(Icons.sort)` + `SizedBox(width: 4)` + label Text. Use `theme.textTheme.labelLarge` for the text.
|
||||||
|
- `itemBuilder` returns 3 `PopupMenuItem<TaskSortOption>` entries with check marks: for each option, show a Row with `Icon(Icons.check, size: 18)` (visible only when selected, invisible when not via `Opacity(opacity: isSelected ? 1 : 0)`) + `SizedBox(width: 8)` + label Text
|
||||||
|
- `onSelected`: `ref.read(sortPreferenceProvider.notifier).setSortOption(value)`
|
||||||
|
- Helper method `String _label(TaskSortOption option, AppLocalizations l10n)` that maps enum to l10n string
|
||||||
|
|
||||||
|
2. Edit `lib/features/home/presentation/home_screen.dart`:
|
||||||
|
- HomeScreen currently returns a `Stack` with `Column(CalendarStrip, Expanded(CalendarDayList))` + optional floating Today button
|
||||||
|
- Wrap the entire current Stack in a `Scaffold` with an `AppBar`:
|
||||||
|
- `AppBar(title: Text(l10n.tabHome), actions: [const SortDropdown()])`
|
||||||
|
- The `tabHome` l10n string already exists ("Ubersicht") — reuse it as the AppBar title for the home screen
|
||||||
|
- body: the existing Stack content
|
||||||
|
- Keep CalendarStrip, CalendarDayList, and floating Today FAB exactly as they are
|
||||||
|
- Import `sort_dropdown.dart`
|
||||||
|
- Note: HomeScreen is inside AppShell's Scaffold body. Adding a nested Scaffold is fine and standard for per-tab AppBars in StatefulShellRoute.indexedStack. The AppShell Scaffold provides the bottom nav; the inner Scaffold provides the AppBar.
|
||||||
|
|
||||||
|
3. Edit `lib/features/tasks/presentation/task_list_screen.dart`:
|
||||||
|
- In the existing `AppBar.actions` list, add `const SortDropdown()` BEFORE the edit and delete IconButtons. Order: [SortDropdown, edit, delete].
|
||||||
|
- Import `sort_dropdown.dart`
|
||||||
|
- No other changes to TaskListScreen
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter analyze --no-fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>SortDropdown widget exists showing current sort label with sort icon. HomeScreen has AppBar with title "Ubersicht" and SortDropdown. TaskListScreen AppBar has SortDropdown before edit/delete buttons. dart analyze clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Update tests for HomeScreen AppBar and sort dropdown</name>
|
||||||
|
<files>
|
||||||
|
test/features/home/presentation/home_screen_test.dart
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Edit `test/features/home/presentation/home_screen_test.dart`:
|
||||||
|
- Add import for `sort_preference_notifier.dart` and `task_sort_option.dart`
|
||||||
|
- In the `_buildApp` helper, add a provider override for `sortPreferenceProvider`:
|
||||||
|
```dart
|
||||||
|
sortPreferenceProvider.overrideWith(SortPreferenceNotifier.new),
|
||||||
|
```
|
||||||
|
This will use the real notifier with mock SharedPreferences (already set up in setUp).
|
||||||
|
- Add a new test group `'HomeScreen sort dropdown'`:
|
||||||
|
- Test: "shows sort dropdown in AppBar" — pump the app with tasks, verify `find.byType(PopupMenuButton<TaskSortOption>)` findsOneWidget
|
||||||
|
- Test: "shows AppBar with title" — verify `find.text('Ubersicht')` findsOneWidget (the tabHome l10n string)
|
||||||
|
- Verify all existing tests still pass. The addition of an AppBar wrapping the existing content should not break existing assertions since they look for specific widgets/text within the tree.
|
||||||
|
|
||||||
|
2. Run full test suite to confirm no regressions.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test && flutter analyze --no-fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Home screen tests verify AppBar with sort dropdown is present. All 108+ tests pass (106 existing + 2+ new). dart analyze clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `flutter test` — all tests pass including new sort dropdown tests
|
||||||
|
- `flutter analyze --no-fatal-infos` — zero issues
|
||||||
|
- HomeScreen has AppBar with SortDropdown visible
|
||||||
|
- TaskListScreen has SortDropdown in AppBar actions
|
||||||
|
- Tapping dropdown shows 3 options with check mark on current selection
|
||||||
|
- Selecting a different sort option reorders the task list reactively
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- SortDropdown widget is reusable and shows current sort with icon
|
||||||
|
- HomeScreen has AppBar titled "Ubersicht" with SortDropdown in trailing actions
|
||||||
|
- TaskListScreen has SortDropdown before edit/delete buttons in AppBar
|
||||||
|
- Sort selection updates task list order immediately (reactive via provider)
|
||||||
|
- Sort preference persists (set in one screen, visible in another after navigation)
|
||||||
|
- All tests pass, analyze clean
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/07-task-sorting/07-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
125
.planning/phases/07-task-sorting/07-02-SUMMARY.md
Normal file
125
.planning/phases/07-task-sorting/07-02-SUMMARY.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
phase: 07-task-sorting
|
||||||
|
plan: 02
|
||||||
|
subsystem: ui
|
||||||
|
tags: [flutter, riverpod, material3, popup-menu, sort-ui, localization]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 07-task-sorting
|
||||||
|
plan: 01
|
||||||
|
provides: sortPreferenceProvider, TaskSortOption enum, German sort l10n strings
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- SortDropdown ConsumerWidget (PopupMenuButton<TaskSortOption> with check marks)
|
||||||
|
- HomeScreen with AppBar (title: Übersicht, actions: SortDropdown)
|
||||||
|
- TaskListScreen AppBar with SortDropdown before edit/delete buttons
|
||||||
|
|
||||||
|
affects: [home_screen_test.dart, app_shell_test.dart, any screen showing HomeScreen]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "PopupMenuButton<TaskSortOption> with Opacity check mark — avoids layout shift vs conditional Icon"
|
||||||
|
- "Nested Scaffold inside AppShell tab body — standard pattern for per-tab AppBars in StatefulShellRoute"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- lib/features/tasks/presentation/sort_dropdown.dart
|
||||||
|
modified:
|
||||||
|
- lib/features/home/presentation/home_screen.dart
|
||||||
|
- lib/features/tasks/presentation/task_list_screen.dart
|
||||||
|
- test/features/home/presentation/home_screen_test.dart
|
||||||
|
- test/shell/app_shell_test.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Used PopupMenuButton instead of DropdownButton for AppBar — menu overlay vs inline expansion, consistent with Material 3 AppBar action patterns"
|
||||||
|
- "Opacity(opacity: isSelected ? 1 : 0) for check mark — preserves item width alignment vs conditional show/hide"
|
||||||
|
- "HomeScreen Scaffold is nested inside AppShell Scaffold — standard StatefulShellRoute pattern for per-tab AppBars"
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 4min
|
||||||
|
completed: 2026-03-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 07 Plan 02: Sort Dropdown UI Summary
|
||||||
|
|
||||||
|
**SortDropdown ConsumerWidget using PopupMenuButton wired into HomeScreen AppBar (title: Übersicht) and TaskListScreen AppBar before edit/delete actions**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 4 min
|
||||||
|
- **Started:** 2026-03-16T21:35:56Z
|
||||||
|
- **Completed:** 2026-03-16T21:39:24Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 5 (1 created, 4 modified)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- SortDropdown ConsumerWidget: PopupMenuButton<TaskSortOption> with sort icon, current label, and check mark on active option
|
||||||
|
- HomeScreen wrapped in Scaffold with AppBar titled "Übersicht" and SortDropdown in trailing actions
|
||||||
|
- TaskListScreen AppBar has SortDropdown before the existing edit/delete IconButtons
|
||||||
|
- 2 new tests in HomeScreen test suite: verifies PopupMenuButton and AppBar title presence
|
||||||
|
- Auto-fixed app_shell_test regression caused by "Übersicht" now appearing twice (AppBar + bottom nav)
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: SortDropdown widget and HomeScreen/TaskListScreen integration** - `e5eccb7` (feat)
|
||||||
|
2. **Task 2: Sort dropdown tests and AppShell test fix** - `a3e4d02` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `lib/features/tasks/presentation/sort_dropdown.dart` - Reusable SortDropdown ConsumerWidget with PopupMenuButton<TaskSortOption>
|
||||||
|
- `lib/features/home/presentation/home_screen.dart` - Added Scaffold with AppBar (Übersicht title + SortDropdown)
|
||||||
|
- `lib/features/tasks/presentation/task_list_screen.dart` - Added SortDropdown before edit/delete in AppBar actions
|
||||||
|
- `test/features/home/presentation/home_screen_test.dart` - Added sortPreferenceProvider override + 2 new sort dropdown tests
|
||||||
|
- `test/shell/app_shell_test.dart` - Fixed findsOneWidget -> findsWidgets for 'Übersicht' (now in AppBar + bottom nav)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- Used PopupMenuButton instead of DropdownButton for AppBar actions — menu overlay is cleaner in AppBar context (Material 3)
|
||||||
|
- Opacity trick for check mark: `Opacity(opacity: isSelected ? 1 : 0)` preserves item width so labels align regardless of selection
|
||||||
|
- HomeScreen uses nested Scaffold for AppBar — standard pattern in StatefulShellRoute.indexedStack; AppShell provides bottom nav, HomeScreen provides AppBar
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Fixed AppShell test regression from 'Übersicht' duplicate**
|
||||||
|
- **Found during:** Task 2 test run
|
||||||
|
- **Issue:** `app_shell_test.dart` expected `findsOneWidget` for 'Übersicht'. Adding the HomeScreen AppBar title caused the string to appear twice (AppBar + bottom nav label).
|
||||||
|
- **Fix:** Changed `findsOneWidget` to `findsWidgets` in `app_shell_test.dart` line 67. Applied same fix to new `home_screen_test.dart` AppBar title test.
|
||||||
|
- **Files modified:** `test/shell/app_shell_test.dart`, `test/features/home/presentation/home_screen_test.dart`
|
||||||
|
- **Commit:** `a3e4d02`
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None beyond the auto-fixed regression.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- Phase 07 (task sorting) is now complete: data layer (07-01) + UI layer (07-02)
|
||||||
|
- Sort dropdown is live in both HomeScreen and TaskListScreen AppBars
|
||||||
|
- Selecting a sort option reactively reorders task lists via sortPreferenceProvider
|
||||||
|
- Preference persists across app restarts via SharedPreferences
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 07-task-sorting*
|
||||||
|
*Completed: 2026-03-16*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: lib/features/tasks/presentation/sort_dropdown.dart
|
||||||
|
- FOUND: lib/features/home/presentation/home_screen.dart (modified)
|
||||||
|
- FOUND: lib/features/tasks/presentation/task_list_screen.dart (modified)
|
||||||
|
- FOUND: test/features/home/presentation/home_screen_test.dart (modified)
|
||||||
|
- FOUND: test/shell/app_shell_test.dart (modified)
|
||||||
|
- Commits e5eccb7, a3e4d02 verified in git log
|
||||||
|
- All 115 tests pass, dart analyze clean
|
||||||
91
.planning/phases/07-task-sorting/07-CONTEXT.md
Normal file
91
.planning/phases/07-task-sorting/07-CONTEXT.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Phase 7: Task Sorting - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-03-16
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Add sort controls to task list screens so users can reorder tasks by name (alphabetical), frequency interval, or effort level. The sort preference persists across app restarts. Requirements: SORT-01, SORT-02, SORT-03.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Sort control widget
|
||||||
|
- Dropdown button in the AppBar, right side (trailing actions position)
|
||||||
|
- When collapsed, shows the current sort name as text (e.g., "A-Z", "Intervall", "Aufwand")
|
||||||
|
- Expands to show the 3 sort options as a standard dropdown menu
|
||||||
|
|
||||||
|
### Sort option labels
|
||||||
|
- Claude's discretion — pick German labels that fit the app's existing localization style (concise but clear)
|
||||||
|
|
||||||
|
### Sort scope
|
||||||
|
- One global sort preference applies to all task list screens
|
||||||
|
- Same dropdown appears in both the home screen (CalendarDayList) and per-room (TaskListScreen) AppBars
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
- Store the sort preference in SharedPreferences (simple key-value for a single enum)
|
||||||
|
- No database schema change needed
|
||||||
|
- Persists across app restarts per success criteria
|
||||||
|
|
||||||
|
### Default sort
|
||||||
|
- Claude's discretion — pick the least disruptive default (likely alphabetical to match current CalendarDayList behavior)
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Sort option label text (German, concise)
|
||||||
|
- Default sort order (recommend alphabetical for continuity)
|
||||||
|
- Whether TaskListScreen also gets the dropdown (recommend yes for consistency with global setting, since the success criteria says "task list screens" plural)
|
||||||
|
- Sort direction (always ascending — A-Z, daily→yearly, low→high — no toggle needed for MVP)
|
||||||
|
- Dropdown styling (Material 3 DropdownButton or PopupMenuButton variant)
|
||||||
|
- Sort icon or visual indicator in the dropdown
|
||||||
|
- How overdue section interacts with sorting (recommend: overdue section stays pinned at top regardless of sort, only day tasks are sorted)
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
No specific requirements — open to standard approaches that match existing app patterns.
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `EffortLevel` enum (low/medium/high) with `.index` for ordering — directly usable for effort sort
|
||||||
|
- `IntervalType` enum with `.index` ordered roughly by frequency (daily=0 through yearly=7) — usable for interval sort
|
||||||
|
- `FrequencyInterval.presets` list ordered most-frequent to least — reference for sort order
|
||||||
|
- `Task.name` field — direct alphabetical sort target
|
||||||
|
- `CalendarDayList` and `TaskListScreen` — the two list widgets that need sort integration
|
||||||
|
- `AppLocalizations` + `.arb` files — existing German localization pipeline
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Manual `StreamProvider.autoDispose` for drift types (riverpod_generator issue) — sort provider follows same pattern
|
||||||
|
- `calendarDayProvider` watches `selectedDateProvider` — can also watch a sort preference provider
|
||||||
|
- `tasksInRoomProvider` family provider — can be extended with sort parameter or read global sort
|
||||||
|
- Feature folder structure: `features/home/`, `features/tasks/` — sort logic may live in a shared location or in each feature
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `HomeScreen` AppBar — add dropdown to trailing actions
|
||||||
|
- `TaskListScreen` AppBar — already has edit/delete actions; add dropdown alongside
|
||||||
|
- `CalendarDao.watchTasksForDate()` — currently sorts alphabetically; needs sort-aware query or in-memory sort
|
||||||
|
- `TasksDao.watchTasksInRoom()` — currently sorts by nextDueDate; needs sort-aware query or in-memory sort
|
||||||
|
- `SharedPreferences` — not yet used in the app; needs package addition and provider setup
|
||||||
|
- `app_de.arb` — add localization strings for sort labels
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 07-task-sorting*
|
||||||
|
*Context gathered: 2026-03-16*
|
||||||
135
.planning/phases/07-task-sorting/7-VERIFICATION.md
Normal file
135
.planning/phases/07-task-sorting/7-VERIFICATION.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
---
|
||||||
|
phase: 07-task-sorting
|
||||||
|
verified: 2026-03-16T22:00:00Z
|
||||||
|
status: passed
|
||||||
|
score: 9/9 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 7: Task Sorting Verification Report
|
||||||
|
|
||||||
|
**Phase 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
|
||||||
|
**Verified:** 2026-03-16T22:00:00Z
|
||||||
|
**Status:** passed
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|----|--------------------------------------------------------------------------------|------------|------------------------------------------------------------------------------------------------------|
|
||||||
|
| 1 | Sort preference persists across app restarts | VERIFIED | `SortPreferenceNotifier._loadPersisted()` reads `SharedPreferences.getString('task_sort_option')` on build; 2 restart-recovery tests pass |
|
||||||
|
| 2 | CalendarDayList tasks are sorted according to the active sort preference | VERIFIED | `calendarDayProvider` calls `ref.watch(sortPreferenceProvider)` and applies `_sortTasks(dayTasks, sortOption)` before returning `CalendarDayState` |
|
||||||
|
| 3 | TaskListScreen tasks are sorted according to the active sort preference | VERIFIED | `tasksInRoomProvider` calls `ref.watch(sortPreferenceProvider)` and applies `stream.map((tasks) => _sortTasksRaw(tasks, sortOption))` |
|
||||||
|
| 4 | Default sort is alphabetical (matches current CalendarDayList behavior) | VERIFIED | `SortPreferenceNotifier.build()` returns `TaskSortOption.alphabetical` synchronously; test "build() returns default state of alphabetical" confirms |
|
||||||
|
| 5 | A sort dropdown is visible in the HomeScreen AppBar showing the current label | VERIFIED | `HomeScreen.build()` returns `Scaffold(appBar: AppBar(actions: const [SortDropdown()]))` — wired and rendered |
|
||||||
|
| 6 | A sort dropdown is visible in the TaskListScreen AppBar | VERIFIED | `TaskListScreen.build()` AppBar actions list: `[const SortDropdown(), edit IconButton, delete IconButton]` |
|
||||||
|
| 7 | Tapping the dropdown shows three options: A-Z, Intervall, Aufwand | VERIFIED | `SortDropdown` builds `PopupMenuButton` from `TaskSortOption.values` (3 items), labels map to `l10n.sortAlphabetical/sortInterval/sortEffort` |
|
||||||
|
| 8 | Selecting a sort option updates the task list order immediately | VERIFIED | `onSelected` calls `ref.read(sortPreferenceProvider.notifier).setSortOption(value)`; providers watch `sortPreferenceProvider` and rebuild reactively |
|
||||||
|
| 9 | The sort preference persists across screen navigations and app restarts | VERIFIED | `@Riverpod(keepAlive: true)` prevents disposal during navigation; SharedPreferences stores and reloads value |
|
||||||
|
|
||||||
|
**Score:** 9/9 truths verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Artifacts
|
||||||
|
|
||||||
|
### Plan 07-01 Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `lib/features/tasks/domain/task_sort_option.dart` | `TaskSortOption` enum with alphabetical, interval, effort | VERIFIED | Exactly 3 values, comments match intent. No stubs. |
|
||||||
|
| `lib/features/tasks/presentation/sort_preference_notifier.dart` | `SortPreferenceNotifier` with SharedPreferences persistence | VERIFIED | `build()` returns `alphabetical` synchronously, `_loadPersisted()` async, `setSortOption()` sets state + persists. Pattern matches `ThemeNotifier`. |
|
||||||
|
| `lib/features/tasks/presentation/sort_preference_notifier.g.dart` | Generated Riverpod provider file | VERIFIED | Generated correctly; `sortPreferenceProvider` declared as `SortPreferenceNotifierProvider._()` with `isAutoDispose: false` (keepAlive). |
|
||||||
|
| `lib/features/home/presentation/calendar_providers.dart` | `calendarDayProvider` sorts `dayTasks` by active sort preference | VERIFIED | `ref.watch(sortPreferenceProvider)` present. `_sortTasks()` helper implements all 3 sort modes. `overdueTasks` intentionally unsorted. |
|
||||||
|
| `lib/features/tasks/presentation/task_providers.dart` | `tasksInRoomProvider` sorts tasks by active sort preference | VERIFIED | `ref.watch(sortPreferenceProvider)` present. `_sortTasksRaw()` helper + `stream.map()` applied correctly. |
|
||||||
|
| `test/features/tasks/presentation/sort_preference_notifier_test.dart` | Unit tests for sort preference persistence and default | VERIFIED | 7 tests: default alphabetical, setSortOption interval, setSortOption effort, persist to SharedPreferences, restart recovery (effort), restart recovery (interval), unknown value fallback. |
|
||||||
|
|
||||||
|
### Plan 07-02 Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `lib/features/tasks/presentation/sort_dropdown.dart` | Reusable `SortDropdown` `ConsumerWidget` | VERIFIED | `ConsumerWidget`, `PopupMenuButton<TaskSortOption>`, Opacity check mark pattern, `ref.watch` for display, `ref.read` for mutation, `_label()` helper. |
|
||||||
|
| `lib/features/home/presentation/home_screen.dart` | HomeScreen with AppBar containing `SortDropdown` | VERIFIED | `Scaffold(appBar: AppBar(title: Text(l10n.tabHome), actions: const [SortDropdown()]))`. Existing Stack body preserved. |
|
||||||
|
| `lib/features/tasks/presentation/task_list_screen.dart` | TaskListScreen AppBar with `SortDropdown` before edit/delete | VERIFIED | `actions: [const SortDropdown(), IconButton(edit), IconButton(delete)]`. Correct order. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Link Verification
|
||||||
|
|
||||||
|
### Plan 07-01 Key Links
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `calendar_providers.dart` | `sortPreferenceProvider` | `ref.watch(sortPreferenceProvider)` in `calendarDayProvider` | WIRED | Line 77: `final sortOption = ref.watch(sortPreferenceProvider);`. Applied at line 101: `dayTasks: _sortTasks(dayTasks, sortOption)`. |
|
||||||
|
| `task_providers.dart` | `sortPreferenceProvider` | `ref.watch(sortPreferenceProvider)` in `tasksInRoomProvider` | WIRED | Line 43: `final sortOption = ref.watch(sortPreferenceProvider);`. Applied at lines 44-46 via `stream.map`. |
|
||||||
|
|
||||||
|
### Plan 07-02 Key Links
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `sort_dropdown.dart` | `sortPreferenceProvider` | `ref.watch` for display, `ref.read` for mutation | WIRED | Line 21: `ref.watch(sortPreferenceProvider)`. Line 27: `ref.read(sortPreferenceProvider.notifier).setSortOption(value)`. |
|
||||||
|
| `home_screen.dart` | `sort_dropdown.dart` | `SortDropdown` widget in AppBar actions | WIRED | Import on line 7. Used in `AppBar(actions: const [SortDropdown()])` on line 37. |
|
||||||
|
| `task_list_screen.dart` | `sort_dropdown.dart` | `SortDropdown` widget in AppBar actions | WIRED | Import on line 7. Used in `actions: [const SortDropdown(), ...]` on line 31. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|-------------|-------------|--------|----------|
|
||||||
|
| SORT-01 | 07-01, 07-02 | User can sort tasks alphabetically | SATISFIED | `TaskSortOption.alphabetical` is the default. `_sortTasks()` and `_sortTasksRaw()` implement case-insensitive A-Z sort. `SortDropdown` displays "A–Z" label (l10n). |
|
||||||
|
| SORT-02 | 07-01, 07-02 | User can sort tasks by frequency interval | SATISFIED | `TaskSortOption.interval` sort implemented: `intervalType.index` ascending with `intervalDays` tiebreaker. Displayed as "Intervall" in `SortDropdown`. |
|
||||||
|
| SORT-03 | 07-01, 07-02 | User can sort tasks by effort level | SATISFIED | `TaskSortOption.effort` sort implemented: `effortLevel.index` ascending (low=0, medium=1, high=2). Displayed as "Aufwand" in `SortDropdown`. |
|
||||||
|
|
||||||
|
No orphaned requirements. All three SORT requirements are claimed by both plans and fully implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns Found
|
||||||
|
|
||||||
|
None. All seven implementation files scanned — no TODO, FIXME, XXX, HACK, PLACEHOLDER, return null, return {}, return [], or empty arrow functions found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Human Verification Required
|
||||||
|
|
||||||
|
### 1. Visual check: Sort dropdown appearance in AppBar
|
||||||
|
|
||||||
|
**Test:** Launch the app, navigate to HomeScreen. Verify the AppBar shows a sort icon (Icons.sort) followed by the current sort label text "A–Z".
|
||||||
|
**Expected:** Sort icon and "A–Z" text visible in the top-right AppBar area.
|
||||||
|
**Why human:** Widget rendering and visual layout cannot be verified programmatically.
|
||||||
|
|
||||||
|
### 2. Popup menu interaction: Check marks on active option
|
||||||
|
|
||||||
|
**Test:** Tap the sort dropdown, verify three items appear with a check mark next to the currently selected option and no check mark on the other two.
|
||||||
|
**Expected:** Check mark visible on "A–Z" (default), invisible (but space-preserving) on "Intervall" and "Aufwand".
|
||||||
|
**Why human:** Opacity(0) vs Opacity(1) rendering and visual alignment cannot be verified with grep.
|
||||||
|
|
||||||
|
### 3. Reactive reorder on selection
|
||||||
|
|
||||||
|
**Test:** With tasks loaded in HomeScreen, tap the sort dropdown and select "Aufwand". Verify the task list reorders immediately without a page reload.
|
||||||
|
**Expected:** Task list updates instantly, sorted low-effort first.
|
||||||
|
**Why human:** Real-time Riverpod reactive rebuild requires a running app to observe.
|
||||||
|
|
||||||
|
### 4. Cross-screen persistence of sort preference
|
||||||
|
|
||||||
|
**Test:** Select "Intervall" in HomeScreen, then navigate to a room's TaskListScreen. Verify the sort dropdown there also shows "Intervall".
|
||||||
|
**Expected:** Sort preference is shared across screens (same `sortPreferenceProvider`, `keepAlive: true`).
|
||||||
|
**Why human:** Cross-screen navigation state cannot be verified statically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gaps Summary
|
||||||
|
|
||||||
|
None. All 9 observable truths verified. All artifacts exist, are substantive, and are correctly wired. All 3 requirements (SORT-01, SORT-02, SORT-03) are fully satisfied. All 5 commits (a9f2983, 13c7d62, 3697e4e, e5eccb7, a3e4d02) confirmed present in git log. No anti-patterns detected in implementation files.
|
||||||
|
|
||||||
|
The phase delivers its stated goal: users can reorder task lists by name (A–Z), frequency interval, or effort level via a persistent, reactive sort preference accessible from both HomeScreen and TaskListScreen AppBars.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-16T22:00:00Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -31,25 +34,25 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
def keystorePropertiesFile = rootProject.file("key.properties")
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
def keystoreProperties = new Properties()
|
val keystoreProperties = Properties()
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
release {
|
create("release") {
|
||||||
keyAlias = keystoreProperties['keyAlias']
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
keyPassword = keystoreProperties['keyPassword']
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
storeFile = keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
storeFile = keystoreProperties.getProperty("storeFile")?.let { file(it) }
|
||||||
storePassword = keystoreProperties['storePassword']
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.release
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.jlmak.household_keeper
|
package de.jeanlucmakiola.household_keeper
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../features/home/data/calendar_dao.dart';
|
||||||
import '../../features/home/data/daily_plan_dao.dart';
|
import '../../features/home/data/daily_plan_dao.dart';
|
||||||
import '../../features/rooms/data/rooms_dao.dart';
|
import '../../features/rooms/data/rooms_dao.dart';
|
||||||
import '../../features/tasks/data/tasks_dao.dart';
|
import '../../features/tasks/data/tasks_dao.dart';
|
||||||
@@ -45,7 +46,7 @@ class TaskCompletions extends Table {
|
|||||||
|
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
tables: [Rooms, Tasks, TaskCompletions],
|
tables: [Rooms, Tasks, TaskCompletions],
|
||||||
daos: [RoomsDao, TasksDao, DailyPlanDao],
|
daos: [RoomsDao, TasksDao, DailyPlanDao, CalendarDao],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase([QueryExecutor? executor])
|
AppDatabase([QueryExecutor? executor])
|
||||||
|
|||||||
@@ -1246,6 +1246,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
|||||||
late final RoomsDao roomsDao = RoomsDao(this as AppDatabase);
|
late final RoomsDao roomsDao = RoomsDao(this as AppDatabase);
|
||||||
late final TasksDao tasksDao = TasksDao(this as AppDatabase);
|
late final TasksDao tasksDao = TasksDao(this as AppDatabase);
|
||||||
late final DailyPlanDao dailyPlanDao = DailyPlanDao(this as AppDatabase);
|
late final DailyPlanDao dailyPlanDao = DailyPlanDao(this as AppDatabase);
|
||||||
|
late final CalendarDao calendarDao = CalendarDao(this as AppDatabase);
|
||||||
@override
|
@override
|
||||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
|
|||||||
87
lib/features/home/data/calendar_dao.dart
Normal file
87
lib/features/home/data/calendar_dao.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../../../core/database/database.dart';
|
||||||
|
import '../domain/daily_plan_models.dart';
|
||||||
|
|
||||||
|
part 'calendar_dao.g.dart';
|
||||||
|
|
||||||
|
/// DAO for calendar-based task queries.
|
||||||
|
///
|
||||||
|
/// Provides date-parameterized queries to answer:
|
||||||
|
/// - "What tasks are due on date X?"
|
||||||
|
/// - "What tasks are overdue relative to today?"
|
||||||
|
@DriftAccessor(tables: [Tasks, Rooms, TaskCompletions])
|
||||||
|
class CalendarDao extends DatabaseAccessor<AppDatabase>
|
||||||
|
with _$CalendarDaoMixin {
|
||||||
|
CalendarDao(super.attachedDatabase);
|
||||||
|
|
||||||
|
/// Watch tasks whose [nextDueDate] falls on the given calendar day.
|
||||||
|
///
|
||||||
|
/// Returns tasks sorted alphabetically by name.
|
||||||
|
/// Does NOT include overdue carry-over — only tasks originally due on [date].
|
||||||
|
Stream<List<TaskWithRoom>> watchTasksForDate(DateTime date) {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total count of tasks across all rooms and dates.
|
||||||
|
///
|
||||||
|
/// Used by the UI to distinguish first-run empty state from celebration state.
|
||||||
|
Future<int> getTaskCount() async {
|
||||||
|
final countExp = tasks.id.count();
|
||||||
|
final query = selectOnly(tasks)..addColumns([countExp]);
|
||||||
|
final result = await query.getSingle();
|
||||||
|
return result.read(countExp) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch tasks whose [nextDueDate] is strictly before [referenceDate].
|
||||||
|
///
|
||||||
|
/// Returns tasks sorted by [nextDueDate] ascending (oldest first).
|
||||||
|
/// Does NOT include tasks due on [referenceDate] itself.
|
||||||
|
Stream<List<TaskWithRoom>> watchOverdueTasks(DateTime referenceDate) {
|
||||||
|
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));
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/features/home/data/calendar_dao.g.dart
Normal file
25
lib/features/home/data/calendar_dao.g.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'calendar_dao.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
mixin _$CalendarDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||||
|
$RoomsTable get rooms => attachedDatabase.rooms;
|
||||||
|
$TasksTable get tasks => attachedDatabase.tasks;
|
||||||
|
$TaskCompletionsTable get taskCompletions => attachedDatabase.taskCompletions;
|
||||||
|
CalendarDaoManager get managers => CalendarDaoManager(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CalendarDaoManager {
|
||||||
|
final _$CalendarDaoMixin _db;
|
||||||
|
CalendarDaoManager(this._db);
|
||||||
|
$$RoomsTableTableManager get rooms =>
|
||||||
|
$$RoomsTableTableManager(_db.attachedDatabase, _db.rooms);
|
||||||
|
$$TasksTableTableManager get tasks =>
|
||||||
|
$$TasksTableTableManager(_db.attachedDatabase, _db.tasks);
|
||||||
|
$$TaskCompletionsTableTableManager get taskCompletions =>
|
||||||
|
$$TaskCompletionsTableTableManager(
|
||||||
|
_db.attachedDatabase,
|
||||||
|
_db.taskCompletions,
|
||||||
|
);
|
||||||
|
}
|
||||||
25
lib/features/home/domain/calendar_models.dart
Normal file
25
lib/features/home/domain/calendar_models.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
|
||||||
|
/// State for the calendar day view: tasks for the selected date + overdue tasks.
|
||||||
|
class CalendarDayState {
|
||||||
|
final DateTime selectedDate;
|
||||||
|
final List<TaskWithRoom> dayTasks;
|
||||||
|
final List<TaskWithRoom> overdueTasks;
|
||||||
|
|
||||||
|
/// Total number of tasks in the database (across all days/rooms).
|
||||||
|
/// Used by the UI to distinguish first-run empty state (no tasks exist at all)
|
||||||
|
/// from celebration state (tasks exist but today's are all done).
|
||||||
|
final int totalTaskCount;
|
||||||
|
|
||||||
|
const CalendarDayState({
|
||||||
|
required this.selectedDate,
|
||||||
|
required this.dayTasks,
|
||||||
|
required this.overdueTasks,
|
||||||
|
required this.totalTaskCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True when both day tasks and overdue tasks are empty.
|
||||||
|
/// Determined by the UI layer (completion state vs. no tasks at all
|
||||||
|
/// is handled in the widget based on this flag and history context).
|
||||||
|
bool get isEmpty => dayTasks.isEmpty && overdueTasks.isEmpty;
|
||||||
|
}
|
||||||
310
lib/features/home/presentation/calendar_day_list.dart
Normal file
310
lib/features/home/presentation/calendar_day_list.dart
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
||||||
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
|
import 'package:household_keeper/features/home/presentation/calendar_task_row.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/presentation/task_providers.dart';
|
||||||
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Warm coral/terracotta color for overdue section header.
|
||||||
|
const _overdueColor = Color(0xFFE07A5F);
|
||||||
|
|
||||||
|
/// Shows the task list for the selected calendar day.
|
||||||
|
///
|
||||||
|
/// Watches [calendarDayProvider] 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
|
||||||
|
/// - Empty day state (tasks exist elsewhere but not this day)
|
||||||
|
/// - 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});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CalendarDayList> createState() => _CalendarDayListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CalendarDayListState extends ConsumerState<CalendarDayList> {
|
||||||
|
/// Task IDs currently animating out after completion.
|
||||||
|
final Set<int> _completingTaskIds = {};
|
||||||
|
|
||||||
|
void _onTaskCompleted(int taskId) {
|
||||||
|
setState(() {
|
||||||
|
_completingTaskIds.add(taskId);
|
||||||
|
});
|
||||||
|
ref.read(taskActionsProvider.notifier).completeTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final dayState = ref.watch(calendarDayProvider);
|
||||||
|
|
||||||
|
return dayState.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => Center(child: Text(error.toString())),
|
||||||
|
data: (state) {
|
||||||
|
// Clean up animation IDs for tasks that are no longer in the data.
|
||||||
|
_completingTaskIds.removeWhere((id) =>
|
||||||
|
!state.overdueTasks.any((t) => t.task.id == id) &&
|
||||||
|
!state.dayTasks.any((t) => t.task.id == id));
|
||||||
|
|
||||||
|
return _buildContent(context, state, l10n, theme);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(
|
||||||
|
BuildContext context,
|
||||||
|
CalendarDayState state,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
ThemeData theme,
|
||||||
|
) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final isToday = state.selectedDate == today;
|
||||||
|
|
||||||
|
// State (a): First-run empty — no tasks exist at all in the database.
|
||||||
|
if (state.isEmpty && state.totalTaskCount == 0) {
|
||||||
|
return _buildFirstRunEmpty(context, l10n, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (e): Celebration — today is selected and all tasks are done
|
||||||
|
// (totalTaskCount > 0 so at least some task exists somewhere, but today
|
||||||
|
// has none remaining after completion).
|
||||||
|
if (isToday && state.dayTasks.isEmpty && state.overdueTasks.isEmpty && state.totalTaskCount > 0) {
|
||||||
|
return _buildCelebration(l10n, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (d): Empty day — tasks exist elsewhere but not this day.
|
||||||
|
if (state.isEmpty) {
|
||||||
|
return _buildEmptyDay(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (f): Has tasks — render overdue section (today only) + day tasks.
|
||||||
|
return _buildTaskList(state, l10n, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First-run: no rooms/tasks created yet.
|
||||||
|
Widget _buildFirstRunEmpty(
|
||||||
|
BuildContext context,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
ThemeData theme,
|
||||||
|
) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.checklist_rounded,
|
||||||
|
size: 80,
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
l10n.dailyPlanNoTasks,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.homeEmptyMessage,
|
||||||
|
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'),
|
||||||
|
child: Text(l10n.homeEmptyAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Celebration state: today is selected and all tasks are done.
|
||||||
|
Widget _buildCelebration(AppLocalizations l10n, ThemeData theme) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.celebration_outlined,
|
||||||
|
size: 80,
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
l10n.dailyPlanAllClearTitle,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.dailyPlanAllClearMessage,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty day: tasks exist elsewhere but nothing scheduled for this day.
|
||||||
|
Widget _buildEmptyDay(ThemeData theme) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.event_available,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Keine Aufgaben',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Task list with optional overdue section.
|
||||||
|
Widget _buildTaskList(
|
||||||
|
CalendarDayState state,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
ThemeData theme,
|
||||||
|
) {
|
||||||
|
final items = <Widget>[];
|
||||||
|
|
||||||
|
// Overdue section (today only, when overdue tasks exist).
|
||||||
|
if (state.overdueTasks.isNotEmpty) {
|
||||||
|
items.add(_buildSectionHeader(l10n.dailyPlanSectionOverdue, theme,
|
||||||
|
color: _overdueColor));
|
||||||
|
for (final tw in state.overdueTasks) {
|
||||||
|
items.add(_buildAnimatedTaskRow(tw, isOverdue: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day tasks section.
|
||||||
|
for (final tw in state.dayTasks) {
|
||||||
|
items.add(_buildAnimatedTaskRow(tw, isOverdue: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(children: items);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(
|
||||||
|
String title,
|
||||||
|
ThemeData theme, {
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnimatedTaskRow(TaskWithRoom tw, {required bool isOverdue}) {
|
||||||
|
final isCompleting = _completingTaskIds.contains(tw.task.id);
|
||||||
|
|
||||||
|
if (isCompleting) {
|
||||||
|
return _CompletingTaskRow(
|
||||||
|
key: ValueKey('completing-${tw.task.id}'),
|
||||||
|
taskWithRoom: tw,
|
||||||
|
isOverdue: isOverdue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CalendarTaskRow(
|
||||||
|
key: ValueKey('task-${tw.task.id}'),
|
||||||
|
taskWithRoom: tw,
|
||||||
|
isOverdue: isOverdue,
|
||||||
|
onCompleted: () => _onTaskCompleted(tw.task.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A task row that animates (slide + size) to zero height on completion.
|
||||||
|
class _CompletingTaskRow extends StatefulWidget {
|
||||||
|
const _CompletingTaskRow({
|
||||||
|
super.key,
|
||||||
|
required this.taskWithRoom,
|
||||||
|
required this.isOverdue,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TaskWithRoom taskWithRoom;
|
||||||
|
final bool isOverdue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CompletingTaskRow> createState() => _CompletingTaskRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompletingTaskRowState extends State<_CompletingTaskRow>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<double> _sizeAnimation;
|
||||||
|
late final Animation<Offset> _slideAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_sizeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
|
_slideAnimation = Tween<Offset>(
|
||||||
|
begin: Offset.zero,
|
||||||
|
end: const Offset(1.0, 0.0),
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizeTransition(
|
||||||
|
sizeFactor: _sizeAnimation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _slideAnimation,
|
||||||
|
child: CalendarTaskRow(
|
||||||
|
taskWithRoom: widget.taskWithRoom,
|
||||||
|
isOverdue: widget.isOverdue,
|
||||||
|
onCompleted: () {}, // Already completing — ignore repeat taps.
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/features/home/presentation/calendar_providers.dart
Normal file
106
lib/features/home/presentation/calendar_providers.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/core/providers/database_provider.dart';
|
||||||
|
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/domain/task_sort_option.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/presentation/sort_preference_notifier.dart';
|
||||||
|
|
||||||
|
/// Notifier that manages the currently selected date in the calendar strip.
|
||||||
|
///
|
||||||
|
/// Defaults to today (start of day, time zeroed out).
|
||||||
|
/// NOT autoDispose — the selected date persists while the app is alive.
|
||||||
|
class SelectedDateNotifier extends Notifier<DateTime> {
|
||||||
|
@override
|
||||||
|
DateTime build() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return DateTime(now.year, now.month, now.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the selected date (always normalized to start of day).
|
||||||
|
void selectDate(DateTime date) {
|
||||||
|
state = DateTime(date.year, date.month, date.day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider for the currently selected date in the calendar strip.
|
||||||
|
final selectedDateProvider =
|
||||||
|
NotifierProvider<SelectedDateNotifier, DateTime>(
|
||||||
|
SelectedDateNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Sort a list of [TaskWithRoom] by the given [sortOption].
|
||||||
|
///
|
||||||
|
/// Returns a new sorted list; never mutates the original.
|
||||||
|
/// Only [dayTasks] are sorted — the overdue section stays in its existing
|
||||||
|
/// order per user decision.
|
||||||
|
List<TaskWithRoom> _sortTasks(
|
||||||
|
List<TaskWithRoom> tasks,
|
||||||
|
TaskSortOption sortOption,
|
||||||
|
) {
|
||||||
|
final sorted = List<TaskWithRoom>.from(tasks);
|
||||||
|
switch (sortOption) {
|
||||||
|
case TaskSortOption.alphabetical:
|
||||||
|
sorted.sort((a, b) => a.task.name.toLowerCase().compareTo(
|
||||||
|
b.task.name.toLowerCase(),
|
||||||
|
));
|
||||||
|
case TaskSortOption.interval:
|
||||||
|
sorted.sort((a, b) {
|
||||||
|
final cmp = a.task.intervalType.index.compareTo(
|
||||||
|
b.task.intervalType.index,
|
||||||
|
);
|
||||||
|
if (cmp != 0) return cmp;
|
||||||
|
return a.task.intervalDays.compareTo(b.task.intervalDays);
|
||||||
|
});
|
||||||
|
case TaskSortOption.effort:
|
||||||
|
sorted.sort((a, b) => a.task.effortLevel.index.compareTo(
|
||||||
|
b.task.effortLevel.index,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reactive calendar day state: tasks for the selected date + overdue tasks.
|
||||||
|
///
|
||||||
|
/// Overdue tasks are only included when the selected date is today.
|
||||||
|
/// Past and future dates show only tasks originally due on that day.
|
||||||
|
///
|
||||||
|
/// dayTasks are sorted in-memory according to the active [sortPreferenceProvider].
|
||||||
|
/// overdueTasks retain their existing order (pinned at top, unsorted per design).
|
||||||
|
///
|
||||||
|
/// Defined manually (not @riverpod) because riverpod_generator has trouble
|
||||||
|
/// with drift's generated [Task] type. Same pattern as [dailyPlanProvider].
|
||||||
|
final calendarDayProvider =
|
||||||
|
StreamProvider.autoDispose<CalendarDayState>((ref) {
|
||||||
|
final db = ref.watch(appDatabaseProvider);
|
||||||
|
final selectedDate = ref.watch(selectedDateProvider);
|
||||||
|
final sortOption = ref.watch(sortPreferenceProvider);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final isToday = selectedDate == today;
|
||||||
|
|
||||||
|
final dayTasksStream = db.calendarDao.watchTasksForDate(selectedDate);
|
||||||
|
|
||||||
|
return dayTasksStream.asyncMap((dayTasks) async {
|
||||||
|
final List<TaskWithRoom> overdueTasks;
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
// When viewing today, include overdue tasks (due before today)
|
||||||
|
overdueTasks =
|
||||||
|
await db.calendarDao.watchOverdueTasks(selectedDate).first;
|
||||||
|
} else {
|
||||||
|
// Past or future dates: no overdue carry-over
|
||||||
|
overdueTasks = const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final totalTaskCount = await db.calendarDao.getTaskCount();
|
||||||
|
|
||||||
|
return CalendarDayState(
|
||||||
|
selectedDate: selectedDate,
|
||||||
|
dayTasks: _sortTasks(dayTasks, sortOption),
|
||||||
|
overdueTasks: overdueTasks,
|
||||||
|
totalTaskCount: totalTaskCount,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
348
lib/features/home/presentation/calendar_strip.dart
Normal file
348
lib/features/home/presentation/calendar_strip.dart
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
|
|
||||||
|
/// Number of days in the past and future to show in the strip.
|
||||||
|
const _kPastDays = 90;
|
||||||
|
const _kFutureDays = 90;
|
||||||
|
|
||||||
|
/// Total number of day cards in the strip.
|
||||||
|
const _kTotalDays = _kPastDays + 1 + _kFutureDays;
|
||||||
|
|
||||||
|
/// Fixed card width and height for each day card.
|
||||||
|
const _kCardWidth = 56.0;
|
||||||
|
const _kCardHeight = 72.0;
|
||||||
|
|
||||||
|
/// Default horizontal margin between cards.
|
||||||
|
const _kCardMargin = 4.0;
|
||||||
|
|
||||||
|
/// Wider gap inserted at month boundaries (left side margin of the first-of-month card).
|
||||||
|
const _kMonthBoundaryGap = 16.0;
|
||||||
|
|
||||||
|
/// Controller that allows external code (e.g. the Today button) to trigger
|
||||||
|
/// a scroll-to-today animation on the strip.
|
||||||
|
class CalendarStripController {
|
||||||
|
VoidCallback? _scrollToToday;
|
||||||
|
|
||||||
|
/// Animate the strip to center today's card.
|
||||||
|
void scrollToToday() => _scrollToToday?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal scrollable strip of day cards spanning [_kPastDays] days in the
|
||||||
|
/// past and [_kFutureDays] days in the future.
|
||||||
|
///
|
||||||
|
/// Each card shows:
|
||||||
|
/// - German day abbreviation (Mo, Di, Mi, Do, Fr, Sa, So)
|
||||||
|
/// - Date number (day of month)
|
||||||
|
///
|
||||||
|
/// The selected card is highlighted and always centered.
|
||||||
|
/// Today's card uses bold text + an accent underline bar.
|
||||||
|
/// Month boundaries get a wider gap and a small month label.
|
||||||
|
class CalendarStrip extends ConsumerStatefulWidget {
|
||||||
|
const CalendarStrip({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.onTodayVisibilityChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Controller for programmatic scroll-to-today.
|
||||||
|
final CalendarStripController controller;
|
||||||
|
|
||||||
|
/// Called when today's card enters or leaves the viewport.
|
||||||
|
final ValueChanged<bool> onTodayVisibilityChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CalendarStrip> createState() => _CalendarStripState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CalendarStripState extends ConsumerState<CalendarStrip> {
|
||||||
|
late final ScrollController _scrollController;
|
||||||
|
late final DateTime _today;
|
||||||
|
late final List<DateTime> _dates;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
_today = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
|
// Build the date list: _kPastDays before today, today, _kFutureDays after.
|
||||||
|
_dates = List.generate(
|
||||||
|
_kTotalDays,
|
||||||
|
(i) => _today.subtract(Duration(days: _kPastDays - i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate initial scroll offset so today's card is centered.
|
||||||
|
_scrollController = ScrollController(
|
||||||
|
initialScrollOffset: _offsetForIndex(_kPastDays),
|
||||||
|
);
|
||||||
|
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
// Register the scroll-to-today callback on the controller.
|
||||||
|
widget.controller._scrollToToday = _animateToToday;
|
||||||
|
|
||||||
|
// After first frame, animate to center today with a short delay so the
|
||||||
|
// strip has laid out its children.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_animateToToday();
|
||||||
|
// Initial visibility check
|
||||||
|
_onScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the scroll offset that centers the card at [index].
|
||||||
|
double _offsetForIndex(int index) {
|
||||||
|
// Sum the widths of all items before [index], then subtract half the viewport
|
||||||
|
// width so the card is centered. We approximate viewport as screen width
|
||||||
|
// because we cannot access it here; we compensate in the post-frame callback.
|
||||||
|
double offset = 0;
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
offset += _itemWidth(i);
|
||||||
|
}
|
||||||
|
// Center by subtracting half the card container width (will be corrected post-frame).
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total width occupied by the item at [index], including margins
|
||||||
|
/// and any month-boundary gap on its left side.
|
||||||
|
double _itemWidth(int index) {
|
||||||
|
final date = _dates[index];
|
||||||
|
final leftMargin = _isFirstOfMonth(date) && index > 0
|
||||||
|
? _kMonthBoundaryGap
|
||||||
|
: _kCardMargin;
|
||||||
|
// Each item = leftMargin + card width + rightMargin
|
||||||
|
return leftMargin + _kCardWidth + _kCardMargin;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isFirstOfMonth(DateTime date) => date.day == 1;
|
||||||
|
|
||||||
|
void _animateToToday() {
|
||||||
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
|
final viewportWidth = _scrollController.position.viewportDimension;
|
||||||
|
double targetOffset = 0;
|
||||||
|
for (int i = 0; i < _kPastDays; i++) {
|
||||||
|
targetOffset += _itemWidth(i);
|
||||||
|
}
|
||||||
|
// Center today's card in the viewport.
|
||||||
|
targetOffset -= (viewportWidth - _kCardWidth) / 2;
|
||||||
|
targetOffset = targetOffset.clamp(
|
||||||
|
_scrollController.position.minScrollExtent,
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
_scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _animateToIndex(int index) {
|
||||||
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
|
final viewportWidth = _scrollController.position.viewportDimension;
|
||||||
|
double targetOffset = 0;
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
targetOffset += _itemWidth(i);
|
||||||
|
}
|
||||||
|
targetOffset -= (viewportWidth - _kCardWidth) / 2;
|
||||||
|
targetOffset = targetOffset.clamp(
|
||||||
|
_scrollController.position.minScrollExtent,
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
_scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
|
final viewportWidth = _scrollController.position.viewportDimension;
|
||||||
|
final scrollOffset = _scrollController.offset;
|
||||||
|
|
||||||
|
// Calculate the left edge of today's card.
|
||||||
|
double todayLeftEdge = 0;
|
||||||
|
for (int i = 0; i < _kPastDays; i++) {
|
||||||
|
todayLeftEdge += _itemWidth(i);
|
||||||
|
}
|
||||||
|
final todayRightEdge = todayLeftEdge + _kCardWidth;
|
||||||
|
|
||||||
|
// Today is visible if any part of the card is in the viewport.
|
||||||
|
final isVisible =
|
||||||
|
todayRightEdge > scrollOffset &&
|
||||||
|
todayLeftEdge < scrollOffset + viewportWidth;
|
||||||
|
|
||||||
|
widget.onTodayVisibilityChanged(isVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCardTapped(int index) {
|
||||||
|
final tappedDate = _dates[index];
|
||||||
|
ref.read(selectedDateProvider.notifier).selectDate(tappedDate);
|
||||||
|
_animateToIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final selectedDate = ref.watch(selectedDateProvider);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: _kCardHeight + 24, // extra height for month label
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: _kTotalDays,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final date = _dates[index];
|
||||||
|
final isToday = date == _today;
|
||||||
|
final isSelected = date == selectedDate;
|
||||||
|
final isFirstOfMonth = _isFirstOfMonth(date) && index > 0;
|
||||||
|
|
||||||
|
return _DayCardItem(
|
||||||
|
date: date,
|
||||||
|
isToday: isToday,
|
||||||
|
isSelected: isSelected,
|
||||||
|
isFirstOfMonth: isFirstOfMonth,
|
||||||
|
onTap: () => _onCardTapped(index),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single day card in the calendar strip, with optional month boundary label.
|
||||||
|
class _DayCardItem extends StatelessWidget {
|
||||||
|
const _DayCardItem({
|
||||||
|
required this.date,
|
||||||
|
required this.isToday,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.isFirstOfMonth,
|
||||||
|
required this.onTap,
|
||||||
|
required this.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime date;
|
||||||
|
final bool isToday;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool isFirstOfMonth;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final leftMargin = isFirstOfMonth ? _kMonthBoundaryGap : _kCardMargin;
|
||||||
|
|
||||||
|
// Card background color: selected gets full primaryContainer, others get
|
||||||
|
// a subtle tint of primaryContainer.
|
||||||
|
final bgColor = isSelected
|
||||||
|
? theme.colorScheme.primaryContainer
|
||||||
|
: theme.colorScheme.primaryContainer.withValues(alpha: 0.3);
|
||||||
|
|
||||||
|
// Border: selected card gets a primary color border.
|
||||||
|
final border = isSelected
|
||||||
|
? Border.all(color: theme.colorScheme.primary, width: 1.5)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Text weight: today uses bold.
|
||||||
|
final fontWeight = isToday ? FontWeight.bold : FontWeight.normal;
|
||||||
|
|
||||||
|
// Day abbreviation (German locale): Mo, Di, Mi, Do, Fr, Sa, So
|
||||||
|
final dayAbbr = DateFormat('E', 'de').format(date);
|
||||||
|
// Date number
|
||||||
|
final dayNum = date.day.toString();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Month label at boundary
|
||||||
|
if (isFirstOfMonth)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(left: leftMargin),
|
||||||
|
child: SizedBox(
|
||||||
|
width: _kCardWidth + _kCardMargin,
|
||||||
|
child: Text(
|
||||||
|
DateFormat('MMM', 'de').format(date),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 16), // Reserve space for month label row
|
||||||
|
|
||||||
|
// Day card
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: _kCardWidth,
|
||||||
|
height: _kCardHeight,
|
||||||
|
margin: EdgeInsets.only(left: leftMargin, right: _kCardMargin),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: border,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// German day abbreviation
|
||||||
|
Text(
|
||||||
|
dayAbbr,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Date number
|
||||||
|
Text(
|
||||||
|
dayNum,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Today accent underline bar
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (isToday)
|
||||||
|
Container(
|
||||||
|
width: 20,
|
||||||
|
height: 2,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
lib/features/home/presentation/calendar_task_row.dart
Normal file
72
lib/features/home/presentation/calendar_task_row.dart
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
|
||||||
|
/// Warm coral/terracotta color for overdue task name text.
|
||||||
|
const _overdueColor = Color(0xFFE07A5F);
|
||||||
|
|
||||||
|
/// A task row adapted for the calendar day list.
|
||||||
|
///
|
||||||
|
/// Shows task name, a tappable room tag (navigates to room task list),
|
||||||
|
/// and an interactive checkbox. Does NOT show a relative date — the
|
||||||
|
/// calendar strip already communicates which day is selected.
|
||||||
|
///
|
||||||
|
/// When [isOverdue] is true the task name uses coral text to visually
|
||||||
|
/// distinguish overdue carry-over from today's regular tasks.
|
||||||
|
class CalendarTaskRow extends StatelessWidget {
|
||||||
|
const CalendarTaskRow({
|
||||||
|
super.key,
|
||||||
|
required this.taskWithRoom,
|
||||||
|
required this.onCompleted,
|
||||||
|
this.isOverdue = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TaskWithRoom taskWithRoom;
|
||||||
|
|
||||||
|
/// Called when the user checks the checkbox.
|
||||||
|
final VoidCallback onCompleted;
|
||||||
|
|
||||||
|
/// When true, task name is rendered in coral color.
|
||||||
|
final bool isOverdue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final task = taskWithRoom.task;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
onTap: () => context.go(
|
||||||
|
'/rooms/${taskWithRoom.roomId}/tasks/${taskWithRoom.task.id}',
|
||||||
|
),
|
||||||
|
leading: Checkbox(
|
||||||
|
value: false,
|
||||||
|
onChanged: (_) => onCompleted(),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
task.name,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: isOverdue ? _overdueColor : null,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_day_list.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_task_row.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_strip.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/progress_card.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/l10n/app_localizations.dart';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Warm coral/terracotta color for overdue section header.
|
/// The app's primary screen: a horizontal calendar strip at the top with a
|
||||||
const _overdueColor = Color(0xFFE07A5F);
|
/// day task list below.
|
||||||
|
|
||||||
/// The app's primary screen: daily plan showing what's due today,
|
|
||||||
/// overdue tasks, and a preview of tomorrow.
|
|
||||||
///
|
///
|
||||||
/// Replaces the former placeholder with a full daily workflow:
|
/// Replaces the former stacked overdue/today/tomorrow daily plan layout.
|
||||||
/// see what's due, check it off, feel progress.
|
/// Users navigate by tapping day cards to see that day's tasks.
|
||||||
class HomeScreen extends ConsumerStatefulWidget {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@@ -25,365 +20,57 @@ class HomeScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||||
/// Task IDs currently animating out after completion.
|
late final CalendarStripController _stripController =
|
||||||
final Set<int> _completingTaskIds = {};
|
CalendarStripController();
|
||||||
|
|
||||||
void _onTaskCompleted(int taskId) {
|
/// Whether to show the floating "Heute" button.
|
||||||
setState(() {
|
/// True when the user has scrolled away from today's card.
|
||||||
_completingTaskIds.add(taskId);
|
bool _showTodayButton = false;
|
||||||
});
|
|
||||||
ref.read(taskActionsProvider.notifier).completeTask(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
|
||||||
final dailyPlan = ref.watch(dailyPlanProvider);
|
|
||||||
|
|
||||||
return dailyPlan.when(
|
return Scaffold(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
appBar: AppBar(
|
||||||
error: (error, _) => Center(child: Text(error.toString())),
|
title: Text(l10n.tabHome),
|
||||||
data: (state) {
|
actions: const [SortDropdown()],
|
||||||
// Clean up completing IDs that are no longer in the data
|
),
|
||||||
_completingTaskIds.removeWhere((id) =>
|
body: Stack(
|
||||||
!state.overdueTasks.any((t) => t.task.id == id) &&
|
children: [
|
||||||
!state.todayTasks.any((t) => t.task.id == id));
|
Column(
|
||||||
|
children: [
|
||||||
return _buildDailyPlan(context, state, l10n, theme);
|
CalendarStrip(
|
||||||
|
controller: _stripController,
|
||||||
|
onTodayVisibilityChanged: (visible) {
|
||||||
|
setState(() => _showTodayButton = !visible);
|
||||||
},
|
},
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDailyPlan(
|
|
||||||
BuildContext context,
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
// Case a: No tasks at all (user hasn't created any rooms/tasks)
|
|
||||||
if (state.totalTodayCount == 0 &&
|
|
||||||
state.tomorrowTasks.isEmpty &&
|
|
||||||
state.completedTodayCount == 0) {
|
|
||||||
return _buildNoTasksState(l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case b: All clear -- there WERE tasks today but all are done
|
|
||||||
if (state.overdueTasks.isEmpty &&
|
|
||||||
state.todayTasks.isEmpty &&
|
|
||||||
state.completedTodayCount > 0 &&
|
|
||||||
state.tomorrowTasks.isEmpty) {
|
|
||||||
return _buildAllClearState(state, l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case c: Nothing today, but stuff tomorrow -- show celebration + tomorrow
|
|
||||||
if (state.overdueTasks.isEmpty &&
|
|
||||||
state.todayTasks.isEmpty &&
|
|
||||||
state.completedTodayCount == 0 &&
|
|
||||||
state.tomorrowTasks.isNotEmpty) {
|
|
||||||
return _buildAllClearWithTomorrow(state, l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case b extended: all clear with tomorrow tasks
|
|
||||||
if (state.overdueTasks.isEmpty &&
|
|
||||||
state.todayTasks.isEmpty &&
|
|
||||||
state.completedTodayCount > 0 &&
|
|
||||||
state.tomorrowTasks.isNotEmpty) {
|
|
||||||
return _buildAllClearWithTomorrow(state, l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case d: Normal state -- tasks exist
|
|
||||||
return _buildNormalState(state, l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No tasks at all -- first-run empty state.
|
|
||||||
Widget _buildNoTasksState(AppLocalizations l10n, ThemeData theme) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.checklist_rounded,
|
|
||||||
size: 80,
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const Expanded(child: CalendarDayList()),
|
||||||
Text(
|
],
|
||||||
l10n.dailyPlanNoTasks,
|
|
||||||
style: theme.textTheme.headlineSmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
if (_showTodayButton)
|
||||||
Text(
|
Positioned(
|
||||||
l10n.homeEmptyMessage,
|
bottom: 16,
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
left: 0,
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
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),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
FilledButton.tonal(
|
|
||||||
onPressed: () => context.go('/rooms'),
|
|
||||||
child: Text(l10n.homeEmptyAction),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All tasks done, no tomorrow tasks -- celebration state.
|
|
||||||
Widget _buildAllClearState(
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ProgressCard(
|
|
||||||
completed: state.completedTodayCount,
|
|
||||||
total: state.totalTodayCount,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Icon(
|
|
||||||
Icons.celebration_outlined,
|
|
||||||
size: 80,
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
l10n.dailyPlanAllClearTitle,
|
|
||||||
style: theme.textTheme.headlineSmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
l10n.dailyPlanAllClearMessage,
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All clear for today but tomorrow tasks exist.
|
|
||||||
Widget _buildAllClearWithTomorrow(
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
return ListView(
|
|
||||||
children: [
|
|
||||||
ProgressCard(
|
|
||||||
completed: state.completedTodayCount,
|
|
||||||
total: state.totalTodayCount,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Center(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.celebration_outlined,
|
|
||||||
size: 80,
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
l10n.dailyPlanAllClearTitle,
|
|
||||||
style: theme.textTheme.headlineSmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
l10n.dailyPlanAllClearMessage,
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildTomorrowSection(state, l10n, theme),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normal state with overdue/today/tomorrow sections.
|
|
||||||
Widget _buildNormalState(
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
return ListView(
|
|
||||||
children: [
|
|
||||||
ProgressCard(
|
|
||||||
completed: state.completedTodayCount,
|
|
||||||
total: state.totalTodayCount,
|
|
||||||
),
|
|
||||||
// Overdue section (conditional)
|
|
||||||
if (state.overdueTasks.isNotEmpty) ...[
|
|
||||||
_buildSectionHeader(
|
|
||||||
l10n.dailyPlanSectionOverdue,
|
|
||||||
theme,
|
|
||||||
color: _overdueColor,
|
|
||||||
),
|
|
||||||
...state.overdueTasks.map(
|
|
||||||
(tw) => _buildAnimatedTaskRow(tw, showCheckbox: true),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// Today section
|
|
||||||
_buildSectionHeader(
|
|
||||||
l10n.dailyPlanSectionToday,
|
|
||||||
theme,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
if (state.todayTasks.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Text(
|
|
||||||
l10n.dailyPlanAllClearMessage,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
...state.todayTasks.map(
|
|
||||||
(tw) => _buildAnimatedTaskRow(tw, showCheckbox: true),
|
|
||||||
),
|
|
||||||
// Tomorrow section (conditional, collapsed)
|
|
||||||
if (state.tomorrowTasks.isNotEmpty)
|
|
||||||
_buildTomorrowSection(state, l10n, theme),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSectionHeader(
|
|
||||||
String title,
|
|
||||||
ThemeData theme, {
|
|
||||||
required Color color,
|
|
||||||
}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAnimatedTaskRow(
|
|
||||||
TaskWithRoom tw, {
|
|
||||||
required bool showCheckbox,
|
|
||||||
}) {
|
|
||||||
final isCompleting = _completingTaskIds.contains(tw.task.id);
|
|
||||||
|
|
||||||
if (isCompleting) {
|
|
||||||
return _CompletingTaskRow(
|
|
||||||
key: ValueKey('completing-${tw.task.id}'),
|
|
||||||
taskWithRoom: tw,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DailyPlanTaskRow(
|
|
||||||
key: ValueKey('task-${tw.task.id}'),
|
|
||||||
taskWithRoom: tw,
|
|
||||||
showCheckbox: showCheckbox,
|
|
||||||
onCompleted: () => _onTaskCompleted(tw.task.id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTomorrowSection(
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
return ExpansionTile(
|
|
||||||
initiallyExpanded: false,
|
|
||||||
title: Text(
|
|
||||||
l10n.dailyPlanUpcomingCount(state.tomorrowTasks.length),
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
children: state.tomorrowTasks
|
|
||||||
.map(
|
|
||||||
(tw) => DailyPlanTaskRow(
|
|
||||||
key: ValueKey('tomorrow-${tw.task.id}'),
|
|
||||||
taskWithRoom: tw,
|
|
||||||
showCheckbox: false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A task row that animates to zero height on completion.
|
|
||||||
class _CompletingTaskRow extends StatefulWidget {
|
|
||||||
const _CompletingTaskRow({
|
|
||||||
super.key,
|
|
||||||
required this.taskWithRoom,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TaskWithRoom taskWithRoom;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_CompletingTaskRow> createState() => _CompletingTaskRowState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CompletingTaskRowState extends State<_CompletingTaskRow>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final AnimationController _controller;
|
|
||||||
late final Animation<double> _sizeAnimation;
|
|
||||||
late final Animation<Offset> _slideAnimation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
_sizeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
|
||||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
|
||||||
);
|
|
||||||
_slideAnimation = Tween<Offset>(
|
|
||||||
begin: Offset.zero,
|
|
||||||
end: const Offset(1.0, 0.0),
|
|
||||||
).animate(
|
|
||||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
|
||||||
);
|
|
||||||
_controller.forward();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SizeTransition(
|
|
||||||
sizeFactor: _sizeAnimation,
|
|
||||||
child: SlideTransition(
|
|
||||||
position: _slideAnimation,
|
|
||||||
child: DailyPlanTaskRow(
|
|
||||||
taskWithRoom: widget.taskWithRoom,
|
|
||||||
showCheckbox: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Watch all completions for a task, newest first.
|
||||||
|
Stream<List<TaskCompletion>> watchCompletionsForTask(int taskId) {
|
||||||
|
return (select(taskCompletions)
|
||||||
|
..where((c) => c.taskId.equals(taskId))
|
||||||
|
..orderBy([(c) => OrderingTerm.desc(c.completedAt)]))
|
||||||
|
.watch();
|
||||||
|
}
|
||||||
|
|
||||||
/// Count overdue tasks in a room (nextDueDate before today).
|
/// Count overdue tasks in a room (nextDueDate before today).
|
||||||
Future<int> getOverdueTaskCount(int roomId, {DateTime? today}) async {
|
Future<int> getOverdueTaskCount(int roomId, {DateTime? today}) async {
|
||||||
final now = today ?? DateTime.now();
|
final now = today ?? DateTime.now();
|
||||||
|
|||||||
9
lib/features/tasks/domain/task_sort_option.dart
Normal file
9
lib/features/tasks/domain/task_sort_option.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// Sort options for task lists.
|
||||||
|
///
|
||||||
|
/// Stored as a string in SharedPreferences (not as intEnum in the database),
|
||||||
|
/// so reordering these values is safe.
|
||||||
|
enum TaskSortOption {
|
||||||
|
alphabetical, // A–Z by task name
|
||||||
|
interval, // by frequency interval (most frequent first)
|
||||||
|
effort, // by effort level (low → medium → high)
|
||||||
|
}
|
||||||
71
lib/features/tasks/presentation/sort_dropdown.dart
Normal file
71
lib/features/tasks/presentation/sort_dropdown.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.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/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// A reusable sort dropdown widget for use in AppBar actions.
|
||||||
|
///
|
||||||
|
/// Displays the current sort option as a labelled button with a sort icon.
|
||||||
|
/// Tapping opens a popup menu with three options: A–Z, Intervall, Aufwand.
|
||||||
|
/// The active option is indicated with a visible check mark.
|
||||||
|
///
|
||||||
|
/// Reads sort state from [sortPreferenceProvider] and writes via
|
||||||
|
/// [SortPreferenceNotifier.setSortOption].
|
||||||
|
class SortDropdown extends ConsumerWidget {
|
||||||
|
const SortDropdown({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final current = ref.watch(sortPreferenceProvider);
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return PopupMenuButton<TaskSortOption>(
|
||||||
|
onSelected: (value) =>
|
||||||
|
ref.read(sortPreferenceProvider.notifier).setSortOption(value),
|
||||||
|
itemBuilder: (context) => TaskSortOption.values.map((option) {
|
||||||
|
final isSelected = option == current;
|
||||||
|
return PopupMenuItem<TaskSortOption>(
|
||||||
|
value: option,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Opacity(
|
||||||
|
opacity: isSelected ? 1.0 : 0.0,
|
||||||
|
child: const Icon(Icons.check, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(_label(option, l10n)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.sort),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_label(current, l10n),
|
||||||
|
style: theme.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _label(TaskSortOption option, AppLocalizations l10n) {
|
||||||
|
switch (option) {
|
||||||
|
case TaskSortOption.alphabetical:
|
||||||
|
return l10n.sortAlphabetical;
|
||||||
|
case TaskSortOption.interval:
|
||||||
|
return l10n.sortInterval;
|
||||||
|
case TaskSortOption.effort:
|
||||||
|
return l10n.sortEffort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/tasks/domain/task_sort_option.dart';
|
||||||
|
|
||||||
|
part 'sort_preference_notifier.g.dart';
|
||||||
|
|
||||||
|
const _sortOptionKey = 'task_sort_option';
|
||||||
|
|
||||||
|
/// Notifier that manages the active task sort preference.
|
||||||
|
///
|
||||||
|
/// Defaults to [TaskSortOption.alphabetical] synchronously (matching the
|
||||||
|
/// existing A-Z sort in CalendarDayList), then loads the persisted value
|
||||||
|
/// asynchronously on first build.
|
||||||
|
///
|
||||||
|
/// Follows the same pattern as [ThemeNotifier] in
|
||||||
|
/// `lib/core/theme/theme_provider.dart`.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class SortPreferenceNotifier extends _$SortPreferenceNotifier {
|
||||||
|
@override
|
||||||
|
TaskSortOption build() {
|
||||||
|
_loadPersisted();
|
||||||
|
return TaskSortOption.alphabetical;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPersisted() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final persisted = prefs.getString(_sortOptionKey);
|
||||||
|
if (persisted != null) {
|
||||||
|
state = _fromString(persisted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the active sort preference and persist it.
|
||||||
|
Future<void> setSortOption(TaskSortOption option) async {
|
||||||
|
state = option;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_sortOptionKey, _toString(option));
|
||||||
|
}
|
||||||
|
|
||||||
|
static TaskSortOption _fromString(String value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'alphabetical':
|
||||||
|
return TaskSortOption.alphabetical;
|
||||||
|
case 'interval':
|
||||||
|
return TaskSortOption.interval;
|
||||||
|
case 'effort':
|
||||||
|
return TaskSortOption.effort;
|
||||||
|
default:
|
||||||
|
return TaskSortOption.alphabetical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _toString(TaskSortOption option) {
|
||||||
|
return option.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'sort_preference_notifier.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Notifier that manages the active task sort preference.
|
||||||
|
///
|
||||||
|
/// Defaults to [TaskSortOption.alphabetical] synchronously (matching the
|
||||||
|
/// existing A-Z sort in CalendarDayList), then loads the persisted value
|
||||||
|
/// asynchronously on first build.
|
||||||
|
///
|
||||||
|
/// Follows the same pattern as [ThemeNotifier] in
|
||||||
|
/// `lib/core/theme/theme_provider.dart`.
|
||||||
|
|
||||||
|
@ProviderFor(SortPreferenceNotifier)
|
||||||
|
final sortPreferenceProvider = SortPreferenceNotifierProvider._();
|
||||||
|
|
||||||
|
/// Notifier that manages the active task sort preference.
|
||||||
|
///
|
||||||
|
/// Defaults to [TaskSortOption.alphabetical] synchronously (matching the
|
||||||
|
/// existing A-Z sort in CalendarDayList), then loads the persisted value
|
||||||
|
/// asynchronously on first build.
|
||||||
|
///
|
||||||
|
/// Follows the same pattern as [ThemeNotifier] in
|
||||||
|
/// `lib/core/theme/theme_provider.dart`.
|
||||||
|
final class SortPreferenceNotifierProvider
|
||||||
|
extends $NotifierProvider<SortPreferenceNotifier, TaskSortOption> {
|
||||||
|
/// Notifier that manages the active task sort preference.
|
||||||
|
///
|
||||||
|
/// Defaults to [TaskSortOption.alphabetical] synchronously (matching the
|
||||||
|
/// existing A-Z sort in CalendarDayList), then loads the persisted value
|
||||||
|
/// asynchronously on first build.
|
||||||
|
///
|
||||||
|
/// Follows the same pattern as [ThemeNotifier] in
|
||||||
|
/// `lib/core/theme/theme_provider.dart`.
|
||||||
|
SortPreferenceNotifierProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'sortPreferenceProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$sortPreferenceNotifierHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
SortPreferenceNotifier create() => SortPreferenceNotifier();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(TaskSortOption value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<TaskSortOption>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$sortPreferenceNotifierHash() =>
|
||||||
|
r'5d7f2c5d06b82b4114262ee05cf890ebe717fe2a';
|
||||||
|
|
||||||
|
/// Notifier that manages the active task sort preference.
|
||||||
|
///
|
||||||
|
/// Defaults to [TaskSortOption.alphabetical] synchronously (matching the
|
||||||
|
/// existing A-Z sort in CalendarDayList), then loads the persisted value
|
||||||
|
/// asynchronously on first build.
|
||||||
|
///
|
||||||
|
/// Follows the same pattern as [ThemeNotifier] in
|
||||||
|
/// `lib/core/theme/theme_provider.dart`.
|
||||||
|
|
||||||
|
abstract class _$SortPreferenceNotifier extends $Notifier<TaskSortOption> {
|
||||||
|
TaskSortOption build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<TaskSortOption, TaskSortOption>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<TaskSortOption, TaskSortOption>,
|
||||||
|
TaskSortOption,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import '../../../core/providers/database_provider.dart';
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../domain/effort_level.dart';
|
import '../domain/effort_level.dart';
|
||||||
import '../domain/frequency.dart';
|
import '../domain/frequency.dart';
|
||||||
|
import 'task_history_sheet.dart';
|
||||||
import 'task_providers.dart';
|
import 'task_providers.dart';
|
||||||
|
|
||||||
/// Full-screen form for task creation and editing.
|
/// Full-screen form for task creation and editing.
|
||||||
@@ -186,6 +187,21 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildDueDatePicker(theme),
|
_buildDueDatePicker(theme),
|
||||||
|
|
||||||
|
// History section (edit mode only)
|
||||||
|
if (widget.isEditing) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.history),
|
||||||
|
title: Text(l10n.taskHistoryTitle),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => showTaskHistorySheet(
|
||||||
|
context: context,
|
||||||
|
taskId: widget.taskId!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
136
lib/features/tasks/presentation/task_history_sheet.dart
Normal file
136
lib/features/tasks/presentation/task_history_sheet.dart
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../../../core/database/database.dart';
|
||||||
|
import '../../../core/providers/database_provider.dart';
|
||||||
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Shows a modal bottom sheet displaying the completion history for a task.
|
||||||
|
///
|
||||||
|
/// The sheet displays all past completions in reverse-chronological order
|
||||||
|
/// (newest first). If the task has never been completed, an empty state is shown.
|
||||||
|
Future<void> showTaskHistorySheet({
|
||||||
|
required BuildContext context,
|
||||||
|
required int taskId,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => _TaskHistorySheet(taskId: taskId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskHistorySheet extends ConsumerWidget {
|
||||||
|
const _TaskHistorySheet({required this.taskId});
|
||||||
|
|
||||||
|
final int taskId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Drag handle
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
l10n.taskHistoryTitle,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Completion list via StreamBuilder
|
||||||
|
StreamBuilder<List<TaskCompletion>>(
|
||||||
|
stream: ref
|
||||||
|
.read(appDatabaseProvider)
|
||||||
|
.tasksDao
|
||||||
|
.watchCompletionsForTask(taskId),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final completions = snapshot.data!;
|
||||||
|
|
||||||
|
if (completions.isEmpty) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.history,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.taskHistoryEmpty,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show count summary
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.taskHistoryCount(completions.length),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight:
|
||||||
|
MediaQuery.of(context).size.height * 0.4,
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: completions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final completion = completions[index];
|
||||||
|
final dateStr = DateFormat('dd.MM.yyyy', 'de')
|
||||||
|
.format(completion.completedAt);
|
||||||
|
final timeStr = DateFormat('HH:mm', 'de')
|
||||||
|
.format(completion.completedAt);
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: Text(dateStr),
|
||||||
|
subtitle: Text(timeStr),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import 'package:household_keeper/core/database/database.dart';
|
import 'package:household_keeper/core/database/database.dart';
|
||||||
import 'package:household_keeper/core/providers/database_provider.dart';
|
import 'package:household_keeper/core/providers/database_provider.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_providers.dart';
|
||||||
import 'package:household_keeper/features/tasks/presentation/task_row.dart';
|
import 'package:household_keeper/features/tasks/presentation/task_row.dart';
|
||||||
import 'package:household_keeper/l10n/app_localizations.dart';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
@@ -27,6 +28,7 @@ class TaskListScreen extends ConsumerWidget {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: _RoomTitle(roomId: roomId),
|
title: _RoomTitle(roomId: roomId),
|
||||||
actions: [
|
actions: [
|
||||||
|
const SortDropdown(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
onPressed: () => context.go('/rooms/$roomId/edit'),
|
onPressed: () => context.go('/rooms/$roomId/edit'),
|
||||||
|
|||||||
@@ -6,17 +6,44 @@ import 'package:household_keeper/core/database/database.dart';
|
|||||||
import 'package:household_keeper/core/providers/database_provider.dart';
|
import 'package:household_keeper/core/providers/database_provider.dart';
|
||||||
import 'package:household_keeper/features/tasks/domain/effort_level.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/frequency.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/domain/task_sort_option.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/presentation/sort_preference_notifier.dart';
|
||||||
|
|
||||||
part 'task_providers.g.dart';
|
part 'task_providers.g.dart';
|
||||||
|
|
||||||
/// Stream provider family for tasks in a specific room, sorted by due date.
|
/// Sort a list of [Task] by the given [sortOption].
|
||||||
|
///
|
||||||
|
/// Returns a new sorted list; never mutates the original.
|
||||||
|
List<Task> _sortTasksRaw(List<Task> tasks, TaskSortOption sortOption) {
|
||||||
|
final sorted = List<Task>.from(tasks);
|
||||||
|
switch (sortOption) {
|
||||||
|
case TaskSortOption.alphabetical:
|
||||||
|
sorted.sort((a, b) => a.name.toLowerCase().compareTo(
|
||||||
|
b.name.toLowerCase(),
|
||||||
|
));
|
||||||
|
case TaskSortOption.interval:
|
||||||
|
sorted.sort((a, b) {
|
||||||
|
final cmp = a.intervalType.index.compareTo(b.intervalType.index);
|
||||||
|
if (cmp != 0) return cmp;
|
||||||
|
return a.intervalDays.compareTo(b.intervalDays);
|
||||||
|
});
|
||||||
|
case TaskSortOption.effort:
|
||||||
|
sorted.sort((a, b) => a.effortLevel.index.compareTo(b.effortLevel.index));
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream provider family for tasks in a specific room, sorted by active sort preference.
|
||||||
///
|
///
|
||||||
/// Defined manually because riverpod_generator has trouble with drift's
|
/// Defined manually because riverpod_generator has trouble with drift's
|
||||||
/// generated [Task] type in family provider return types.
|
/// generated [Task] type in family provider return types.
|
||||||
final tasksInRoomProvider =
|
final tasksInRoomProvider =
|
||||||
StreamProvider.family.autoDispose<List<Task>, int>((ref, roomId) {
|
StreamProvider.family.autoDispose<List<Task>, int>((ref, roomId) {
|
||||||
final db = ref.watch(appDatabaseProvider);
|
final db = ref.watch(appDatabaseProvider);
|
||||||
return db.tasksDao.watchTasksInRoom(roomId);
|
final sortOption = ref.watch(sortPreferenceProvider);
|
||||||
|
return db.tasksDao
|
||||||
|
.watchTasksInRoom(roomId)
|
||||||
|
.map((tasks) => _sortTasksRaw(tasks, sortOption));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Notifier for task mutations: create, update, delete, complete.
|
/// Notifier for task mutations: create, update, delete, complete.
|
||||||
|
|||||||
@@ -106,5 +106,18 @@
|
|||||||
"count": { "type": "int" },
|
"count": { "type": "int" },
|
||||||
"overdue": { "type": "int" }
|
"overdue": { "type": "int" }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"calendarTodayButton": "Heute",
|
||||||
|
"taskHistoryTitle": "Verlauf",
|
||||||
|
"taskHistoryEmpty": "Noch nie erledigt",
|
||||||
|
"taskHistoryCount": "{count} Mal erledigt",
|
||||||
|
"@taskHistoryCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": { "type": "int" }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sortAlphabetical": "A\u2013Z",
|
||||||
|
"sortInterval": "Intervall",
|
||||||
|
"sortEffort": "Aufwand",
|
||||||
|
"sortLabel": "Sortierung"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -513,6 +513,54 @@ abstract class AppLocalizations {
|
|||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
/// **'{count} Aufgaben fällig ({overdue} überfällig)'**
|
/// **'{count} Aufgaben fällig ({overdue} überfällig)'**
|
||||||
String notificationBodyWithOverdue(int count, int overdue);
|
String notificationBodyWithOverdue(int count, int overdue);
|
||||||
|
|
||||||
|
/// No description provided for @calendarTodayButton.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Heute'**
|
||||||
|
String get calendarTodayButton;
|
||||||
|
|
||||||
|
/// No description provided for @taskHistoryTitle.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Verlauf'**
|
||||||
|
String get taskHistoryTitle;
|
||||||
|
|
||||||
|
/// No description provided for @taskHistoryEmpty.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Noch nie erledigt'**
|
||||||
|
String get taskHistoryEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @taskHistoryCount.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'{count} Mal erledigt'**
|
||||||
|
String taskHistoryCount(int count);
|
||||||
|
|
||||||
|
/// No description provided for @sortAlphabetical.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'A–Z'**
|
||||||
|
String get sortAlphabetical;
|
||||||
|
|
||||||
|
/// No description provided for @sortInterval.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Intervall'**
|
||||||
|
String get sortInterval;
|
||||||
|
|
||||||
|
/// No description provided for @sortEffort.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Aufwand'**
|
||||||
|
String get sortEffort;
|
||||||
|
|
||||||
|
/// No description provided for @sortLabel.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Sortierung'**
|
||||||
|
String get sortLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -236,4 +236,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String notificationBodyWithOverdue(int count, int overdue) {
|
String notificationBodyWithOverdue(int count, int overdue) {
|
||||||
return '$count Aufgaben fällig ($overdue überfällig)';
|
return '$count Aufgaben fällig ($overdue überfällig)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get calendarTodayButton => 'Heute';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get taskHistoryTitle => 'Verlauf';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get taskHistoryEmpty => 'Noch nie erledigt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String taskHistoryCount(int count) {
|
||||||
|
return '$count Mal erledigt';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphabetical => 'A–Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortInterval => 'Intervall';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortEffort => 'Aufwand';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortLabel => 'Sortierung';
|
||||||
}
|
}
|
||||||
|
|||||||
286
test/features/home/data/calendar_dao_test.dart
Normal file
286
test/features/home/data/calendar_dao_test.dart
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:household_keeper/core/database/database.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/domain/effort_level.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/domain/frequency.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late int room1Id;
|
||||||
|
late int room2Id;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
room1Id = await db.roomsDao.insertRoom(
|
||||||
|
RoomsCompanion.insert(name: 'Kueche', iconName: 'kitchen'),
|
||||||
|
);
|
||||||
|
room2Id = await db.roomsDao.insertRoom(
|
||||||
|
RoomsCompanion.insert(name: 'Badezimmer', iconName: 'bathroom'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CalendarDao.watchTasksForDate', () {
|
||||||
|
test('returns empty list when no tasks exist', () async {
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchTasksForDate(DateTime(2026, 3, 16))
|
||||||
|
.first;
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns only tasks due on the queried date', () async {
|
||||||
|
// Task due on March 16
|
||||||
|
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 (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 (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
|
||||||
|
.watchTasksForDate(DateTime(2026, 3, 16))
|
||||||
|
.first;
|
||||||
|
expect(result.length, 1);
|
||||||
|
expect(result.first.task.name, 'Abspuelen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns tasks from multiple rooms with correct room pairing',
|
||||||
|
() async {
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Kueche Aufgabe',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 20),
|
||||||
|
));
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room2Id,
|
||||||
|
name: 'Bad Aufgabe',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 20, 14),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchTasksForDate(DateTime(2026, 3, 20))
|
||||||
|
.first;
|
||||||
|
expect(result.length, 2);
|
||||||
|
|
||||||
|
final names = result.map((t) => t.task.name).toList();
|
||||||
|
expect(names, contains('Kueche Aufgabe'));
|
||||||
|
expect(names, contains('Bad Aufgabe'));
|
||||||
|
|
||||||
|
final kuecheTask =
|
||||||
|
result.firstWhere((t) => t.task.name == 'Kueche Aufgabe');
|
||||||
|
expect(kuecheTask.roomName, 'Kueche');
|
||||||
|
expect(kuecheTask.roomId, room1Id);
|
||||||
|
|
||||||
|
final badTask =
|
||||||
|
result.firstWhere((t) => t.task.name == 'Bad Aufgabe');
|
||||||
|
expect(badTask.roomName, 'Badezimmer');
|
||||||
|
expect(badTask.roomId, room2Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: room2Id,
|
||||||
|
name: 'Moppen',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 18, 8),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchTasksForDate(DateTime(2026, 3, 18))
|
||||||
|
.first;
|
||||||
|
expect(result.length, 3);
|
||||||
|
expect(result[0].task.name, 'Abspuelen');
|
||||||
|
expect(result[1].task.name, 'Moppen');
|
||||||
|
expect(result[2].task.name, 'Zitrone putzen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does NOT include overdue carry-over for past dates', () async {
|
||||||
|
// Task due on March 10 (overdue relative to March 16)
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Alte Aufgabe',
|
||||||
|
intervalType: IntervalType.monthly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 10),
|
||||||
|
));
|
||||||
|
// Task due on March 15 (queried date)
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Richtige Aufgabe',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Querying March 15 should only return the task due on March 15
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchTasksForDate(DateTime(2026, 3, 15))
|
||||||
|
.first;
|
||||||
|
expect(result.length, 1);
|
||||||
|
expect(result.first.task.name, 'Richtige Aufgabe');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CalendarDao.watchOverdueTasks', () {
|
||||||
|
test('returns empty list when no overdue tasks exist', () async {
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchOverdueTasks(DateTime(2026, 3, 16))
|
||||||
|
.first;
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns tasks whose nextDueDate is before referenceDate', () async {
|
||||||
|
// Task due March 15 — 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 — 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),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchOverdueTasks(DateTime(2026, 3, 16))
|
||||||
|
.first;
|
||||||
|
expect(result.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does NOT include tasks due ON the referenceDate', () async {
|
||||||
|
// Task due exactly on reference date (March 16) — 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),
|
||||||
|
));
|
||||||
|
// Task due yesterday (March 15) — should appear
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Gestriges Task',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchOverdueTasks(DateTime(2026, 3, 16))
|
||||||
|
.first;
|
||||||
|
expect(result.length, 1);
|
||||||
|
expect(result.first.task.name, 'Gestriges Task');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does NOT include tasks due in the future', () async {
|
||||||
|
// Future task — should NOT appear
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Zukuenftiges Task',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 20),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchOverdueTasks(DateTime(2026, 3, 16))
|
||||||
|
.first;
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: room2Id,
|
||||||
|
name: 'Mittleres Overdue',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 10),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchOverdueTasks(DateTime(2026, 3, 16))
|
||||||
|
.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');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns overdue task with correct room pairing', () async {
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room2Id,
|
||||||
|
name: 'Bad Overdue',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 14),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchOverdueTasks(DateTime(2026, 3, 16))
|
||||||
|
.first;
|
||||||
|
expect(result.length, 1);
|
||||||
|
expect(result.first.task.name, 'Bad Overdue');
|
||||||
|
expect(result.first.roomName, 'Badezimmer');
|
||||||
|
expect(result.first.roomId, room2Id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,12 +5,15 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import 'package:household_keeper/core/database/database.dart';
|
import 'package:household_keeper/core/database/database.dart';
|
||||||
import 'package:household_keeper/core/router/router.dart';
|
import 'package:household_keeper/core/router/router.dart';
|
||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
||||||
import 'package:household_keeper/features/tasks/domain/effort_level.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/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/l10n/app_localizations.dart';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
|
||||||
/// Helper to create a test [Task] with sensible defaults.
|
/// Helper to create a test [Task] with sensible defaults.
|
||||||
Task _makeTask({
|
Task _makeTask({
|
||||||
@@ -51,18 +54,20 @@ TaskWithRoom _makeTaskWithRoom({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the app with dailyPlanProvider overridden to the given state.
|
/// Build the app with calendarDayProvider overridden to the given state.
|
||||||
///
|
///
|
||||||
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
||||||
/// the riverpod_lint scoped_providers_should_specify_dependencies warning.
|
/// the riverpod_lint scoped_providers_should_specify_dependencies warning.
|
||||||
Widget _buildApp(DailyPlanState planState) {
|
Widget _buildApp(CalendarDayState dayState) {
|
||||||
final container = ProviderContainer(overrides: [
|
final container = ProviderContainer(overrides: [
|
||||||
dailyPlanProvider.overrideWith(
|
calendarDayProvider.overrideWith(
|
||||||
(ref) => Stream.value(planState),
|
(ref) => Stream.value(dayState),
|
||||||
),
|
),
|
||||||
|
selectedDateProvider.overrideWith(SelectedDateNotifier.new),
|
||||||
roomWithStatsListProvider.overrideWith(
|
roomWithStatsListProvider.overrideWith(
|
||||||
(ref) => Stream.value([]),
|
(ref) => Stream.value([]),
|
||||||
),
|
),
|
||||||
|
sortPreferenceProvider.overrideWith(SortPreferenceNotifier.new),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return UncontrolledProviderScope(
|
return UncontrolledProviderScope(
|
||||||
@@ -81,17 +86,21 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final yesterday = today.subtract(const Duration(days: 1));
|
||||||
|
|
||||||
group('HomeScreen empty states', () {
|
group('HomeScreen empty states', () {
|
||||||
testWidgets('shows no-tasks empty state when no tasks exist at all',
|
testWidgets('shows no-tasks empty state when no tasks exist at all',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(_buildApp(const DailyPlanState(
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
overdueTasks: [],
|
selectedDate: today,
|
||||||
todayTasks: [],
|
dayTasks: const [],
|
||||||
tomorrowTasks: [],
|
overdueTasks: const [],
|
||||||
completedTodayCount: 0,
|
totalTaskCount: 0,
|
||||||
totalTodayCount: 0,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show "Noch keine Aufgaben angelegt" (dailyPlanNoTasks)
|
// Should show "Noch keine Aufgaben angelegt" (dailyPlanNoTasks)
|
||||||
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
||||||
@@ -99,58 +108,53 @@ void main() {
|
|||||||
expect(find.text('Raum erstellen'), findsOneWidget);
|
expect(find.text('Raum erstellen'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows all-clear state when all tasks are done',
|
testWidgets('shows celebration state when tasks exist but today is clear',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(_buildApp(const DailyPlanState(
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
overdueTasks: [],
|
selectedDate: today,
|
||||||
todayTasks: [],
|
dayTasks: const [],
|
||||||
tomorrowTasks: [],
|
overdueTasks: const [],
|
||||||
completedTodayCount: 3,
|
totalTaskCount: 5, // tasks exist elsewhere
|
||||||
totalTodayCount: 3,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show celebration empty state
|
// Should show celebration state
|
||||||
expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget);
|
|
||||||
expect(find.byIcon(Icons.celebration_outlined), findsOneWidget);
|
expect(find.byIcon(Icons.celebration_outlined), findsOneWidget);
|
||||||
// Progress card should show 3/3
|
expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget);
|
||||||
expect(find.text('3 von 3 erledigt'), 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('HomeScreen normal state', () {
|
group('HomeScreen normal state', () {
|
||||||
testWidgets('shows progress card with correct counts', (tester) async {
|
testWidgets('shows overdue section when overdue tasks exist (today)',
|
||||||
final now = DateTime.now();
|
(tester) async {
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
|
selectedDate: today,
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
dayTasks: [
|
||||||
overdueTasks: [],
|
|
||||||
todayTasks: [
|
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 2,
|
||||||
taskName: 'Staubsaugen',
|
taskName: 'Staubsaugen',
|
||||||
roomName: 'Wohnzimmer',
|
roomName: 'Wohnzimmer',
|
||||||
nextDueDate: today,
|
nextDueDate: today,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
tomorrowTasks: [],
|
|
||||||
completedTodayCount: 2,
|
|
||||||
totalTodayCount: 3,
|
|
||||||
)));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Progress card should show 2/3
|
|
||||||
expect(find.text('2 von 3 erledigt'), findsOneWidget);
|
|
||||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows overdue section when overdue tasks exist',
|
|
||||||
(tester) async {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
|
||||||
final yesterday = today.subtract(const Duration(days: 1));
|
|
||||||
|
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
|
||||||
overdueTasks: [
|
overdueTasks: [
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -159,24 +163,13 @@ void main() {
|
|||||||
nextDueDate: yesterday,
|
nextDueDate: yesterday,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
todayTasks: [
|
totalTaskCount: 2,
|
||||||
_makeTaskWithRoom(
|
|
||||||
id: 2,
|
|
||||||
taskName: 'Staubsaugen',
|
|
||||||
roomName: 'Wohnzimmer',
|
|
||||||
nextDueDate: today,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
tomorrowTasks: [],
|
|
||||||
completedTodayCount: 0,
|
|
||||||
totalTodayCount: 2,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show overdue section header
|
// Should show overdue section header
|
||||||
expect(find.text('\u00dcberf\u00e4llig'), findsOneWidget);
|
expect(find.text('\u00dcberf\u00e4llig'), findsOneWidget);
|
||||||
// Should show today section header (may also appear as relative date)
|
|
||||||
expect(find.text('Heute'), findsAtLeast(1));
|
|
||||||
// Should show both tasks
|
// Should show both tasks
|
||||||
expect(find.text('Boden wischen'), findsOneWidget);
|
expect(find.text('Boden wischen'), findsOneWidget);
|
||||||
expect(find.text('Staubsaugen'), findsOneWidget);
|
expect(find.text('Staubsaugen'), findsOneWidget);
|
||||||
@@ -185,54 +178,37 @@ void main() {
|
|||||||
expect(find.text('Wohnzimmer'), findsOneWidget);
|
expect(find.text('Wohnzimmer'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows collapsed tomorrow section with count',
|
testWidgets('does not show overdue section for non-today date',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final now = DateTime.now();
|
// On a future date, overdueTasks will be empty (calendarDayProvider
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
// only populates overdueTasks when isToday).
|
||||||
final tomorrow = today.add(const Duration(days: 1));
|
final tomorrow = today.add(const Duration(days: 1));
|
||||||
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
selectedDate: tomorrow,
|
||||||
overdueTasks: [],
|
dayTasks: [
|
||||||
todayTasks: [
|
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 1,
|
||||||
taskName: 'Staubsaugen',
|
taskName: 'Staubsaugen',
|
||||||
roomName: 'Wohnzimmer',
|
roomName: 'Wohnzimmer',
|
||||||
nextDueDate: today,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
tomorrowTasks: [
|
|
||||||
_makeTaskWithRoom(
|
|
||||||
id: 2,
|
|
||||||
taskName: 'Fenster putzen',
|
|
||||||
roomName: 'Schlafzimmer',
|
|
||||||
nextDueDate: tomorrow,
|
|
||||||
),
|
|
||||||
_makeTaskWithRoom(
|
|
||||||
id: 3,
|
|
||||||
taskName: 'Bett beziehen',
|
|
||||||
roomName: 'Schlafzimmer',
|
|
||||||
nextDueDate: tomorrow,
|
nextDueDate: tomorrow,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
completedTodayCount: 0,
|
overdueTasks: const [], // No overdue for non-today
|
||||||
totalTodayCount: 1,
|
totalTaskCount: 1,
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show collapsed tomorrow section with count
|
// Should NOT show overdue section header
|
||||||
expect(find.text('Demn\u00e4chst (2)'), findsOneWidget);
|
expect(find.text('\u00dcberf\u00e4llig'), findsNothing);
|
||||||
// Tomorrow tasks should NOT be visible (collapsed by default)
|
// Should show day task
|
||||||
expect(find.text('Fenster putzen'), findsNothing);
|
expect(find.text('Staubsaugen'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('today tasks have checkboxes', (tester) async {
|
testWidgets('tasks have checkboxes', (tester) async {
|
||||||
final now = DateTime.now();
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
selectedDate: today,
|
||||||
|
dayTasks: [
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
|
||||||
overdueTasks: [],
|
|
||||||
todayTasks: [
|
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 1,
|
||||||
taskName: 'Staubsaugen',
|
taskName: 'Staubsaugen',
|
||||||
@@ -240,14 +216,63 @@ void main() {
|
|||||||
nextDueDate: today,
|
nextDueDate: today,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
tomorrowTasks: [],
|
overdueTasks: const [],
|
||||||
completedTodayCount: 0,
|
totalTaskCount: 1,
|
||||||
totalTodayCount: 1,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Today task should have a checkbox
|
// Task should have a checkbox
|
||||||
expect(find.byType(Checkbox), findsOneWidget);
|
expect(find.byType(Checkbox), 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('HomeScreen sort dropdown', () {
|
||||||
|
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 AppBar with title', (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));
|
||||||
|
|
||||||
|
// tabHome l10n string is 'Übersicht'. It appears in the AppBar title
|
||||||
|
// and also in the bottom navigation bar label — use findsWidgets.
|
||||||
|
expect(find.text('\u00dcbersicht'), findsWidgets);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
157
test/features/tasks/data/task_history_dao_test.dart
Normal file
157
test/features/tasks/data/task_history_dao_test.dart
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:household_keeper/core/database/database.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/domain/effort_level.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/domain/frequency.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late int roomId;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
roomId = await db.roomsDao.insertRoom(
|
||||||
|
RoomsCompanion.insert(name: 'Kueche', iconName: 'kitchen'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('TasksDao.watchCompletionsForTask', () {
|
||||||
|
test('returns empty list when task has no completions', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Staubsaugen',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final completions =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId).first;
|
||||||
|
|
||||||
|
expect(completions, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns completion after completeTask is called', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Abspuelen',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final completionTime = DateTime(2026, 3, 15, 10, 30);
|
||||||
|
await db.tasksDao.completeTask(taskId, now: completionTime);
|
||||||
|
|
||||||
|
final completions =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId).first;
|
||||||
|
|
||||||
|
expect(completions.length, 1);
|
||||||
|
expect(completions.first.taskId, taskId);
|
||||||
|
expect(completions.first.completedAt, completionTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns multiple completions in reverse-chronological order', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Fenster putzen',
|
||||||
|
intervalType: IntervalType.monthly,
|
||||||
|
effortLevel: EffortLevel.high,
|
||||||
|
nextDueDate: DateTime(2026, 1, 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Complete multiple times with specific timestamps
|
||||||
|
final time1 = DateTime(2026, 1, 10, 9, 0);
|
||||||
|
final time2 = DateTime(2026, 2, 12, 14, 30);
|
||||||
|
final time3 = DateTime(2026, 3, 15, 8, 0);
|
||||||
|
|
||||||
|
// Insert out of order to verify ordering is enforced by query
|
||||||
|
await db.tasksDao.completeTask(taskId, now: time1);
|
||||||
|
await db.tasksDao.completeTask(taskId, now: time2);
|
||||||
|
await db.tasksDao.completeTask(taskId, now: time3);
|
||||||
|
|
||||||
|
final completions =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId).first;
|
||||||
|
|
||||||
|
expect(completions.length, 3);
|
||||||
|
// Newest first (reverse-chronological)
|
||||||
|
expect(completions[0].completedAt, time3);
|
||||||
|
expect(completions[1].completedAt, time2);
|
||||||
|
expect(completions[2].completedAt, time1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completions for different tasks are isolated', () async {
|
||||||
|
final taskId1 = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Task A',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final taskId2 = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Task B',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.tasksDao.completeTask(taskId1, now: DateTime(2026, 3, 15));
|
||||||
|
|
||||||
|
final completionsForTask1 =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId1).first;
|
||||||
|
final completionsForTask2 =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId2).first;
|
||||||
|
|
||||||
|
expect(completionsForTask1.length, 1);
|
||||||
|
expect(completionsForTask1.first.taskId, taskId1);
|
||||||
|
expect(completionsForTask2, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stream emits updated list after new completion is added', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Bodenwischen',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect stream emissions
|
||||||
|
final emissions = <List<TaskCompletion>>[];
|
||||||
|
final subscription = db.tasksDao
|
||||||
|
.watchCompletionsForTask(taskId)
|
||||||
|
.listen(emissions.add);
|
||||||
|
|
||||||
|
// Wait for initial empty emission
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(emissions.isNotEmpty, isTrue);
|
||||||
|
expect(emissions.last, isEmpty);
|
||||||
|
|
||||||
|
// Complete the task
|
||||||
|
await db.tasksDao.completeTask(taskId, now: DateTime(2026, 3, 15, 9, 0));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(emissions.last.length, 1);
|
||||||
|
|
||||||
|
await subscription.cancel();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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/features/tasks/domain/task_sort_option.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/presentation/sort_preference_notifier.dart';
|
||||||
|
|
||||||
|
/// Helper: create a container and wait for the initial async _loadPersisted()
|
||||||
|
/// to finish.
|
||||||
|
Future<ProviderContainer> makeContainer() async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
// Trigger build
|
||||||
|
container.read(sortPreferenceProvider);
|
||||||
|
// Allow the async _loadPersisted() to complete
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SortPreferenceNotifier', () {
|
||||||
|
test('build() returns default state of alphabetical', () async {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final state = container.read(sortPreferenceProvider);
|
||||||
|
|
||||||
|
expect(state, TaskSortOption.alphabetical);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSortOption(interval) updates state to interval', () async {
|
||||||
|
final container = await makeContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container
|
||||||
|
.read(sortPreferenceProvider.notifier)
|
||||||
|
.setSortOption(TaskSortOption.interval);
|
||||||
|
|
||||||
|
expect(container.read(sortPreferenceProvider), TaskSortOption.interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSortOption(effort) updates state to effort', () async {
|
||||||
|
final container = await makeContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container
|
||||||
|
.read(sortPreferenceProvider.notifier)
|
||||||
|
.setSortOption(TaskSortOption.effort);
|
||||||
|
|
||||||
|
expect(container.read(sortPreferenceProvider), TaskSortOption.effort);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSortOption persists to SharedPreferences', () async {
|
||||||
|
final container = await makeContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container
|
||||||
|
.read(sortPreferenceProvider.notifier)
|
||||||
|
.setSortOption(TaskSortOption.effort);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getString('task_sort_option'), 'effort');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persisted value is loaded on restart (effort)', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'task_sort_option': 'effort',
|
||||||
|
});
|
||||||
|
|
||||||
|
final container = await makeContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
expect(container.read(sortPreferenceProvider), TaskSortOption.effort);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persisted value is loaded on restart (interval)', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'task_sort_option': 'interval',
|
||||||
|
});
|
||||||
|
|
||||||
|
final container = await makeContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
expect(container.read(sortPreferenceProvider), TaskSortOption.interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown persisted value falls back to alphabetical', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'task_sort_option': 'unknown_value',
|
||||||
|
});
|
||||||
|
|
||||||
|
final container = await makeContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
expect(container.read(sortPreferenceProvider), TaskSortOption.alphabetical);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:household_keeper/core/router/router.dart';
|
import 'package:household_keeper/core/router/router.dart';
|
||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
||||||
import 'package:household_keeper/l10n/app_localizations.dart';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -15,6 +15,9 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
/// Helper to build the app with providers overridden for testing.
|
/// Helper to build the app with providers overridden for testing.
|
||||||
///
|
///
|
||||||
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
||||||
@@ -26,15 +29,16 @@ void main() {
|
|||||||
roomWithStatsListProvider.overrideWith(
|
roomWithStatsListProvider.overrideWith(
|
||||||
(ref) => Stream.value([]),
|
(ref) => Stream.value([]),
|
||||||
),
|
),
|
||||||
// Override daily plan to return empty state so HomeScreen
|
// Override selected date to avoid any DB access.
|
||||||
// renders without a database.
|
selectedDateProvider.overrideWith(SelectedDateNotifier.new),
|
||||||
dailyPlanProvider.overrideWith(
|
// Override calendar day provider to return empty first-run state so
|
||||||
(ref) => Stream.value(const DailyPlanState(
|
// HomeScreen renders without a database.
|
||||||
overdueTasks: [],
|
calendarDayProvider.overrideWith(
|
||||||
todayTasks: [],
|
(ref) => Stream.value(CalendarDayState(
|
||||||
tomorrowTasks: [],
|
selectedDate: today,
|
||||||
completedTodayCount: 0,
|
dayTasks: const [],
|
||||||
totalTodayCount: 0,
|
overdueTasks: const [],
|
||||||
|
totalTaskCount: 0,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -53,13 +57,15 @@ void main() {
|
|||||||
testWidgets('renders 3 navigation destinations with correct German labels',
|
testWidgets('renders 3 navigation destinations with correct German labels',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(buildApp());
|
await tester.pumpWidget(buildApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Verify 3 NavigationDestination widgets are rendered
|
// Verify 3 NavigationDestination widgets are rendered
|
||||||
expect(find.byType(NavigationDestination), findsNWidgets(3));
|
expect(find.byType(NavigationDestination), findsNWidgets(3));
|
||||||
|
|
||||||
// Verify correct German labels from ARB (with umlauts)
|
// Verify correct German labels from ARB (with umlauts).
|
||||||
expect(find.text('\u00dcbersicht'), findsOneWidget);
|
// 'Übersicht' appears in both the bottom nav and the HomeScreen AppBar.
|
||||||
|
expect(find.text('\u00dcbersicht'), findsWidgets);
|
||||||
expect(find.text('R\u00e4ume'), findsOneWidget);
|
expect(find.text('R\u00e4ume'), findsOneWidget);
|
||||||
expect(find.text('Einstellungen'), findsOneWidget);
|
expect(find.text('Einstellungen'), findsOneWidget);
|
||||||
});
|
});
|
||||||
@@ -67,22 +73,24 @@ void main() {
|
|||||||
testWidgets('tapping a destination changes the selected tab',
|
testWidgets('tapping a destination changes the selected tab',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(buildApp());
|
await tester.pumpWidget(buildApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Initially on Home tab (index 0) -- verify home empty state is shown
|
// Initially on Home tab (index 0) -- verify home first-run empty state
|
||||||
// (dailyPlanNoTasks text from the daily plan empty state)
|
|
||||||
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
||||||
|
|
||||||
// Tap the Rooms tab (second destination)
|
// Tap the Rooms tab (second destination)
|
||||||
await tester.tap(find.text('R\u00e4ume'));
|
await tester.tap(find.text('R\u00e4ume'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Verify we see Rooms content now (empty state)
|
// Verify we see Rooms content now (empty state)
|
||||||
expect(find.text('Hier ist noch alles leer!'), findsOneWidget);
|
expect(find.text('Hier ist noch alles leer!'), findsOneWidget);
|
||||||
|
|
||||||
// Tap the Settings tab (third destination)
|
// Tap the Settings tab (third destination)
|
||||||
await tester.tap(find.text('Einstellungen'));
|
await tester.tap(find.text('Einstellungen'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Verify we see Settings content now
|
// Verify we see Settings content now
|
||||||
expect(find.text('Darstellung'), findsOneWidget);
|
expect(find.text('Darstellung'), findsOneWidget);
|
||||||
|
|||||||
Reference in New Issue
Block a user