Compare commits
28 Commits
v1.1.3
...
22a0f2f99b
| Author | SHA1 | Date | |
|---|---|---|---|
| 22a0f2f99b | |||
| 35905af70c | |||
| 80e701187e | |||
| 510529a950 | |||
| 11c70f63ae | |||
| d83e6332cd | |||
| 8af0b1b4e5 | |||
| 8a0b69b688 | |||
| 1fd6c05f0f | |||
| c482f16b8d | |||
| 8a3fb65e20 | |||
| 6db4611719 | |||
| 6133c977f5 | |||
| 1b1b981dac | |||
| 3bfa411d29 | |||
| b2f14dcd97 | |||
| 4b51f5fa04 | |||
| a2cef91d7e | |||
| cff5f9e67b | |||
| 5fb688fc22 | |||
| aed676c236 | |||
| b00ed8fac1 | |||
| 1f59e2ef8e | |||
| de6f5a6784 | |||
| 3d28aba0db | |||
| 92de2bd7de | |||
| bca7e391ad | |||
| 3902755f61 |
120
.gitea/workflows/ci.yaml
Normal file
120
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags-ignore:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
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
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
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
|
||||||
|
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
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Static analysis
|
||||||
|
run: flutter analyze --no-pub
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: flutter test
|
||||||
|
|
||||||
|
- name: Check outdated dependencies
|
||||||
|
run: dart pub outdated
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Trivy filesystem scan
|
||||||
|
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 wget apt-transport-https gnupg lsb-release
|
||||||
|
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | $SUDO tee /usr/share/keyrings/trivy.gpg > /dev/null
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | $SUDO tee /etc/apt/sources.list.d/trivy.list
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y trivy
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
$SUDO apk add --no-cache trivy || (wget -qO trivy.tar.gz https://github.com/aquasecurity/trivy/releases/latest/download/trivy_0.62.1_Linux-64bit.tar.gz && tar xzf trivy.tar.gz trivy && $SUDO mv trivy /usr/local/bin/)
|
||||||
|
fi
|
||||||
|
trivy filesystem --severity HIGH,CRITICAL --exit-code 0 .
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Build debug APK
|
||||||
|
run: flutter build apk --debug
|
||||||
@@ -7,7 +7,118 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
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
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
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
|
||||||
|
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
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Static analysis
|
||||||
|
run: flutter analyze --no-pub
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: flutter test
|
||||||
|
|
||||||
|
- name: Check outdated dependencies
|
||||||
|
run: dart pub outdated
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Trivy filesystem scan
|
||||||
|
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 wget apt-transport-https gnupg lsb-release
|
||||||
|
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | $SUDO tee /usr/share/keyrings/trivy.gpg > /dev/null
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | $SUDO tee /etc/apt/sources.list.d/trivy.list
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y trivy
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
$SUDO apk add --no-cache trivy || (wget -qO trivy.tar.gz https://github.com/aquasecurity/trivy/releases/latest/download/trivy_0.62.1_Linux-64bit.tar.gz && tar xzf trivy.tar.gz trivy && $SUDO mv trivy /usr/local/bin/)
|
||||||
|
fi
|
||||||
|
trivy filesystem --severity HIGH,CRITICAL --exit-code 0 .
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Build debug APK
|
||||||
|
run: flutter build apk --debug
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
|
needs: ci
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ A local-first Flutter app for organizing household chores, built for personal/co
|
|||||||
|
|
||||||
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.2 Polish & Task Management
|
||||||
|
|
||||||
|
**Goal:** Add task delete with smart soft/hard behavior, rework the task creation frequency picker for better UX, and clean up dead code from v1.0.
|
||||||
|
|
||||||
|
**Target features:**
|
||||||
|
- Delete action in task edit form (hard delete if never completed, soft delete if completed at least once)
|
||||||
|
- Intuitive "Every [N] [unit]" frequency picker replacing the flat preset chip grid
|
||||||
|
- Common frequency shortcuts (daily, weekly, biweekly, monthly) as quick-select
|
||||||
|
- Dead code cleanup (orphaned v1.0 daily plan files)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Validated
|
### Validated
|
||||||
@@ -27,9 +37,11 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
|
|||||||
|
|
||||||
### Active
|
### Active
|
||||||
|
|
||||||
- [ ] Data export/import (JSON)
|
- [ ] Task delete with smart soft/hard behavior
|
||||||
- [ ] English localization
|
- [ ] Task creation frequency picker UX rework
|
||||||
- [ ] Room cover photos from camera or gallery
|
- [ ] Dead code cleanup (v1.0 daily plan files)
|
||||||
|
- [ ] Data export/import (JSON) — deferred
|
||||||
|
- [ ] English localization — deferred
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
@@ -46,6 +58,7 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
|
|||||||
- Weekly/monthly calendar views — date strip is sufficient for task app
|
- Weekly/monthly calendar views — date strip is sufficient for task app
|
||||||
- Drag tasks between days — tasks auto-schedule based on frequency
|
- Drag tasks between days — tasks auto-schedule based on frequency
|
||||||
- Calendar sync (Google/Apple) — contradicts local-first, offline-only design
|
- Calendar sync (Google/Apple) — contradicts local-first, offline-only design
|
||||||
|
- Room cover photos from camera or gallery — dropped, clean design preferred
|
||||||
- Statistics & insights dashboard — v2.0
|
- Statistics & insights dashboard — v2.0
|
||||||
- Onboarding wizard — v2.0
|
- Onboarding wizard — v2.0
|
||||||
- Custom accent color picker — v2.0
|
- Custom accent color picker — v2.0
|
||||||
@@ -90,4 +103,4 @@ Users can see what needs doing today, mark it done, and trust the app to schedul
|
|||||||
| PopupMenuButton for sort UI | Material 3 AppBar action pattern, overlay menu | Good — clean integration in both HomeScreen and TaskListScreen AppBars |
|
| PopupMenuButton for sort UI | Material 3 AppBar action pattern, overlay menu | Good — clean integration in both HomeScreen and TaskListScreen AppBars |
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last updated: 2026-03-16 after v1.1 milestone completed*
|
*Last updated: 2026-03-18 after v1.2 milestone started*
|
||||||
|
|||||||
91
.planning/REQUIREMENTS.md
Normal file
91
.planning/REQUIREMENTS.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Requirements: HouseHoldKeaper
|
||||||
|
|
||||||
|
**Defined:** 2026-03-18
|
||||||
|
**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.2 Requirements
|
||||||
|
|
||||||
|
Requirements for milestone v1.2 Polish & Task Management. Each maps to roadmap phases.
|
||||||
|
|
||||||
|
### Task Delete
|
||||||
|
|
||||||
|
- [x] **DEL-01**: User can delete a task from the task edit form via a clearly visible delete action
|
||||||
|
- [x] **DEL-02**: Deleting a task that has never been completed removes it from the database entirely (hard delete)
|
||||||
|
- [x] **DEL-03**: Deleting a task that has been completed at least once deactivates it instead (soft delete) — task is hidden from all active views but preserved in the database for future statistics
|
||||||
|
- [x] **DEL-04**: User sees a confirmation before deleting/deactivating a task
|
||||||
|
|
||||||
|
### Task Creation UX
|
||||||
|
|
||||||
|
- [x] **TCX-01**: Frequency picker presents an intuitive "Every [N] [unit]" interface instead of a flat grid of preset chips
|
||||||
|
- [x] **TCX-02**: Common frequencies (daily, weekly, biweekly, monthly) are available as quick-select shortcuts without scrolling through all options
|
||||||
|
- [x] **TCX-03**: User can set any arbitrary interval (e.g., every 5 days, every 3 weeks, every 2 months) without needing to select "Custom" first
|
||||||
|
- [x] **TCX-04**: The frequency picker preserves all existing interval types and scheduling behavior (calendar-anchored monthly/quarterly/yearly with anchor memory)
|
||||||
|
|
||||||
|
### Cleanup
|
||||||
|
|
||||||
|
- [x] **CLN-01**: Dead code from v1.0 daily plan (daily_plan_providers.dart, daily_plan_task_row.dart, progress_card.dart) is removed without breaking notification service (DailyPlanDao must be preserved)
|
||||||
|
|
||||||
|
## Future Requirements
|
||||||
|
|
||||||
|
Deferred to future release. Tracked but not in current roadmap.
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
- **DATA-01**: User can export all data as JSON
|
||||||
|
- **DATA-02**: User can import data from JSON backup
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
|
||||||
|
- **LOC-01**: User can switch UI language to English
|
||||||
|
|
||||||
|
### v2.0
|
||||||
|
|
||||||
|
- **ONB-01**: Onboarding wizard for first-time users
|
||||||
|
- **ACC-01**: User can pick a custom accent color for the app theme
|
||||||
|
- **STAT-01**: Statistics & insights dashboard showing task completion trends
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
Explicitly excluded. Documented to prevent scope creep.
|
||||||
|
|
||||||
|
| Feature | Reason |
|
||||||
|
|---------|--------|
|
||||||
|
| Room cover photos from camera or gallery | Dropped — clean design system preferred |
|
||||||
|
| User accounts & cloud sync | Local-only by design |
|
||||||
|
| Leaderboards & points ranking | Not a gamification app |
|
||||||
|
| Subscription model / in-app purchases | Free forever |
|
||||||
|
| Family profile sharing across devices | Single-device app |
|
||||||
|
| Server-side infrastructure | Zero backend |
|
||||||
|
| AI-powered task suggestions | Overkill for curated templates |
|
||||||
|
| 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 |
|
||||||
|
| Tablet-optimized layout | Future enhancement |
|
||||||
|
| Weekly/monthly calendar views | Date strip is sufficient for task app |
|
||||||
|
| Drag tasks between days | Tasks auto-schedule based on frequency |
|
||||||
|
| Calendar sync (Google/Apple) | Contradicts local-first, offline-only design |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
Which phases cover which requirements. Updated during roadmap creation.
|
||||||
|
|
||||||
|
| Requirement | Phase | Status |
|
||||||
|
|-------------|-------|--------|
|
||||||
|
| DEL-01 | Phase 8 | Planned |
|
||||||
|
| DEL-02 | Phase 8 | Planned |
|
||||||
|
| DEL-03 | Phase 8 | Planned |
|
||||||
|
| DEL-04 | Phase 8 | Planned |
|
||||||
|
| TCX-01 | Phase 9 | Planned |
|
||||||
|
| TCX-02 | Phase 9 | Planned |
|
||||||
|
| TCX-03 | Phase 9 | Planned |
|
||||||
|
| TCX-04 | Phase 9 | Planned |
|
||||||
|
| CLN-01 | Phase 10 | Planned |
|
||||||
|
|
||||||
|
**Coverage:**
|
||||||
|
- v1.2 requirements: 9 total
|
||||||
|
- Mapped to phases: 9
|
||||||
|
- Unmapped: 0
|
||||||
|
|
||||||
|
---
|
||||||
|
*Requirements defined: 2026-03-18*
|
||||||
|
*Last updated: 2026-03-18 after roadmap creation (phases 8-10)*
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- ✅ **v1.0 MVP** — Phases 1-4 (shipped 2026-03-16)
|
- ✅ **v1.0 MVP** — Phases 1-4 (shipped 2026-03-16)
|
||||||
- ✅ **v1.1 Calendar & Polish** — Phases 5-7 (shipped 2026-03-16)
|
- ✅ **v1.1 Calendar & Polish** — Phases 5-7 (shipped 2026-03-16)
|
||||||
|
- **v1.2 Polish & Task Management** — Phases 8-10 (in progress)
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
@@ -30,6 +31,56 @@ See `milestones/v1.1-ROADMAP.md` for full phase details.
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
**v1.2 Polish & Task Management (Phases 8-10):**
|
||||||
|
|
||||||
|
- [x] **Phase 8: Task Delete** - Add smart delete action to tasks — hard delete if never completed, soft delete (deactivate) if completed at least once (completed 2026-03-18)
|
||||||
|
- [x] **Phase 9: Task Creation UX** - Rework the frequency picker from flat preset chips to an intuitive "Every N units" interface with quick-select shortcuts (completed 2026-03-18)
|
||||||
|
- [x] **Phase 10: Dead Code Cleanup** - Remove orphaned v1.0 daily plan files and verify no regressions (completed 2026-03-19)
|
||||||
|
|
||||||
|
## Phase Details
|
||||||
|
|
||||||
|
### Phase 8: Task Delete
|
||||||
|
**Goal**: Users can remove tasks they no longer need, with smart preservation of completion history for future statistics
|
||||||
|
**Depends on**: Phase 7 (v1.1 shipped — calendar, history, and sorting all in place)
|
||||||
|
**Requirements**: DEL-01, DEL-02, DEL-03, DEL-04
|
||||||
|
**Plans:** 2/2 plans complete
|
||||||
|
Plans:
|
||||||
|
- [ ] 08-01-PLAN.md — Data layer: isActive column, schema migration, DAO filters and methods
|
||||||
|
- [ ] 08-02-PLAN.md — UI layer: delete button, confirmation dialog, smart delete provider
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. The task edit form has a clearly visible delete action (button or icon)
|
||||||
|
2. Deleting a task with zero completions removes it from the database entirely
|
||||||
|
3. Deleting a task with one or more completions sets it to inactive/archived — the task disappears from all active views (calendar, room task lists) but its completion records remain in the database
|
||||||
|
4. A confirmation dialog appears before any delete/archive action
|
||||||
|
5. The tasks table has an `isActive` (or equivalent) column, with all existing tasks defaulting to active via migration
|
||||||
|
|
||||||
|
### Phase 9: Task Creation UX
|
||||||
|
**Goal**: Users can set any recurring frequency intuitively without hunting through a grid of preset chips — common frequencies are one tap away, custom intervals are freeform
|
||||||
|
**Depends on**: Phase 8
|
||||||
|
**Requirements**: TCX-01, TCX-02, TCX-03, TCX-04
|
||||||
|
**Plans:** 1/1 plans complete
|
||||||
|
Plans:
|
||||||
|
- [ ] 09-01-PLAN.md — Rework frequency picker: 4 shortcut chips + freeform "Every N units" picker
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. The frequency section presents a primary "Every [N] [unit]" picker where users can type a number and select days/weeks/months
|
||||||
|
2. Common frequencies (daily, weekly, biweekly, monthly) are available as quick-select shortcuts that populate the picker
|
||||||
|
3. Any arbitrary interval is settable without a separate "Custom" mode — the picker is inherently freeform
|
||||||
|
4. All existing interval types and calendar-anchored scheduling behavior continue to work correctly (monthly/quarterly/yearly anchor memory)
|
||||||
|
5. Existing tasks load their current interval into the new picker correctly in edit mode
|
||||||
|
|
||||||
|
### Phase 10: Dead Code Cleanup
|
||||||
|
**Goal**: Remove orphaned v1.0 daily plan files that are no longer used after the calendar strip replacement, keeping the codebase clean
|
||||||
|
**Depends on**: Phase 8 (cleanup after feature work is done)
|
||||||
|
**Requirements**: CLN-01
|
||||||
|
**Plans:** 1/1 plans complete
|
||||||
|
Plans:
|
||||||
|
- [x] 10-01-PLAN.md — Delete 3 orphaned presentation files, remove DailyPlanState, verify zero regressions (completed 2026-03-19)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. daily_plan_providers.dart, daily_plan_task_row.dart, and progress_card.dart are deleted
|
||||||
|
2. DailyPlanDao is preserved (still used by notification service)
|
||||||
|
3. All 108+ tests pass after cleanup
|
||||||
|
4. `dart analyze` reports zero issues
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||||
@@ -41,3 +92,6 @@ See `milestones/v1.1-ROADMAP.md` for full phase details.
|
|||||||
| 5. Calendar Strip | v1.1 | 2/2 | Complete | 2026-03-16 |
|
| 5. Calendar Strip | v1.1 | 2/2 | Complete | 2026-03-16 |
|
||||||
| 6. Task History | v1.1 | 1/1 | Complete | 2026-03-16 |
|
| 6. Task History | v1.1 | 1/1 | Complete | 2026-03-16 |
|
||||||
| 7. Task Sorting | v1.1 | 2/2 | Complete | 2026-03-16 |
|
| 7. Task Sorting | v1.1 | 2/2 | Complete | 2026-03-16 |
|
||||||
|
| 8. Task Delete | 2/2 | Complete | 2026-03-18 | - |
|
||||||
|
| 9. Task Creation UX | 1/1 | Complete | 2026-03-18 | - |
|
||||||
|
| 10. Dead Code Cleanup | v1.2 | Complete | 2026-03-19 | 2026-03-19 |
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.1
|
milestone: v1.0
|
||||||
milestone_name: Calendar & Polish
|
milestone_name: milestone
|
||||||
status: completed
|
status: completed
|
||||||
stopped_at: Milestone v1.1 archived
|
stopped_at: Completed 10-dead-code-cleanup 10-01-PLAN.md
|
||||||
last_updated: "2026-03-16T23:26:00.000Z"
|
last_updated: "2026-03-19T07:29:08.098Z"
|
||||||
last_activity: 2026-03-16 — Milestone v1.1 archived
|
last_activity: 2026-03-19 — Deleted orphaned v1.0 daily plan files and removed DailyPlanState
|
||||||
progress:
|
progress:
|
||||||
total_phases: 3
|
total_phases: 3
|
||||||
completed_phases: 3
|
completed_phases: 3
|
||||||
total_plans: 5
|
total_plans: 4
|
||||||
completed_plans: 5
|
completed_plans: 4
|
||||||
percent: 100
|
percent: 100
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,35 +18,48 @@ progress:
|
|||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
|
|
||||||
See: .planning/PROJECT.md (updated 2026-03-16)
|
See: .planning/PROJECT.md (updated 2026-03-18)
|
||||||
|
|
||||||
**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:** Planning next milestone
|
**Current focus:** v1.2 Polish & Task Management — Phase 8: Task Delete
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Milestone: v1.1 Calendar & Polish — SHIPPED
|
Milestone: v1.2 Polish & Task Management
|
||||||
Status: Milestone Complete
|
Phase: 10 — Dead Code Cleanup (complete)
|
||||||
Last activity: 2026-03-16 — Archived milestone v1.1
|
Status: Completed 10-dead-code-cleanup 10-01-PLAN.md
|
||||||
|
Last activity: 2026-03-19 — Deleted orphaned v1.0 daily plan files and removed DailyPlanState
|
||||||
|
|
||||||
```
|
```
|
||||||
Progress: [██████████] 100% (v1.1 shipped)
|
Progress: [██████████] 100% (1/1 plans in phase 10)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
| Metric | v1.0 | v1.1 |
|
| Metric | v1.0 | v1.1 | v1.2 |
|
||||||
|--------|------|------|
|
|--------|------|------|------|
|
||||||
| Phases | 4 | 3 |
|
| Phases | 4 | 3 | 3 planned |
|
||||||
| Plans | 13 | 5 |
|
| Plans | 13 | 5 | TBD |
|
||||||
| LOC (lib) | 7,773 | 9,051 |
|
| LOC (lib) | 7,773 | 9,051 | TBD |
|
||||||
| Tests | 89 | 108 |
|
| Tests | 89 | 108 | TBD |
|
||||||
|
| Phase 08-task-delete P01 | 9 | 2 tasks | 11 files |
|
||||||
|
| Phase 08-task-delete P02 | 2 | 2 tasks | 3 files |
|
||||||
|
| Phase 09-task-creation-ux P01 | 2 | 1 tasks | 4 files |
|
||||||
|
| Phase 10-dead-code-cleanup P01 | 5 | 2 tasks | 4 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
### Decisions
|
### Decisions
|
||||||
|
|
||||||
Decisions archived to PROJECT.md Key Decisions table.
|
Decisions archived to PROJECT.md Key Decisions table.
|
||||||
|
- [Phase 08-task-delete]: isActive uses BoolColumn.withDefault(true) so existing rows are automatically active after migration without backfill
|
||||||
|
- [Phase 08-task-delete]: Migration uses from==2 (not from<3) for addColumn to avoid duplicate-column error when createTable already includes isActive in current schema definition
|
||||||
|
- [Phase 08-task-delete]: Migration tests updated to only test v1->v3 and v2->v3 paths since AppDatabase.schemaVersion=3 always migrates to v3
|
||||||
|
- [Phase 08-task-delete]: smartDeleteTask kept separate from deleteTask to preserve existing hard-delete path for cascade/other uses
|
||||||
|
- [Phase 08-task-delete]: Delete button placed after history section with divider, visible only in edit mode
|
||||||
|
- [Phase 09-task-creation-ux]: Picker is single source of truth: _resolveFrequency() reads from picker always; _ShortcutFrequency enum handles bidirectional sync via toPickerValues()/fromPickerValues()
|
||||||
|
- [Phase 10-dead-code-cleanup]: DailyPlanDao kept in database.dart — still used by settings service; only the three presentation layer files were deleted
|
||||||
|
- [Phase 10-dead-code-cleanup]: TaskWithRoom retained in daily_plan_models.dart — actively used by calendar_dao.dart, calendar_providers.dart, and related calendar files
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -54,11 +67,11 @@ None.
|
|||||||
|
|
||||||
### Blockers/Concerns
|
### Blockers/Concerns
|
||||||
|
|
||||||
- Dead code from v1.0: daily_plan_providers.dart, daily_plan_task_row.dart, progress_card.dart (DailyPlanDao still used by notification service)
|
None.
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-16
|
Last session: 2026-03-19T00:05:00Z
|
||||||
Stopped at: Milestone v1.1 archived
|
Stopped at: Completed 10-dead-code-cleanup 10-01-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
Next action: /gsd:new-milestone
|
Next action: Phase 10 complete
|
||||||
|
|||||||
310
.planning/phases/08-task-delete/08-01-PLAN.md
Normal file
310
.planning/phases/08-task-delete/08-01-PLAN.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
---
|
||||||
|
phase: 08-task-delete
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- lib/core/database/database.dart
|
||||||
|
- lib/features/tasks/data/tasks_dao.dart
|
||||||
|
- lib/features/home/data/calendar_dao.dart
|
||||||
|
- lib/features/home/data/daily_plan_dao.dart
|
||||||
|
- lib/features/rooms/data/rooms_dao.dart
|
||||||
|
- test/features/tasks/data/tasks_dao_test.dart
|
||||||
|
autonomous: true
|
||||||
|
requirements: [DEL-02, DEL-03]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Active tasks appear in all views (calendar, room task lists, daily plan)"
|
||||||
|
- "Deactivated tasks are hidden from all views"
|
||||||
|
- "Hard delete removes task and completions from DB entirely"
|
||||||
|
- "Soft delete sets isActive to false without removing data"
|
||||||
|
- "Existing tasks default to active after migration"
|
||||||
|
artifacts:
|
||||||
|
- path: "lib/core/database/database.dart"
|
||||||
|
provides: "isActive BoolColumn on Tasks table, schema v3, migration"
|
||||||
|
contains: "isActive"
|
||||||
|
- path: "lib/features/tasks/data/tasks_dao.dart"
|
||||||
|
provides: "softDeleteTask, getCompletionCount, isActive filter on watchTasksInRoom"
|
||||||
|
exports: ["softDeleteTask", "getCompletionCount"]
|
||||||
|
- path: "lib/features/home/data/calendar_dao.dart"
|
||||||
|
provides: "isActive=true filter on all 6 task queries + getTaskCount"
|
||||||
|
contains: "isActive"
|
||||||
|
- path: "lib/features/home/data/daily_plan_dao.dart"
|
||||||
|
provides: "isActive=true filter on watchAllTasksWithRoomName and count queries"
|
||||||
|
contains: "isActive"
|
||||||
|
- path: "lib/features/rooms/data/rooms_dao.dart"
|
||||||
|
provides: "isActive=true filter on task queries in watchRoomWithStats"
|
||||||
|
contains: "isActive"
|
||||||
|
- path: "test/features/tasks/data/tasks_dao_test.dart"
|
||||||
|
provides: "Tests for softDeleteTask, getCompletionCount, isActive filtering"
|
||||||
|
key_links:
|
||||||
|
- from: "lib/core/database/database.dart"
|
||||||
|
to: "All DAOs"
|
||||||
|
via: "Tasks table schema with isActive column"
|
||||||
|
pattern: "BoolColumn.*isActive.*withDefault.*true"
|
||||||
|
- from: "lib/features/tasks/data/tasks_dao.dart"
|
||||||
|
to: "lib/features/tasks/presentation/task_providers.dart"
|
||||||
|
via: "softDeleteTask and getCompletionCount methods"
|
||||||
|
pattern: "softDeleteTask|getCompletionCount"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add isActive column to the Tasks table and filter all DAO queries to exclude deactivated tasks.
|
||||||
|
|
||||||
|
Purpose: Foundation for smart task deletion — the isActive column enables soft-delete behavior where completed tasks are hidden but preserved for statistics, while hard-delete removes tasks with no history entirely.
|
||||||
|
|
||||||
|
Output: Schema v3 with isActive column, all DAO queries filtering active-only, softDeleteTask and getCompletionCount DAO methods, passing 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/08-task-delete/08-CONTEXT.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
From lib/core/database/database.dart (current schema):
|
||||||
|
```dart
|
||||||
|
class Tasks extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get roomId => integer().references(Rooms, #id)();
|
||||||
|
TextColumn get name => text().withLength(min: 1, max: 200)();
|
||||||
|
TextColumn get description => text().nullable()();
|
||||||
|
IntColumn get intervalType => intEnum<IntervalType>()();
|
||||||
|
IntColumn get intervalDays => integer().withDefault(const Constant(1))();
|
||||||
|
IntColumn get anchorDay => integer().nullable()();
|
||||||
|
IntColumn get effortLevel => intEnum<EffortLevel>()();
|
||||||
|
DateTimeColumn get nextDueDate => dateTime()();
|
||||||
|
DateTimeColumn get createdAt => dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current schema version
|
||||||
|
int get schemaVersion => 2;
|
||||||
|
|
||||||
|
// Current migration strategy
|
||||||
|
MigrationStrategy get migration {
|
||||||
|
return MigrationStrategy(
|
||||||
|
onCreate: (Migrator m) async { await m.createAll(); },
|
||||||
|
onUpgrade: (Migrator m, int from, int to) async {
|
||||||
|
if (from < 2) {
|
||||||
|
await m.createTable(rooms);
|
||||||
|
await m.createTable(tasks);
|
||||||
|
await m.createTable(taskCompletions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeOpen: (details) async {
|
||||||
|
await customStatement('PRAGMA foreign_keys = ON');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/data/tasks_dao.dart (existing methods):
|
||||||
|
```dart
|
||||||
|
class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
|
||||||
|
Stream<List<Task>> watchTasksInRoom(int roomId);
|
||||||
|
Future<int> insertTask(TasksCompanion task);
|
||||||
|
Future<bool> updateTask(Task task);
|
||||||
|
Future<void> deleteTask(int taskId); // hard delete with cascade
|
||||||
|
Future<void> completeTask(int taskId, {DateTime? now});
|
||||||
|
Stream<List<TaskCompletion>> watchCompletionsForTask(int taskId);
|
||||||
|
Future<int> getOverdueTaskCount(int roomId, {DateTime? today});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/data/calendar_dao.dart (6 queries needing filter):
|
||||||
|
```dart
|
||||||
|
class CalendarDao extends DatabaseAccessor<AppDatabase> with _$CalendarDaoMixin {
|
||||||
|
Stream<List<TaskWithRoom>> watchTasksForDate(DateTime date);
|
||||||
|
Stream<List<TaskWithRoom>> watchTasksForDateInRoom(DateTime date, int roomId);
|
||||||
|
Stream<List<TaskWithRoom>> watchOverdueTasks(DateTime referenceDate);
|
||||||
|
Stream<List<TaskWithRoom>> watchOverdueTasksInRoom(DateTime referenceDate, int roomId);
|
||||||
|
Future<int> getTaskCount();
|
||||||
|
Future<int> getTaskCountInRoom(int roomId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/home/data/daily_plan_dao.dart (3 queries needing filter):
|
||||||
|
```dart
|
||||||
|
class DailyPlanDao extends DatabaseAccessor<AppDatabase> with _$DailyPlanDaoMixin {
|
||||||
|
Stream<List<TaskWithRoom>> watchAllTasksWithRoomName();
|
||||||
|
Future<int> getOverdueAndTodayTaskCount({DateTime? today});
|
||||||
|
Future<int> getOverdueTaskCount({DateTime? today});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/rooms/data/rooms_dao.dart (task query in watchRoomWithStats):
|
||||||
|
```dart
|
||||||
|
// Inside watchRoomWithStats:
|
||||||
|
final taskList = await (select(tasks)
|
||||||
|
..where((t) => t.roomId.equals(room.id)))
|
||||||
|
.get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Test pattern from test/features/tasks/data/tasks_dao_test.dart:
|
||||||
|
```dart
|
||||||
|
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(); });
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Add isActive column, migration, and new DAO methods</name>
|
||||||
|
<files>
|
||||||
|
lib/core/database/database.dart,
|
||||||
|
lib/features/tasks/data/tasks_dao.dart,
|
||||||
|
test/features/tasks/data/tasks_dao_test.dart
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Test: softDeleteTask sets isActive to false (task remains in DB but isActive == false)
|
||||||
|
- Test: getCompletionCount returns 0 for task with no completions
|
||||||
|
- Test: getCompletionCount returns correct count for task with completions
|
||||||
|
- Test: watchTasksInRoom excludes tasks where isActive is false
|
||||||
|
- Test: getOverdueTaskCount excludes tasks where isActive is false
|
||||||
|
- Test: existing hard deleteTask still works (removes task and completions)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. In database.dart Tasks table, add: `BoolColumn get isActive => boolean().withDefault(const Constant(true))();`
|
||||||
|
|
||||||
|
2. Bump schemaVersion to 3.
|
||||||
|
|
||||||
|
3. Update migration onUpgrade — add `from < 3` block:
|
||||||
|
```dart
|
||||||
|
if (from < 3) {
|
||||||
|
await m.addColumn(tasks, tasks.isActive);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This uses Drift's addColumn which handles the ALTER TABLE and the default value for existing rows.
|
||||||
|
|
||||||
|
4. In tasks_dao.dart, add isActive filter to watchTasksInRoom:
|
||||||
|
```dart
|
||||||
|
..where((t) => t.roomId.equals(roomId) & t.isActive.equals(true))
|
||||||
|
```
|
||||||
|
|
||||||
|
5. In tasks_dao.dart, add isActive filter to getOverdueTaskCount task query:
|
||||||
|
```dart
|
||||||
|
..where((t) => t.roomId.equals(roomId) & t.isActive.equals(true))
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Add softDeleteTask method to TasksDao:
|
||||||
|
```dart
|
||||||
|
Future<void> softDeleteTask(int taskId) {
|
||||||
|
return (update(tasks)..where((t) => t.id.equals(taskId)))
|
||||||
|
.write(const TasksCompanion(isActive: Value(false)));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Add getCompletionCount method to TasksDao:
|
||||||
|
```dart
|
||||||
|
Future<int> getCompletionCount(int taskId) async {
|
||||||
|
final count = taskCompletions.id.count();
|
||||||
|
final query = selectOnly(taskCompletions)
|
||||||
|
..addColumns([count])
|
||||||
|
..where(taskCompletions.taskId.equals(taskId));
|
||||||
|
final result = await query.getSingle();
|
||||||
|
return result.read(count) ?? 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Run `dart run build_runner build --delete-conflicting-outputs` to regenerate Drift code.
|
||||||
|
|
||||||
|
9. Write tests in tasks_dao_test.dart following existing test patterns (NativeDatabase.memory, setUp/tearDown).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test test/features/tasks/data/tasks_dao_test.dart --reporter compact</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- Tasks table has isActive BoolColumn with default true
|
||||||
|
- Schema version is 3 with working migration
|
||||||
|
- softDeleteTask sets isActive=false without removing data
|
||||||
|
- getCompletionCount returns accurate count
|
||||||
|
- watchTasksInRoom only returns active tasks
|
||||||
|
- getOverdueTaskCount only counts active tasks
|
||||||
|
- All new tests pass, all existing tests pass
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add isActive filters to CalendarDao, DailyPlanDao, and RoomsDao</name>
|
||||||
|
<files>
|
||||||
|
lib/features/home/data/calendar_dao.dart,
|
||||||
|
lib/features/home/data/daily_plan_dao.dart,
|
||||||
|
lib/features/rooms/data/rooms_dao.dart
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. In calendar_dao.dart, add `& tasks.isActive.equals(true)` to the WHERE clause of ALL 6 query methods:
|
||||||
|
- watchTasksForDate: add to existing `query.where(...)` expression
|
||||||
|
- watchTasksForDateInRoom: add to existing `query.where(...)` expression
|
||||||
|
- watchOverdueTasks: add to existing `query.where(...)` expression
|
||||||
|
- watchOverdueTasksInRoom: add to existing `query.where(...)` expression
|
||||||
|
- getTaskCount: add `..where(tasks.isActive.equals(true))` to selectOnly
|
||||||
|
- getTaskCountInRoom: add `& tasks.isActive.equals(true)` to existing where
|
||||||
|
|
||||||
|
2. In daily_plan_dao.dart, add isActive filter to all 3 query methods:
|
||||||
|
- watchAllTasksWithRoomName: add `query.where(tasks.isActive.equals(true));` after the join
|
||||||
|
- getOverdueAndTodayTaskCount: add `& tasks.isActive.equals(true)` to existing where
|
||||||
|
- getOverdueTaskCount: add `& tasks.isActive.equals(true)` to existing where
|
||||||
|
|
||||||
|
3. In rooms_dao.dart watchRoomWithStats method, filter the task query to active-only:
|
||||||
|
```dart
|
||||||
|
final taskList = await (select(tasks)
|
||||||
|
..where((t) => t.roomId.equals(room.id) & t.isActive.equals(true)))
|
||||||
|
.get();
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run `dart run build_runner build --delete-conflicting-outputs` to regenerate if needed.
|
||||||
|
|
||||||
|
5. Run `dart analyze` to confirm no issues.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test --reporter compact && dart analyze --fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- All 6 CalendarDao queries filter by isActive=true
|
||||||
|
- All 3 DailyPlanDao queries filter by isActive=true
|
||||||
|
- RoomsDao watchRoomWithStats only counts active tasks
|
||||||
|
- All 137+ existing tests still pass
|
||||||
|
- dart analyze reports zero issues
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Schema version is 3, migration adds isActive column with default true
|
||||||
|
- softDeleteTask and getCompletionCount methods exist on TasksDao
|
||||||
|
- Every query across TasksDao, CalendarDao, DailyPlanDao, and RoomsDao that returns tasks filters by isActive=true
|
||||||
|
- Hard deleteTask (cascade) still works unchanged
|
||||||
|
- All tests pass: `flutter test --reporter compact`
|
||||||
|
- Code quality: `dart analyze --fatal-infos` reports zero issues
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Deactivated tasks (isActive=false) are excluded from ALL active views: calendar day tasks, overdue tasks, room task lists, daily plan, room stats
|
||||||
|
- Existing tasks default to active after schema migration
|
||||||
|
- New DAO methods (softDeleteTask, getCompletionCount) are available for the UI layer
|
||||||
|
- All 137+ tests pass, new DAO tests pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/08-task-delete/08-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
152
.planning/phases/08-task-delete/08-01-SUMMARY.md
Normal file
152
.planning/phases/08-task-delete/08-01-SUMMARY.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
phase: 08-task-delete
|
||||||
|
plan: 01
|
||||||
|
subsystem: database
|
||||||
|
tags: [drift, sqlite, flutter, soft-delete, schema-migration]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- isActive BoolColumn on Tasks table (schema v3)
|
||||||
|
- softDeleteTask(taskId) method on TasksDao
|
||||||
|
- getCompletionCount(taskId) method on TasksDao
|
||||||
|
- isActive=true filter on all task queries across all 4 DAOs
|
||||||
|
- Schema migration from v2 to v3 (addColumn)
|
||||||
|
affects: [08-02, 08-03, delete-dialog, task-providers]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Soft delete via isActive BoolColumn with default true"
|
||||||
|
- "All task-returning DAO queries filter by isActive=true"
|
||||||
|
- "Schema versioning via Drift addColumn migration"
|
||||||
|
- "TDD: RED commit before implementation GREEN commit"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- drift_schemas/household_keeper/drift_schema_v3.json
|
||||||
|
- test/drift/household_keeper/generated/schema_v3.dart
|
||||||
|
modified:
|
||||||
|
- lib/core/database/database.dart
|
||||||
|
- lib/features/tasks/data/tasks_dao.dart
|
||||||
|
- lib/features/home/data/calendar_dao.dart
|
||||||
|
- lib/features/home/data/daily_plan_dao.dart
|
||||||
|
- lib/features/rooms/data/rooms_dao.dart
|
||||||
|
- test/features/tasks/data/tasks_dao_test.dart
|
||||||
|
- test/drift/household_keeper/migration_test.dart
|
||||||
|
- test/drift/household_keeper/generated/schema.dart
|
||||||
|
- test/core/database/database_test.dart
|
||||||
|
- test/features/home/presentation/home_screen_test.dart
|
||||||
|
- test/features/tasks/presentation/task_list_screen_test.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "isActive column uses BoolColumn.withDefault(true) so existing rows are automatically active after migration"
|
||||||
|
- "Migration uses from==2 (not from<3) for addColumn to avoid duplicate-column error when upgrading from v1 (where createTable already includes isActive)"
|
||||||
|
- "Migration tests updated to only test paths ending at v3 (current schemaVersion) since AppDatabase always migrates to its schemaVersion"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Soft-delete pattern: isActive BoolColumn with default true, filter all queries by isActive=true"
|
||||||
|
- "Hard-delete remains via deleteTask(id) which cascades to completions"
|
||||||
|
|
||||||
|
requirements-completed: [DEL-02, DEL-03]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 9min
|
||||||
|
completed: 2026-03-18
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 8 Plan 01: isActive Column and DAO Filtering Summary
|
||||||
|
|
||||||
|
**Drift schema v3 with isActive BoolColumn on Tasks, soft-delete DAO methods, and isActive=true filter applied to all 15 task queries across TasksDao, CalendarDao, DailyPlanDao, and RoomsDao**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 9 min
|
||||||
|
- **Started:** 2026-03-18T19:47:32Z
|
||||||
|
- **Completed:** 2026-03-18T19:56:39Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 11
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- Added `isActive BoolColumn` (default `true`) to Tasks table with schema v3 migration
|
||||||
|
- Added `softDeleteTask(taskId)` and `getCompletionCount(taskId)` to TasksDao
|
||||||
|
- Applied `isActive=true` filter to all task-returning queries across all 4 DAOs (15 total query sites)
|
||||||
|
- 6 new tests passing (softDeleteTask, getCompletionCount, watchTasksInRoom filtering, getOverdueTaskCount filtering, hard deleteTask still works)
|
||||||
|
- All 144 tests pass, dart analyze reports zero issues
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **TDD RED: Failing tests** - `a2cef91` (test)
|
||||||
|
2. **Task 1: Add isActive column, migration v3, softDeleteTask and getCompletionCount** - `4b51f5f` (feat)
|
||||||
|
3. **Task 2: Add isActive filters to CalendarDao, DailyPlanDao, RoomsDao** - `b2f14dc` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `lib/core/database/database.dart` - Added isActive BoolColumn to Tasks, bumped schemaVersion to 3, added from==2 migration
|
||||||
|
- `lib/features/tasks/data/tasks_dao.dart` - Added isActive filter to watchTasksInRoom and getOverdueTaskCount, added softDeleteTask and getCompletionCount methods
|
||||||
|
- `lib/features/home/data/calendar_dao.dart` - Added isActive=true filter to all 6 query methods
|
||||||
|
- `lib/features/home/data/daily_plan_dao.dart` - Added isActive=true filter to all 3 query methods
|
||||||
|
- `lib/features/rooms/data/rooms_dao.dart` - Added isActive=true filter to watchRoomWithStats task query
|
||||||
|
- `test/features/tasks/data/tasks_dao_test.dart` - Added 6 new tests for soft-delete behavior
|
||||||
|
- `test/drift/household_keeper/migration_test.dart` - Updated to test v1→v3 and v2→v3 migrations
|
||||||
|
- `test/drift/household_keeper/generated/schema_v3.dart` - Generated schema snapshot for v3
|
||||||
|
- `test/drift/household_keeper/generated/schema.dart` - Updated to include v3 in versions list
|
||||||
|
- `drift_schemas/household_keeper/drift_schema_v3.json` - v3 schema JSON for Drift migration tooling
|
||||||
|
- `test/core/database/database_test.dart` - Updated schemaVersion assertion from 2 to 3
|
||||||
|
- `test/features/home/presentation/home_screen_test.dart` - Added isActive: true to Task constructor helper
|
||||||
|
- `test/features/tasks/presentation/task_list_screen_test.dart` - Added isActive: true to Task constructor helper
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- `isActive` column uses `BoolColumn.withDefault(const Constant(true))` so all existing rows become active after migration without explicit data backfill
|
||||||
|
- Migration uses `from == 2` (not `from < 3`) for `addColumn` to avoid duplicate-column error when upgrading from v1 where `createTable` already includes the isActive column in the current schema definition
|
||||||
|
- Migration test framework updated to only test paths that end at the current schema version (v3), since `AppDatabase.schemaVersion = 3` means all migrations go to v3
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Fixed Task constructor calls missing isActive parameter in test helpers**
|
||||||
|
- **Found during:** Task 2 (running full test suite)
|
||||||
|
- **Issue:** After adding `isActive` as a required field on the generated `Task` dataclass, two test files with manual `Task(...)` constructors (`home_screen_test.dart`, `task_list_screen_test.dart`) failed to compile
|
||||||
|
- **Fix:** Added `isActive: true` to `_makeTask` helper functions in both files
|
||||||
|
- **Files modified:** `test/features/home/presentation/home_screen_test.dart`, `test/features/tasks/presentation/task_list_screen_test.dart`
|
||||||
|
- **Verification:** flutter test passes, all 144 tests pass
|
||||||
|
- **Committed in:** `b2f14dc` (Task 2 commit)
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] Fixed schemaVersion assertion in database_test.dart**
|
||||||
|
- **Found during:** Task 2 (running full test suite)
|
||||||
|
- **Issue:** `database_test.dart` had `expect(db.schemaVersion, equals(2))` which failed after bumping to v3
|
||||||
|
- **Fix:** Updated assertion to `equals(3)` and renamed test to "has schemaVersion 3"
|
||||||
|
- **Files modified:** `test/core/database/database_test.dart`
|
||||||
|
- **Verification:** Test passes
|
||||||
|
- **Committed in:** `b2f14dc` (Task 2 commit)
|
||||||
|
|
||||||
|
**3. [Rule 1 - Bug] Fixed Drift migration tests for v3 schema**
|
||||||
|
- **Found during:** Task 2 (running full test suite)
|
||||||
|
- **Issue:** Migration tests tested v1→v2 migration, but AppDatabase.schemaVersion=3 causes all migrations to end at v3. Also, the `from < 3` addColumn migration caused a duplicate-column error when migrating from v1 (since createTable already includes isActive)
|
||||||
|
- **Fix:** (a) Generated schema_v3.dart snapshot, (b) Updated migration_test.dart to test v1→v3 and v2→v3, (c) Changed migration to `from == 2` instead of `from < 3`
|
||||||
|
- **Files modified:** `test/drift/household_keeper/migration_test.dart`, `test/drift/household_keeper/generated/schema_v3.dart`, `test/drift/household_keeper/generated/schema.dart`, `drift_schemas/household_keeper/drift_schema_v3.json`
|
||||||
|
- **Verification:** All 3 migration tests pass
|
||||||
|
- **Committed in:** `b2f14dc` (Task 2 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 3 auto-fixed (all Rule 1 bugs caused directly by schema change)
|
||||||
|
**Impact on plan:** All fixes necessary for correctness. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- The Drift migration testing framework requires schema snapshots for each version. Adding schema v3 required regenerating schema files and fixing the migration test to only test paths to the current version.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- `isActive` column and `softDeleteTask`/`getCompletionCount` methods are ready for use by the UI layer (task delete dialog in plan 08-02)
|
||||||
|
- All active views (calendar, room task list, daily plan, room stats) now correctly exclude soft-deleted tasks
|
||||||
|
- Hard delete (deleteTask) remains unchanged and still cascades to completions
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 08-task-delete*
|
||||||
|
*Completed: 2026-03-18*
|
||||||
290
.planning/phases/08-task-delete/08-02-PLAN.md
Normal file
290
.planning/phases/08-task-delete/08-02-PLAN.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
---
|
||||||
|
phase: 08-task-delete
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["08-01"]
|
||||||
|
files_modified:
|
||||||
|
- lib/features/tasks/presentation/task_providers.dart
|
||||||
|
- lib/features/tasks/presentation/task_form_screen.dart
|
||||||
|
autonomous: true
|
||||||
|
requirements: [DEL-01, DEL-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User sees a red delete button at the bottom of the task edit form"
|
||||||
|
- "Tapping delete shows a confirmation dialog before any action"
|
||||||
|
- "Confirming delete on a task with no completions removes it from the database"
|
||||||
|
- "Confirming delete on a task with completions deactivates it (hidden from views)"
|
||||||
|
- "After deletion the user is navigated back to the room task list"
|
||||||
|
artifacts:
|
||||||
|
- path: "lib/features/tasks/presentation/task_form_screen.dart"
|
||||||
|
provides: "Delete button and confirmation dialog in edit mode"
|
||||||
|
contains: "taskDeleteConfirmTitle"
|
||||||
|
- path: "lib/features/tasks/presentation/task_providers.dart"
|
||||||
|
provides: "Smart delete method using getCompletionCount"
|
||||||
|
contains: "softDeleteTask"
|
||||||
|
key_links:
|
||||||
|
- from: "lib/features/tasks/presentation/task_form_screen.dart"
|
||||||
|
to: "lib/features/tasks/presentation/task_providers.dart"
|
||||||
|
via: "TaskActions.smartDeleteTask call from delete button callback"
|
||||||
|
pattern: "smartDeleteTask"
|
||||||
|
- from: "lib/features/tasks/presentation/task_providers.dart"
|
||||||
|
to: "lib/features/tasks/data/tasks_dao.dart"
|
||||||
|
via: "getCompletionCount + conditional deleteTask or softDeleteTask"
|
||||||
|
pattern: "getCompletionCount.*softDeleteTask|deleteTask"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add the delete button and confirmation dialog to the task edit form, with smart delete logic in the provider layer.
|
||||||
|
|
||||||
|
Purpose: Users can remove tasks they no longer need. The smart behavior (hard vs soft delete) is invisible to the user -- they just see "delete" with a confirmation.
|
||||||
|
|
||||||
|
Output: Working delete flow on the task edit form: red button -> confirmation dialog -> smart delete -> navigate back.
|
||||||
|
</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/08-task-delete/08-CONTEXT.md
|
||||||
|
@.planning/phases/08-task-delete/08-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. -->
|
||||||
|
|
||||||
|
From lib/features/tasks/presentation/task_providers.dart (existing TaskActions):
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class TaskActions extends _$TaskActions {
|
||||||
|
@override
|
||||||
|
FutureOr<void> build() {}
|
||||||
|
|
||||||
|
Future<int> createTask({...}) async { ... }
|
||||||
|
Future<void> updateTask(Task task) async { ... }
|
||||||
|
Future<void> deleteTask(int taskId) async { ... } // calls DAO hard delete
|
||||||
|
Future<void> completeTask(int taskId) async { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/data/tasks_dao.dart (after Plan 01):
|
||||||
|
```dart
|
||||||
|
class TasksDao {
|
||||||
|
Future<void> deleteTask(int taskId); // hard delete (cascade)
|
||||||
|
Future<void> softDeleteTask(int taskId); // sets isActive = false
|
||||||
|
Future<int> getCompletionCount(int taskId); // count completions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/presentation/task_form_screen.dart (edit mode section):
|
||||||
|
```dart
|
||||||
|
// History section (edit mode only) — delete button goes AFTER this
|
||||||
|
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!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/l10n/app_de.arb (existing delete l10n strings):
|
||||||
|
```json
|
||||||
|
"taskDeleteConfirmTitle": "Aufgabe l\u00f6schen?",
|
||||||
|
"taskDeleteConfirmMessage": "Die Aufgabe wird unwiderruflich gel\u00f6scht.",
|
||||||
|
"taskDeleteConfirmAction": "L\u00f6schen"
|
||||||
|
```
|
||||||
|
|
||||||
|
Room delete dialog pattern (from lib/features/rooms/presentation/rooms_screen.dart:165-189):
|
||||||
|
```dart
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(l10n.roomDeleteConfirmTitle),
|
||||||
|
content: Text(l10n.roomDeleteConfirmMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () { ... },
|
||||||
|
child: Text(l10n.roomDeleteConfirmAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add smartDeleteTask to TaskActions provider</name>
|
||||||
|
<files>lib/features/tasks/presentation/task_providers.dart</files>
|
||||||
|
<action>
|
||||||
|
Add a `smartDeleteTask` method to the `TaskActions` class in task_providers.dart. This method checks the completion count and routes to hard delete or soft delete accordingly:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
/// Smart delete: hard-deletes tasks with no completions, soft-deletes tasks with completions.
|
||||||
|
Future<void> smartDeleteTask(int taskId) async {
|
||||||
|
final db = ref.read(appDatabaseProvider);
|
||||||
|
final completionCount = await db.tasksDao.getCompletionCount(taskId);
|
||||||
|
if (completionCount == 0) {
|
||||||
|
await db.tasksDao.deleteTask(taskId);
|
||||||
|
} else {
|
||||||
|
await db.tasksDao.softDeleteTask(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the existing `deleteTask` method unchanged (it is still a valid hard delete for other uses like room cascade delete).
|
||||||
|
|
||||||
|
Run `dart run build_runner build --delete-conflicting-outputs` to regenerate the provider code.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && dart analyze --fatal-infos lib/features/tasks/presentation/task_providers.dart</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- smartDeleteTask method exists on TaskActions
|
||||||
|
- Method checks completion count and routes to hard or soft delete
|
||||||
|
- dart analyze passes with zero issues
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add delete button and confirmation dialog to TaskFormScreen</name>
|
||||||
|
<files>lib/features/tasks/presentation/task_form_screen.dart</files>
|
||||||
|
<action>
|
||||||
|
1. In the TaskFormScreen build method's ListView children, AFTER the history section (the existing `if (widget.isEditing) ...` block ending at line ~204), add the delete button section inside the same `if (widget.isEditing)` block:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (widget.isEditing) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
// History ListTile (existing)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.history),
|
||||||
|
title: Text(l10n.taskHistoryTitle),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => showTaskHistorySheet(
|
||||||
|
context: context,
|
||||||
|
taskId: widget.taskId!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// DELETE BUTTON — new
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: theme.colorScheme.error,
|
||||||
|
foregroundColor: theme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: _isLoading ? null : _onDelete,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: Text(l10n.taskDeleteConfirmAction),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add a `_onDelete` method to _TaskFormScreenState:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> _onDelete() async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(l10n.taskDeleteConfirmTitle),
|
||||||
|
content: Text(l10n.taskDeleteConfirmMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(l10n.taskDeleteConfirmAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
await ref.read(taskActionsProvider.notifier).smartDeleteTask(widget.taskId!);
|
||||||
|
if (mounted) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `l10n.cancel` string should already exist from the room delete dialog. If not, use `MaterialLocalizations.of(context).cancelButtonLabel`.
|
||||||
|
|
||||||
|
3. Verify `cancel` l10n key exists. If it does not exist in app_de.arb, check for the existing cancel button pattern in rooms_screen.dart and use the same approach.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter test --reporter compact && dart analyze --fatal-infos</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- Red delete button visible at bottom of task edit form (below history, separated by divider)
|
||||||
|
- Delete button only shows in edit mode (not create mode)
|
||||||
|
- Tapping delete shows AlertDialog with title "Aufgabe loschen?" and error-colored confirm button
|
||||||
|
- Canceling dialog does nothing
|
||||||
|
- Confirming dialog calls smartDeleteTask and pops back to room task list
|
||||||
|
- Button is disabled while loading (_isLoading)
|
||||||
|
- All existing tests pass, dart analyze clean
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Task edit form shows red delete button below history section with divider separator
|
||||||
|
- Delete button is NOT shown in create mode
|
||||||
|
- Tapping delete shows confirmation dialog matching room delete dialog pattern
|
||||||
|
- Confirming deletes/deactivates the task and navigates back
|
||||||
|
- Canceling returns to the form without changes
|
||||||
|
- All tests pass: `flutter test --reporter compact`
|
||||||
|
- Code quality: `dart analyze --fatal-infos` reports zero issues
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Complete delete flow works: open task -> scroll to bottom -> tap delete -> confirm -> back to room task list
|
||||||
|
- Smart delete is invisible to user: tasks with completions are deactivated, tasks without are removed
|
||||||
|
- Delete button follows Material 3 error color pattern
|
||||||
|
- Confirmation dialog uses existing German l10n strings
|
||||||
|
- All 137+ tests pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/08-task-delete/08-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
117
.planning/phases/08-task-delete/08-02-SUMMARY.md
Normal file
117
.planning/phases/08-task-delete/08-02-SUMMARY.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
phase: 08-task-delete
|
||||||
|
plan: 02
|
||||||
|
subsystem: ui
|
||||||
|
tags: [flutter, riverpod, drift, material3, l10n]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 08-task-delete plan 01
|
||||||
|
provides: softDeleteTask and getCompletionCount on TasksDao, isActive column migration
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- smartDeleteTask on TaskActions provider (hard delete if 0 completions, soft delete otherwise)
|
||||||
|
- Red delete button in task edit form with Material 3 error color
|
||||||
|
- Confirmation AlertDialog using existing German l10n strings
|
||||||
|
- Full delete flow: button -> dialog -> smart delete -> navigate back
|
||||||
|
|
||||||
|
affects: [task-form, task-providers, any future task management UI]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Smart delete pattern: check completion count to decide hard vs soft delete
|
||||||
|
- Delete confirmation dialog matching room delete pattern with error-colored FilledButton
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- lib/features/tasks/presentation/task_providers.dart
|
||||||
|
- lib/features/tasks/presentation/task_form_screen.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "smartDeleteTask kept separate from deleteTask to preserve existing hard-delete path for cascade/other uses"
|
||||||
|
- "Delete button placed after history section with divider, visible only in edit mode"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Delete confirmation pattern: showDialog<bool> returning true/false, error-colored FilledButton for confirm"
|
||||||
|
- "Smart delete pattern: getCompletionCount -> conditional hard/soft delete invisible to user"
|
||||||
|
|
||||||
|
requirements-completed: [DEL-01, DEL-04]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-03-18
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 8 Plan 02: Task Delete UI Summary
|
||||||
|
|
||||||
|
**Red delete button with confirmation dialog in task edit form: hard-deletes unused tasks, soft-deletes tasks with completion history**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 2 min
|
||||||
|
- **Started:** 2026-03-18T19:59:37Z
|
||||||
|
- **Completed:** 2026-03-18T20:02:05Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 2
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Added `smartDeleteTask` to `TaskActions` Riverpod notifier — checks completion count and routes to DAO `deleteTask` (hard) or `softDeleteTask` (soft)
|
||||||
|
- Added red `FilledButton.icon` with error colorScheme at bottom of task edit form, separated from history section by a `Divider`
|
||||||
|
- Added `_onDelete` confirmation dialog using existing `taskDeleteConfirmTitle`, `taskDeleteConfirmMessage`, `taskDeleteConfirmAction`, and `cancel` l10n strings
|
||||||
|
- All 144 tests pass, `dart analyze --fatal-infos` clean
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Add smartDeleteTask to TaskActions provider** - `1b1b981` (feat)
|
||||||
|
2. **Task 2: Add delete button and confirmation dialog to TaskFormScreen** - `6133c97` (feat)
|
||||||
|
|
||||||
|
**Plan metadata:** _(docs commit follows)_
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `lib/features/tasks/presentation/task_providers.dart` - Added `smartDeleteTask` method to `TaskActions` class
|
||||||
|
- `lib/features/tasks/presentation/task_providers.g.dart` - Regenerated by build_runner (no functional changes)
|
||||||
|
- `lib/features/tasks/presentation/task_form_screen.dart` - Added delete button in edit mode and `_onDelete` async method
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- `smartDeleteTask` kept separate from `deleteTask` to preserve the existing hard-delete path used for room cascade deletes and any other callers
|
||||||
|
- Delete button placed after history ListTile, inside the `if (widget.isEditing)` block, so it never appears in create mode
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- Complete delete flow is working: task edit form -> red delete button -> confirmation dialog -> smart delete -> pop back to room task list
|
||||||
|
- Soft-deleted tasks (isActive=false) are already filtered from all views (implemented in Plan 01)
|
||||||
|
- Phase 08-task-delete is fully complete
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 08-task-delete*
|
||||||
|
*Completed: 2026-03-18*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: lib/features/tasks/presentation/task_providers.dart
|
||||||
|
- FOUND: lib/features/tasks/presentation/task_form_screen.dart
|
||||||
|
- FOUND: .planning/phases/08-task-delete/08-02-SUMMARY.md
|
||||||
|
- FOUND: commit 1b1b981 (smartDeleteTask)
|
||||||
|
- FOUND: commit 6133c97 (delete button and dialog)
|
||||||
|
- FOUND: smartDeleteTask in task_providers.dart
|
||||||
|
- FOUND: taskDeleteConfirmTitle in task_form_screen.dart
|
||||||
94
.planning/phases/08-task-delete/08-CONTEXT.md
Normal file
94
.planning/phases/08-task-delete/08-CONTEXT.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Phase 8: Task Delete - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-03-18
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Add a delete action to tasks with smart behavior: hard delete (remove from DB) if the task has never been completed, soft delete (deactivate, hide from views) if the task has been completed at least once. Preserves completion history for future statistics. No UI to view or restore archived tasks in this phase.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Delete placement
|
||||||
|
- Red delete button at the bottom of the task edit form, below the history section, separated by a divider
|
||||||
|
- Edit mode only — no delete button in create mode (user can just back out)
|
||||||
|
- No swipe-to-delete, no long-press context menu, no AppBar icon — form only
|
||||||
|
- Deliberate action: user must open the task, scroll down, and tap delete
|
||||||
|
|
||||||
|
### Confirmation dialog
|
||||||
|
- Single generic "Aufgabe löschen?" confirmation — same message for both hard and soft delete
|
||||||
|
- User does not need to know the implementation difference (permanent vs deactivated)
|
||||||
|
- Follow existing room delete dialog pattern: TextButton cancel + FilledButton with error color
|
||||||
|
- Existing l10n strings (taskDeleteConfirmTitle, taskDeleteConfirmMessage, taskDeleteConfirmAction) already defined
|
||||||
|
|
||||||
|
### Delete behavior
|
||||||
|
- Check task_completions count before deleting
|
||||||
|
- 0 completions → hard delete: remove task and completions from DB (existing deleteTask DAO method)
|
||||||
|
- 1+ completions → soft delete: set isActive = false on the task, task hidden from all active views
|
||||||
|
- Need new `isActive` BoolColumn on Tasks table with default true + schema migration
|
||||||
|
|
||||||
|
### Post-delete navigation
|
||||||
|
- Pop back to the room task list (same as save behavior)
|
||||||
|
- Reactive providers will auto-update to reflect the deleted/deactivated task
|
||||||
|
|
||||||
|
### Archived task visibility
|
||||||
|
- Soft-deleted tasks are completely hidden from all views — no toggle, no restore UI
|
||||||
|
- Archived tasks preserved in DB purely for future statistics phase
|
||||||
|
- No need to build any "show archived" UI in this phase
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Migration version number and strategy
|
||||||
|
- Exact button styling (consistent with Material 3 error patterns)
|
||||||
|
- Whether to add a SnackBar confirmation after delete or just navigate back silently
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- User explicitly wants simplicity: "just have a delete function keep it simple"
|
||||||
|
- The smart hard/soft behavior is invisible to the user — they just see "delete"
|
||||||
|
- Keep the flow dead simple: open task → scroll to bottom → tap delete → confirm → back to list
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `TasksDao.deleteTask(taskId)`: Already implements hard delete with cascade (completions first, then task)
|
||||||
|
- `TaskActionsNotifier.deleteTask(taskId)`: Provider method exists, calls DAO
|
||||||
|
- Room delete confirmation dialog (`rooms_screen.dart:160-189`): AlertDialog pattern with error-colored FilledButton
|
||||||
|
- German l10n strings already defined: taskDeleteConfirmTitle, taskDeleteConfirmMessage, taskDeleteConfirmAction
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Confirmation dialogs: AlertDialog with TextButton cancel + error FilledButton
|
||||||
|
- DAO transactions for cascade deletes
|
||||||
|
- ConsumerStatefulWidget for screens with async callbacks
|
||||||
|
- Schema migrations in database.dart with version tracking
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `task_form_screen.dart`: Add delete button after history ListTile (edit mode only)
|
||||||
|
- `tasks_dao.dart`: Add softDeleteTask method (UPDATE isActive = false) alongside existing hard deleteTask
|
||||||
|
- `calendar_dao.dart`: 6 queries need WHERE isActive = true filter
|
||||||
|
- `tasks_dao.dart`: watchTasksInRoom needs WHERE isActive = true filter
|
||||||
|
- `database.dart`: Add isActive BoolColumn to Tasks table + migration
|
||||||
|
- All existing tasks must default to isActive = true in migration
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 08-task-delete*
|
||||||
|
*Context gathered: 2026-03-18*
|
||||||
124
.planning/phases/08-task-delete/08-VERIFICATION.md
Normal file
124
.planning/phases/08-task-delete/08-VERIFICATION.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
phase: 08-task-delete
|
||||||
|
verified: 2026-03-18T20:30:00Z
|
||||||
|
status: passed
|
||||||
|
score: 9/9 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 8: Task Delete Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Users can remove tasks they no longer need, with smart preservation of completion history for future statistics
|
||||||
|
**Verified:** 2026-03-18T20:30:00Z
|
||||||
|
**Status:** passed
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Active tasks appear in all views (calendar, room task lists, daily plan) | VERIFIED | All 4 DAOs filter `isActive=true`; 15 query sites confirmed across `tasks_dao.dart`, `calendar_dao.dart`, `daily_plan_dao.dart`, `rooms_dao.dart` |
|
||||||
|
| 2 | Deactivated tasks are hidden from all views | VERIFIED | `isActive.equals(true)` present on all 6 CalendarDao queries, all 3 DailyPlanDao queries, `watchTasksInRoom`, `getOverdueTaskCount`, and `watchRoomWithStats` |
|
||||||
|
| 3 | Hard delete removes task and completions from DB entirely | VERIFIED | `deleteTask` in `tasks_dao.dart` uses a transaction to delete completions then task; test "hard deleteTask still removes task and its completions" passes |
|
||||||
|
| 4 | Soft delete sets isActive to false without removing data | VERIFIED | `softDeleteTask` updates `isActive: Value(false)` only; test "softDeleteTask sets isActive to false without removing the task" passes — row count stays 1, `isActive == false` |
|
||||||
|
| 5 | Existing tasks default to active after migration | VERIFIED | `BoolColumn.withDefault(const Constant(true))` on Tasks table; `from == 2` migration block calls `m.addColumn(tasks, tasks.isActive)` which applies the default to existing rows |
|
||||||
|
| 6 | User sees a red delete button at the bottom of the task edit form | VERIFIED | `FilledButton.icon` with `backgroundColor: theme.colorScheme.error` inside `if (widget.isEditing)` block in `task_form_screen.dart` lines 207-218 |
|
||||||
|
| 7 | Tapping delete shows a confirmation dialog before any action | VERIFIED | `_onDelete()` calls `showDialog<bool>` with `AlertDialog` containing title `l10n.taskDeleteConfirmTitle`, message `l10n.taskDeleteConfirmMessage`, cancel TextButton, and error-colored confirm FilledButton |
|
||||||
|
| 8 | Confirming delete routes to hard or soft delete based on completion history | VERIFIED | `smartDeleteTask` in `task_providers.dart` calls `getCompletionCount` and branches: `deleteTask` if 0, `softDeleteTask` if >0 |
|
||||||
|
| 9 | After deletion the user is navigated back to the room task list | VERIFIED | `_onDelete()` calls `context.pop()` after awaiting `smartDeleteTask`; guarded by `if (mounted)` check |
|
||||||
|
|
||||||
|
**Score:** 9/9 truths verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `lib/core/database/database.dart` | `isActive` BoolColumn, schema v3, migration | VERIFIED | Line 38-39: `BoolColumn get isActive => boolean().withDefault(const Constant(true))();`; `schemaVersion => 3`; `from == 2` block calls `m.addColumn(tasks, tasks.isActive)` |
|
||||||
|
| `lib/features/tasks/data/tasks_dao.dart` | `softDeleteTask`, `getCompletionCount`, `isActive` filter on queries | VERIFIED | Both methods present (lines 115-128); `watchTasksInRoom` and `getOverdueTaskCount` filter by `isActive.equals(true)` |
|
||||||
|
| `lib/features/home/data/calendar_dao.dart` | `isActive=true` filter on all 6 queries | VERIFIED | All 6 methods confirmed: `watchTasksForDate` (l32), `getTaskCount` (l56), `watchTasksForDateInRoom` (l76), `watchOverdueTasks` (l105), `watchOverdueTasksInRoom` (l139), `getTaskCountInRoom` (l164) |
|
||||||
|
| `lib/features/home/data/daily_plan_dao.dart` | `isActive=true` filter on all 3 queries | VERIFIED | `watchAllTasksWithRoomName` (l20), `getOverdueAndTodayTaskCount` (l44), `getOverdueTaskCount` (l57) — all confirmed |
|
||||||
|
| `lib/features/rooms/data/rooms_dao.dart` | `isActive=true` filter in `watchRoomWithStats` task query | VERIFIED | Line 47-49: `t.roomId.equals(room.id) & t.isActive.equals(true)` |
|
||||||
|
| `lib/features/tasks/presentation/task_providers.dart` | `smartDeleteTask` method using `getCompletionCount` | VERIFIED | Lines 94-102: method exists, branches on completion count to `deleteTask` or `softDeleteTask` |
|
||||||
|
| `lib/features/tasks/presentation/task_form_screen.dart` | Delete button and confirmation dialog in edit mode; uses `taskDeleteConfirmTitle` | VERIFIED | Lines 204-218 (button), lines 471-507 (`_onDelete` method); `taskDeleteConfirmTitle` at line 476 |
|
||||||
|
| `test/features/tasks/data/tasks_dao_test.dart` | Tests for `softDeleteTask`, `getCompletionCount`, `isActive` filtering | VERIFIED | 6 new tests present and all 13 tests in file pass |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `lib/core/database/database.dart` | All DAOs | `BoolColumn get isActive` on Tasks table | WIRED | 59 occurrences of `isActive` across 6 DAO/schema files |
|
||||||
|
| `lib/features/tasks/data/tasks_dao.dart` | `lib/features/tasks/presentation/task_providers.dart` | `softDeleteTask` and `getCompletionCount` | WIRED | `task_providers.dart` calls `db.tasksDao.getCompletionCount(taskId)` and `db.tasksDao.softDeleteTask(taskId)` at lines 96-100 |
|
||||||
|
| `lib/features/tasks/presentation/task_form_screen.dart` | `lib/features/tasks/presentation/task_providers.dart` | `smartDeleteTask` call from `_onDelete` | WIRED | `ref.read(taskActionsProvider.notifier).smartDeleteTask(widget.taskId!)` at line 498 |
|
||||||
|
| `lib/features/tasks/presentation/task_providers.dart` | `lib/features/tasks/data/tasks_dao.dart` | `getCompletionCount` then conditional `deleteTask` or `softDeleteTask` | WIRED | `smartDeleteTask` method at lines 94-102 confirmed complete — reads count, branches, calls DAO |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|-------------|-------------|--------|----------|
|
||||||
|
| DEL-01 | 08-02-PLAN.md | User can delete a task from the task edit form via a clearly visible delete action | SATISFIED | Red `FilledButton.icon` with `Icons.delete_outline` at bottom of edit form, gated on `widget.isEditing` |
|
||||||
|
| DEL-02 | 08-01-PLAN.md | Hard delete removes task with no completions from DB entirely | SATISFIED | `deleteTask` cascade + `smartDeleteTask` routes to it when `getCompletionCount == 0` |
|
||||||
|
| DEL-03 | 08-01-PLAN.md | Deleting a task with completions deactivates it (soft delete) | SATISFIED | `softDeleteTask` sets `isActive=false`; all DAO queries filter by `isActive=true` so task disappears from views |
|
||||||
|
| DEL-04 | 08-02-PLAN.md | User sees a confirmation before deleting/deactivating | SATISFIED | `_onDelete` shows `AlertDialog` with cancel and confirm actions; action only proceeds when `confirmed == true` |
|
||||||
|
|
||||||
|
All 4 requirements from REQUIREMENTS.md Phase 8 are SATISFIED. No orphaned requirements found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
None. No TODOs, FIXMEs, placeholder returns, or stub implementations found in any modified files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
#### 1. Delete button visual appearance
|
||||||
|
|
||||||
|
**Test:** Open the app, navigate to a room, tap any task to open the edit form, scroll to the bottom.
|
||||||
|
**Expected:** A full-width red button labeled "Loschen" (with umlaut) appears below the history row, separated by a divider. Button does not appear when creating a new task.
|
||||||
|
**Why human:** Visual layout, color rendering, and scroll behavior cannot be verified programmatically.
|
||||||
|
|
||||||
|
#### 2. Confirmation dialog flow
|
||||||
|
|
||||||
|
**Test:** Tap the delete button. Tap "Abbrechen" (cancel).
|
||||||
|
**Expected:** Dialog dismisses, form remains open, task is unchanged.
|
||||||
|
**Why human:** Dialog dismissal behavior and state preservation requires manual interaction.
|
||||||
|
|
||||||
|
#### 3. Smart delete — task with no completions (hard delete)
|
||||||
|
|
||||||
|
**Test:** Create a fresh task (never completed). Open it, tap delete, confirm.
|
||||||
|
**Expected:** Task disappears from the room list immediately. Navigated back to room task list.
|
||||||
|
**Why human:** End-to-end flow requires running app with real navigation and reactive provider updates.
|
||||||
|
|
||||||
|
#### 4. Smart delete — task with completions (soft delete)
|
||||||
|
|
||||||
|
**Test:** Complete a task at least once. Open it, tap delete, confirm.
|
||||||
|
**Expected:** Task disappears from all views (room list, calendar, daily plan). Navigation returns to room. Task remains in DB (invisible to user but present for future statistics).
|
||||||
|
**Why human:** Requires verifying absence from multiple views and confirming data is preserved in DB — combination of UI behavior and DB state inspection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Gaps Summary
|
||||||
|
|
||||||
|
No gaps. All must-haves verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- `flutter test test/features/tasks/data/tasks_dao_test.dart`: 13/13 passed (including all 6 new soft-delete tests)
|
||||||
|
- `flutter test --reporter compact`: 144/144 passed
|
||||||
|
- `dart analyze --fatal-infos`: No issues found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-18T20:30:00Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
330
.planning/phases/09-task-creation-ux/09-01-PLAN.md
Normal file
330
.planning/phases/09-task-creation-ux/09-01-PLAN.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
---
|
||||||
|
phase: 09-task-creation-ux
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- lib/features/tasks/presentation/task_form_screen.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
autonomous: false
|
||||||
|
requirements: [TCX-01, TCX-02, TCX-03, TCX-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Frequency section shows 4 shortcut chips (Taeglich, Woechentlich, Alle 2 Wochen, Monatlich) above the freeform picker"
|
||||||
|
- "The freeform 'Every [N] [unit]' picker row is always visible — not hidden behind a Custom toggle"
|
||||||
|
- "Tapping a shortcut chip highlights it AND populates the picker with the corresponding values"
|
||||||
|
- "Editing the picker number or unit manually deselects any highlighted chip"
|
||||||
|
- "Any arbitrary interval (e.g., every 5 days, every 3 weeks, every 2 months) can be entered directly in the freeform picker"
|
||||||
|
- "Editing an existing daily task shows 'Taeglich' chip highlighted and picker showing 1/Tage"
|
||||||
|
- "Editing an existing quarterly task (3 months) shows no chip highlighted and picker showing 3/Monate"
|
||||||
|
- "Saving a task from the new picker produces the correct IntervalType and intervalDays values"
|
||||||
|
artifacts:
|
||||||
|
- path: "lib/features/tasks/presentation/task_form_screen.dart"
|
||||||
|
provides: "Reworked frequency picker with shortcut chips + freeform picker"
|
||||||
|
contains: "_ShortcutFrequency"
|
||||||
|
- path: "lib/l10n/app_de.arb"
|
||||||
|
provides: "German l10n strings for shortcut chip labels"
|
||||||
|
contains: "frequencyShortcutDaily"
|
||||||
|
key_links:
|
||||||
|
- from: "lib/features/tasks/presentation/task_form_screen.dart"
|
||||||
|
to: "lib/features/tasks/domain/frequency.dart"
|
||||||
|
via: "IntervalType enum and FrequencyInterval for _resolveFrequency mapping"
|
||||||
|
pattern: "IntervalType\\."
|
||||||
|
- from: "lib/features/tasks/presentation/task_form_screen.dart"
|
||||||
|
to: "lib/l10n/app_de.arb"
|
||||||
|
via: "AppLocalizations for chip labels and picker labels"
|
||||||
|
pattern: "l10n\\.frequencyShortcut"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Rework the frequency picker in TaskFormScreen from a flat grid of 10 preset chips + hidden "Custom" mode into an intuitive 4 shortcut chips + always-visible freeform "Every [N] [unit]" picker.
|
||||||
|
|
||||||
|
Purpose: Users should be able to set any recurring frequency intuitively — common frequencies are one tap away, custom intervals are freeform without mode switching.
|
||||||
|
|
||||||
|
Output: Reworked `task_form_screen.dart` with simplified state management, bidirectional chip/picker sync, correct edit-mode loading, and all existing scheduling behavior preserved.
|
||||||
|
</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/09-task-creation-ux/09-CONTEXT.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
class FrequencyInterval {
|
||||||
|
final IntervalType intervalType;
|
||||||
|
final int days;
|
||||||
|
const FrequencyInterval({required this.intervalType, this.days = 1});
|
||||||
|
String label() { /* German label logic */ }
|
||||||
|
static const List<FrequencyInterval> presets = [ /* 10 presets */ ];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/features/tasks/presentation/task_form_screen.dart (current state to rework):
|
||||||
|
```dart
|
||||||
|
// Current state variables (lines 37-39):
|
||||||
|
FrequencyInterval? _selectedPreset;
|
||||||
|
bool _isCustomFrequency = false;
|
||||||
|
_CustomUnit _customUnit = _CustomUnit.days;
|
||||||
|
|
||||||
|
// Current methods to rework:
|
||||||
|
_buildFrequencySelector() // lines 226-277 — chip grid + conditional custom input
|
||||||
|
_buildCustomFrequencyInput() // lines 279-326 — the "Alle [N] [unit]" row (KEEP, promote to primary)
|
||||||
|
_loadExistingTask() // lines 55-101 — edit mode preset matching (rework for new chips)
|
||||||
|
_resolveFrequency() // lines 378-415 — maps to IntervalType (KEEP, simplify condition)
|
||||||
|
```
|
||||||
|
|
||||||
|
From lib/l10n/app_de.arb (existing frequency strings):
|
||||||
|
```json
|
||||||
|
"taskFormFrequencyLabel": "Wiederholung",
|
||||||
|
"taskFormFrequencyCustom": "Benutzerdefiniert", // will be unused
|
||||||
|
"taskFormFrequencyEvery": "Alle",
|
||||||
|
"taskFormFrequencyUnitDays": "Tage",
|
||||||
|
"taskFormFrequencyUnitWeeks": "Wochen",
|
||||||
|
"taskFormFrequencyUnitMonths": "Monate"
|
||||||
|
```
|
||||||
|
|
||||||
|
IMPORTANT: FrequencyInterval.presets is NOT used outside of task_form_screen.dart for selection purposes.
|
||||||
|
template_picker_sheet.dart and task_row.dart only use FrequencyInterval constructor + .label() — they do NOT reference .presets.
|
||||||
|
The .presets list can safely stop being used in the UI without breaking anything.
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Rework frequency picker — shortcut chips + freeform picker</name>
|
||||||
|
<files>lib/features/tasks/presentation/task_form_screen.dart, lib/l10n/app_de.arb, lib/l10n/app_localizations.dart, lib/l10n/app_localizations_de.dart</files>
|
||||||
|
<action>
|
||||||
|
Rework the frequency picker in `task_form_screen.dart` following the locked user decisions in 09-CONTEXT.md.
|
||||||
|
|
||||||
|
**Step 1 — Add l10n strings** to `app_de.arb`:
|
||||||
|
Add 4 new keys for shortcut chip labels:
|
||||||
|
- `"frequencyShortcutDaily": "Täglich"`
|
||||||
|
- `"frequencyShortcutWeekly": "Wöchentlich"`
|
||||||
|
- `"frequencyShortcutBiweekly": "Alle 2 Wochen"`
|
||||||
|
- `"frequencyShortcutMonthly": "Monatlich"`
|
||||||
|
|
||||||
|
Then run `flutter gen-l10n` to regenerate `app_localizations.dart` and `app_localizations_de.dart`.
|
||||||
|
|
||||||
|
**Step 2 — Define shortcut enum** in `task_form_screen.dart`:
|
||||||
|
Create a private enum `_ShortcutFrequency` with values: `daily, weekly, biweekly, monthly`.
|
||||||
|
Add a method `toPickerValues()` returning `({int number, _CustomUnit unit})`:
|
||||||
|
- daily → (1, days)
|
||||||
|
- weekly → (1, weeks)
|
||||||
|
- biweekly → (2, weeks)
|
||||||
|
- monthly → (1, months)
|
||||||
|
|
||||||
|
Add a static method `fromPickerValues(int number, _CustomUnit unit)` returning `_ShortcutFrequency?`:
|
||||||
|
- (1, days) → daily
|
||||||
|
- (1, weeks) → weekly
|
||||||
|
- (2, weeks) → biweekly
|
||||||
|
- (1, months) → monthly
|
||||||
|
- anything else → null
|
||||||
|
|
||||||
|
**Step 3 — Simplify state variables:**
|
||||||
|
Remove `_selectedPreset` (FrequencyInterval?) and `_isCustomFrequency` (bool).
|
||||||
|
Add `_activeShortcut` (_ShortcutFrequency?) — nullable, null means no chip highlighted.
|
||||||
|
|
||||||
|
Change `initState` default: instead of `_selectedPreset = FrequencyInterval.presets[3]`, set:
|
||||||
|
- `_activeShortcut = _ShortcutFrequency.weekly`
|
||||||
|
- `_customIntervalController.text = '1'` (already defaults to '2', change to '1')
|
||||||
|
- `_customUnit = _CustomUnit.weeks`
|
||||||
|
|
||||||
|
**Step 4 — Rework `_buildFrequencySelector()`:**
|
||||||
|
Replace the entire method. New structure:
|
||||||
|
```
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Shortcut chips row (always visible)
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
for each _ShortcutFrequency shortcut:
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(_shortcutLabel(shortcut, l10n)),
|
||||||
|
selected: _activeShortcut == shortcut,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
final values = shortcut.toPickerValues();
|
||||||
|
setState(() {
|
||||||
|
_activeShortcut = shortcut;
|
||||||
|
_customIntervalController.text = values.number.toString();
|
||||||
|
_customUnit = values.unit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Freeform picker row (ALWAYS visible — not conditional)
|
||||||
|
_buildFrequencyPickerRow(l10n, theme),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add helper `_shortcutLabel(_ShortcutFrequency shortcut, AppLocalizations l10n)` returning the l10n string for each shortcut.
|
||||||
|
|
||||||
|
**Step 5 — Rename `_buildCustomFrequencyInput` to `_buildFrequencyPickerRow`:**
|
||||||
|
The method body stays almost identical. One change: when the user edits the number field or changes the unit, recalculate `_activeShortcut`:
|
||||||
|
- In the TextFormField's `onChanged` callback: `setState(() { _activeShortcut = _ShortcutFrequency.fromPickerValues(int.tryParse(_customIntervalController.text) ?? 1, _customUnit); })`
|
||||||
|
- In the SegmentedButton's `onSelectionChanged`: after setting `_customUnit`, also recalculate: `_activeShortcut = _ShortcutFrequency.fromPickerValues(int.tryParse(_customIntervalController.text) ?? 1, newUnit);`
|
||||||
|
|
||||||
|
This ensures bidirectional sync: chip → picker and picker → chip.
|
||||||
|
|
||||||
|
**Step 6 — Simplify `_resolveFrequency()`:**
|
||||||
|
Remove the `if (!_isCustomFrequency && _selectedPreset != null)` branch entirely.
|
||||||
|
The method now ALWAYS reads from the picker values (`_customIntervalController` + `_customUnit`), since the picker is always the source of truth (shortcuts just populate it). Use the SMART mapping that matches existing DB behavior for named types:
|
||||||
|
- 1 day → IntervalType.daily, days=1
|
||||||
|
- N days (N>1) → IntervalType.everyNDays, days=N
|
||||||
|
- 1 week → IntervalType.weekly, days=1
|
||||||
|
- 2 weeks → IntervalType.biweekly, days=14
|
||||||
|
- N weeks (N>2) → IntervalType.everyNDays, days=N*7
|
||||||
|
- 1 month → IntervalType.monthly, days=1, anchorDay=dueDate.day
|
||||||
|
- N months (N>1) → IntervalType.everyNMonths, days=N, anchorDay=dueDate.day
|
||||||
|
|
||||||
|
CRITICAL correctness note: The existing weekly preset has `days=1` (it's a named type where `intervalDays` stores 1). The old custom weeks path returns `everyNDays` with `days=N*7`. The new unified `_resolveFrequency` MUST use the named types (daily/weekly/biweekly/monthly) for their canonical values to match existing DB records. Only use everyNDays for non-canonical week counts (3+ weeks). Similarly, monthly uses `days=1` (not days=30) since it's a named type.
|
||||||
|
|
||||||
|
**Step 7 — Rework `_loadExistingTask()` for edit mode:**
|
||||||
|
Replace the preset-matching loop (lines 69-78) and custom-detection logic (lines 80-98) with unified picker population:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Populate picker from stored interval
|
||||||
|
switch (task.intervalType) {
|
||||||
|
case IntervalType.daily:
|
||||||
|
_customUnit = _CustomUnit.days;
|
||||||
|
_customIntervalController.text = '1';
|
||||||
|
case IntervalType.everyNDays:
|
||||||
|
// Check if it's a clean week multiple
|
||||||
|
if (task.intervalDays % 7 == 0) {
|
||||||
|
_customUnit = _CustomUnit.weeks;
|
||||||
|
_customIntervalController.text = (task.intervalDays ~/ 7).toString();
|
||||||
|
} else {
|
||||||
|
_customUnit = _CustomUnit.days;
|
||||||
|
_customIntervalController.text = task.intervalDays.toString();
|
||||||
|
}
|
||||||
|
case IntervalType.weekly:
|
||||||
|
_customUnit = _CustomUnit.weeks;
|
||||||
|
_customIntervalController.text = '1';
|
||||||
|
case IntervalType.biweekly:
|
||||||
|
_customUnit = _CustomUnit.weeks;
|
||||||
|
_customIntervalController.text = '2';
|
||||||
|
case IntervalType.monthly:
|
||||||
|
_customUnit = _CustomUnit.months;
|
||||||
|
_customIntervalController.text = '1';
|
||||||
|
case IntervalType.everyNMonths:
|
||||||
|
_customUnit = _CustomUnit.months;
|
||||||
|
_customIntervalController.text = task.intervalDays.toString();
|
||||||
|
case IntervalType.quarterly:
|
||||||
|
_customUnit = _CustomUnit.months;
|
||||||
|
_customIntervalController.text = '3';
|
||||||
|
case IntervalType.yearly:
|
||||||
|
_customUnit = _CustomUnit.months;
|
||||||
|
_customIntervalController.text = '12';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect matching shortcut chip
|
||||||
|
_activeShortcut = _ShortcutFrequency.fromPickerValues(
|
||||||
|
int.tryParse(_customIntervalController.text) ?? 1,
|
||||||
|
_customUnit,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
This handles ALL 8 IntervalType values correctly, including quarterly (3 months) and yearly (12 months) which have no shortcut chip but display correctly in the picker.
|
||||||
|
|
||||||
|
**Step 8 — Clean up unused references:**
|
||||||
|
- Remove `_selectedPreset` field
|
||||||
|
- Remove `_isCustomFrequency` field
|
||||||
|
- Remove the import or reference to `FrequencyInterval.presets` in the chip-building code (the `for (final preset in FrequencyInterval.presets)` loop)
|
||||||
|
- Keep the `taskFormFrequencyCustom` l10n key in the ARB file (do NOT delete l10n keys — they're harmless and removing requires regen)
|
||||||
|
- Do NOT modify `frequency.dart` — the `presets` list stays for backward compatibility even though the UI no longer iterates it
|
||||||
|
|
||||||
|
**Verification notes for _resolveFrequency:**
|
||||||
|
The key correctness requirement is that saving a task from the new picker produces EXACTLY the same IntervalType + intervalDays + anchorDay as the old preset path did for equivalent selections. Verify by mentally tracing:
|
||||||
|
- Chip "Taeglich" → picker (1, days) → resolves to (daily, 1, null) -- matches old preset[0]
|
||||||
|
- Chip "Woechentlich" → picker (1, weeks) → resolves to (weekly, 1, null) -- matches old preset[3]
|
||||||
|
- Chip "Alle 2 Wochen" → picker (2, weeks) → resolves to (biweekly, 14, null) -- matches old preset[4]
|
||||||
|
- Chip "Monatlich" → picker (1, months) → resolves to (monthly, 1, anchorDay) -- matches old preset[5]
|
||||||
|
- Freeform "5 days" → picker (5, days) → resolves to (everyNDays, 5, null) -- matches old custom path
|
||||||
|
- Freeform "3 months" → picker (3, months) → resolves to (everyNMonths, 3, anchorDay) -- matches old custom path
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/HouseHoldKeaper && flutter analyze --no-fatal-infos 2>&1 | tail -5 && flutter test 2>&1 | tail -10</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- The 10-chip Wrap grid is fully replaced by 4 shortcut chips + always-visible freeform picker
|
||||||
|
- The "Benutzerdefiniert" (Custom) chip is removed — the picker is inherently freeform
|
||||||
|
- Bidirectional sync: tapping a chip populates the picker; editing the picker recalculates chip highlight
|
||||||
|
- `_resolveFrequency()` reads exclusively from the picker (single source of truth)
|
||||||
|
- Edit mode correctly loads all 8 IntervalType values into the picker and highlights matching shortcut chip
|
||||||
|
- All existing tests pass, dart analyze is clean
|
||||||
|
- No changes to frequency.dart, no DB migration, no new screens
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 2: Verify frequency picker UX</name>
|
||||||
|
<what-built>Reworked frequency picker with 4 shortcut chips and freeform "Every [N] [unit]" picker, replacing the old 10-chip grid + hidden Custom mode</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Launch the app: `flutter run`
|
||||||
|
2. Navigate to any room and tap "+" to create a new task
|
||||||
|
3. Verify the frequency section shows:
|
||||||
|
- Row of 4 shortcut chips: Taeglich, Woechentlich, Alle 2 Wochen, Monatlich
|
||||||
|
- Below: always-visible freeform picker row with number field + Tage/Wochen/Monate segments
|
||||||
|
- "Woechentlich" chip highlighted by default, picker showing "1" with "Wochen" selected
|
||||||
|
4. Tap "Taeglich" chip — verify chip highlights and picker updates to "1" / "Tage"
|
||||||
|
5. Tap "Monatlich" chip — verify chip highlights and picker updates to "1" / "Monate"
|
||||||
|
6. Manually type "5" in the number field — verify all chips deselect (no shortcut matches 5 weeks)
|
||||||
|
7. Change unit to "Tage" — verify still no chip selected (5 days is not a shortcut)
|
||||||
|
8. Type "1" in the number field with "Tage" selected — verify "Taeglich" chip auto-highlights
|
||||||
|
9. Save a task with "Alle 2 Wochen" shortcut, then re-open in edit mode — verify "Alle 2 Wochen" chip is highlighted and picker shows "2" / "Wochen"
|
||||||
|
10. If you have an existing quarterly or yearly task, open it in edit mode — verify no chip highlighted, picker shows "3" / "Monate" (quarterly) or "12" / "Monate" (yearly)
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" or describe issues</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `flutter analyze --no-fatal-infos` reports zero issues
|
||||||
|
- `flutter test` — all existing tests pass (108+)
|
||||||
|
- Manual verification: create task with each shortcut, create task with arbitrary interval, edit existing tasks of all interval types
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. The frequency section presents 4 shortcut chips above an always-visible "Every [N] [unit]" picker (TCX-01, TCX-02)
|
||||||
|
2. Any arbitrary interval is settable directly in the picker without a "Custom" mode (TCX-03)
|
||||||
|
3. All 8 IntervalType values save and load correctly, including calendar-anchored monthly/quarterly/yearly with anchor memory (TCX-04)
|
||||||
|
4. Existing tests pass without modification, dart analyze is clean
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/09-task-creation-ux/09-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
105
.planning/phases/09-task-creation-ux/09-01-SUMMARY.md
Normal file
105
.planning/phases/09-task-creation-ux/09-01-SUMMARY.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
phase: 09-task-creation-ux
|
||||||
|
plan: 01
|
||||||
|
subsystem: ui
|
||||||
|
tags: [flutter, dart, l10n, frequency-picker, choice-chip, segmented-button]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- Reworked frequency picker with 4 shortcut chips (Täglich, Wöchentlich, Alle 2 Wochen, Monatlich)
|
||||||
|
- Always-visible freeform "Alle [N] [unit]" picker replacing hidden Custom mode
|
||||||
|
- Bidirectional chip/picker sync via _ShortcutFrequency enum
|
||||||
|
- Unified _resolveFrequency() reading exclusively from picker (single source of truth)
|
||||||
|
- Edit mode loading for all 8 IntervalType values including quarterly and yearly
|
||||||
|
affects: []
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Shortcut chip + freeform picker: ChoiceChip row above always-visible SegmentedButton picker"
|
||||||
|
- "Bidirectional sync: chip tapped populates picker; picker edited recalculates chip highlight via fromPickerValues()"
|
||||||
|
- "Single source of truth: _resolveFrequency() always reads from picker, never from a preset reference"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- lib/features/tasks/presentation/task_form_screen.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Picker is single source of truth: _resolveFrequency() reads from _customIntervalController + _customUnit always"
|
||||||
|
- "_ShortcutFrequency enum with toPickerValues() and fromPickerValues() handles bidirectional sync without manual mapping"
|
||||||
|
- "Named IntervalTypes (daily/weekly/biweekly/monthly) used for canonical values; only everyNDays for 3+ weeks"
|
||||||
|
- "Quarterly (3 months) and yearly (12 months) displayed correctly in picker with no chip highlighted"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Shortcut chip pattern: enum with toPickerValues() / fromPickerValues() for bidirectional picker sync"
|
||||||
|
|
||||||
|
requirements-completed: [TCX-01, TCX-02, TCX-03, TCX-04]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-03-18
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 9 Plan 01: Task Creation UX — Frequency Picker Rework Summary
|
||||||
|
|
||||||
|
**4 shortcut chips (Täglich/Wöchentlich/Alle 2 Wochen/Monatlich) + always-visible freeform picker replacing the 10-chip grid with hidden Custom mode**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 2 min
|
||||||
|
- **Started:** 2026-03-18T21:43:24Z
|
||||||
|
- **Completed:** 2026-03-18T21:45:30Z
|
||||||
|
- **Tasks:** 1 (+ 1 auto-approved checkpoint)
|
||||||
|
- **Files modified:** 4
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- Replaced 10-chip preset grid and hidden "Benutzerdefiniert" mode with 4 shortcut chips + always-visible freeform picker
|
||||||
|
- Implemented bidirectional sync: tapping a chip populates the picker; editing the picker recalculates chip highlight
|
||||||
|
- Simplified _resolveFrequency() to read exclusively from the picker (single source of truth), using named IntervalTypes for canonical values
|
||||||
|
- Edit mode correctly loads all 8 IntervalType values (daily, everyNDays, weekly, biweekly, monthly, everyNMonths, quarterly, yearly) into the picker and highlights the matching shortcut chip where applicable
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Rework frequency picker — shortcut chips + freeform picker** - `8a0b69b` (feat)
|
||||||
|
2. **Task 2: Verify frequency picker UX** - auto-approved (checkpoint:human-verify, auto_advance=true)
|
||||||
|
|
||||||
|
**Plan metadata:** (docs commit follows)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `lib/features/tasks/presentation/task_form_screen.dart` - Reworked frequency picker: removed _selectedPreset and _isCustomFrequency fields; added _ShortcutFrequency enum and _activeShortcut state; replaced _buildFrequencySelector() with shortcut chips + always-visible picker; renamed _buildCustomFrequencyInput to _buildFrequencyPickerRow with bidirectional sync; simplified _resolveFrequency() to picker-only
|
||||||
|
- `lib/l10n/app_de.arb` - Added frequencyShortcutDaily/Weekly/Biweekly/Monthly keys
|
||||||
|
- `lib/l10n/app_localizations.dart` - Regenerated to include new shortcut string getters
|
||||||
|
- `lib/l10n/app_localizations_de.dart` - Regenerated with German translations (Täglich, Wöchentlich, Alle 2 Wochen, Monatlich)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- Picker is single source of truth: _resolveFrequency() reads from _customIntervalController + _customUnit always, regardless of which chip is highlighted
|
||||||
|
- _ShortcutFrequency enum with toPickerValues() and static fromPickerValues() cleanly handles bidirectional sync without manual if-chain mapping in each callback
|
||||||
|
- Named IntervalTypes (daily/weekly/biweekly/monthly) used for canonical values (e.g., weekly has days=1, biweekly has days=14) matching existing DB records; only everyNDays used for 3+ weeks
|
||||||
|
- Quarterly (3 months) and yearly (12 months) round-trip correctly: loaded as "3 Monate" / "12 Monate" in picker with no chip highlighted
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
None.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- Frequency picker rework complete; TaskFormScreen is ready for further UX improvements
|
||||||
|
- All 144 existing tests pass, dart analyze is clean
|
||||||
|
- No changes to frequency.dart, no DB migration, no new screens
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 09-task-creation-ux*
|
||||||
|
*Completed: 2026-03-18*
|
||||||
117
.planning/phases/09-task-creation-ux/09-CONTEXT.md
Normal file
117
.planning/phases/09-task-creation-ux/09-CONTEXT.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Phase 9: Task Creation UX - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-03-18
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Rework the frequency picker from a flat grid of 10 preset ChoiceChips + hidden "Custom" mode into an intuitive "Every [N] [unit]" picker with quick-select shortcut chips. The picker is inherently freeform — no separate "Custom" mode. All existing interval types and calendar-anchored scheduling behavior must continue working. No new scheduling logic, no new DB columns, no new screens.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Picker layout
|
||||||
|
- Shortcut chips first (compact row), then the "Every [N] [unit]" picker row below
|
||||||
|
- Tapping a chip highlights it AND populates the picker row (bidirectional sync)
|
||||||
|
- Editing the picker manually deselects any highlighted chip
|
||||||
|
- Both always reflect the same value — single source of truth
|
||||||
|
- The picker row is always visible, not hidden behind a "Custom" toggle
|
||||||
|
|
||||||
|
### Number input
|
||||||
|
- Keep the existing TextFormField with digit-only filter
|
||||||
|
- Same pattern as the current custom interval input (TextFormField + FilteringTextInputFormatter.digitsOnly)
|
||||||
|
- Minimum value: 1 (no max limit — if someone wants every 999 days, let them)
|
||||||
|
- Text field centered, compact width (~60px as current)
|
||||||
|
|
||||||
|
### Unit selector
|
||||||
|
- Keep SegmentedButton with 3 units: Tage (days), Wochen (weeks), Monate (months)
|
||||||
|
- No "years" unit — yearly is handled as 12 months in the picker
|
||||||
|
- Consistent with existing SegmentedButton pattern used for effort level selector
|
||||||
|
|
||||||
|
### Shortcut chips
|
||||||
|
- 4 shortcut chips: Täglich, Wöchentlich, Alle 2 Wochen, Monatlich
|
||||||
|
- No quarterly/yearly shortcut chips — users type 3/12 months via the freeform picker
|
||||||
|
- Drop all other presets (every 2 days, every 3 days, every 2 months, every 6 months) — the freeform picker handles arbitrary intervals naturally
|
||||||
|
- Chips use ChoiceChip widget (existing pattern)
|
||||||
|
|
||||||
|
### Preset removal
|
||||||
|
- Remove the entire `FrequencyInterval.presets` static list from being used in the UI
|
||||||
|
- The 10-chip Wrap grid is fully replaced by 4 shortcut chips + freeform picker
|
||||||
|
- The "Benutzerdefiniert" (Custom) chip is removed — the picker is inherently freeform
|
||||||
|
- `_isCustomFrequency` boolean state is no longer needed
|
||||||
|
|
||||||
|
### Edit mode behavior
|
||||||
|
- When editing an existing task, match the stored interval to a shortcut chip if possible
|
||||||
|
- Daily task → highlight "Täglich" chip, picker shows "Alle 1 Tage"
|
||||||
|
- Weekly task → highlight "Wöchentlich" chip, picker shows "Alle 1 Wochen"
|
||||||
|
- Biweekly → highlight "Alle 2 Wochen" chip, picker shows "Alle 2 Wochen"
|
||||||
|
- Monthly → highlight "Monatlich" chip, picker shows "Alle 1 Monate"
|
||||||
|
- Any other interval (e.g., every 5 days, quarterly, yearly) → no chip highlighted, picker filled with correct values
|
||||||
|
|
||||||
|
### DB mapping
|
||||||
|
- Shortcut chips map to existing IntervalType enum values (daily, weekly, biweekly, monthly)
|
||||||
|
- Freeform days → IntervalType.everyNDays with the entered value
|
||||||
|
- Freeform weeks → IntervalType.everyNDays with value * 7
|
||||||
|
- Freeform months → IntervalType.everyNMonths with the entered value
|
||||||
|
- Calendar-anchored behavior (anchorDay) preserved for month-based intervals
|
||||||
|
- No changes to IntervalType enum, no new DB values, no migration needed
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Exact chip styling and spacing within the Wrap
|
||||||
|
- Animation/transition when syncing chip ↔ picker
|
||||||
|
- Whether the "Alle" prefix label is part of the picker row or omitted
|
||||||
|
- How to handle the edge case where user clears the number field (empty → treat as 1)
|
||||||
|
- l10n string changes needed for new/modified labels
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- User consistently prefers simplicity across phases ("just keep it simple" — Phase 8 pattern)
|
||||||
|
- The key UX improvement: no more hunting through 10 chips or finding a hidden "Custom" mode — the picker is always there and always works
|
||||||
|
- 4 common shortcuts for one-tap convenience, freeform picker for everything else
|
||||||
|
- The current `_buildCustomFrequencyInput` method is essentially what becomes the primary picker — it already has the right structure
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `_buildCustomFrequencyInput()` in `task_form_screen.dart:279-326`: Already implements "Alle [N] [Tage|Wochen|Monate]" row with TextFormField + SegmentedButton — this becomes the primary picker
|
||||||
|
- `_CustomUnit` enum (`task_form_screen.dart:511`): Already has days/weeks/months — reuse directly
|
||||||
|
- `_customIntervalController` (`task_form_screen.dart:35`): Already exists for the number input
|
||||||
|
- `_resolveFrequency()` (`task_form_screen.dart:378-415`): Already handles custom unit → IntervalType mapping — core logic stays the same
|
||||||
|
- `_loadExistingTask()` (`task_form_screen.dart:55-101`): Already has edit-mode loading logic with preset matching — needs rework for new chip set
|
||||||
|
- `FrequencyInterval.presets` (`frequency.dart:50-61`): Static list of 10 presets — UI no longer iterates this, but the model class stays for backward compat
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- ChoiceChip in Wrap for multi-option selection (current frequency chips)
|
||||||
|
- SegmentedButton for unit/level selection (effort level, custom unit)
|
||||||
|
- TextFormField with FilteringTextInputFormatter for numeric input
|
||||||
|
- ConsumerStatefulWidget with setState for form state management
|
||||||
|
- German l10n strings in `app_de.arb` via `AppLocalizations`
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `task_form_screen.dart`: Primary file — rework `_buildFrequencySelector()` method, simplify state variables
|
||||||
|
- `frequency.dart`: `FrequencyInterval.presets` list is no longer iterated in UI but may still be used elsewhere (templates) — check before removing
|
||||||
|
- `app_de.arb` / `app_localizations.dart`: May need new/updated l10n keys for shortcut chip labels
|
||||||
|
- `template_picker_sheet.dart` / `task_templates.dart`: Templates create tasks with specific IntervalType values — no changes needed since DB mapping unchanged
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 09-task-creation-ux*
|
||||||
|
*Context gathered: 2026-03-18*
|
||||||
161
.planning/phases/09-task-creation-ux/09-VERIFICATION.md
Normal file
161
.planning/phases/09-task-creation-ux/09-VERIFICATION.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
phase: 09-task-creation-ux
|
||||||
|
verified: 2026-03-18T23:00:00Z
|
||||||
|
status: human_needed
|
||||||
|
score: 8/8 must-haves verified
|
||||||
|
human_verification:
|
||||||
|
- test: "Create new task — verify frequency section layout"
|
||||||
|
expected: "4 shortcut chips (Taeglich, Woechentlich, Alle 2 Wochen, Monatlich) appear in a Wrap row; below them an always-visible picker row shows 'Alle [number] [Tage|Wochen|Monate]'; 'Woechentlich' chip is highlighted by default with picker showing '1' and 'Wochen' selected"
|
||||||
|
why_human: "Visual layout and default highlight state require running the app"
|
||||||
|
- test: "Tap each shortcut chip and verify bidirectional sync"
|
||||||
|
expected: "Tapping 'Taeglich' highlights that chip and sets picker to '1'/'Tage'; tapping 'Monatlich' highlights that chip and sets picker to '1'/'Monate'; previously highlighted chip deselects"
|
||||||
|
why_human: "Widget interaction and visual chip highlight state require running the app"
|
||||||
|
- test: "Edit the number field and verify chip deselection"
|
||||||
|
expected: "With 'Woechentlich' highlighted, typing '5' in the number field deselects all chips; changing unit to 'Tage' still shows no chip; typing '1' with 'Tage' selected auto-highlights 'Taeglich'"
|
||||||
|
why_human: "Bidirectional sync from picker back to chip highlight requires running the app"
|
||||||
|
- test: "Save a task using each shortcut and verify re-open in edit mode"
|
||||||
|
expected: "Task saved with 'Alle 2 Wochen' reopens with that chip highlighted and picker showing '2'/'Wochen'; task saved with arbitrary interval (e.g. 5 days) reopens with no chip highlighted and picker showing the correct values"
|
||||||
|
why_human: "Round-trip edit-mode loading of IntervalType values requires running the app"
|
||||||
|
- test: "Verify quarterly and yearly tasks load with no chip highlighted"
|
||||||
|
expected: "An existing quarterly task (IntervalType.quarterly) opens with no chip highlighted and picker showing '3'/'Monate'; a yearly task shows '12'/'Monate' with no chip"
|
||||||
|
why_human: "Requires an existing quarterly or yearly task in the database to test against"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 9: Task Creation UX Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Users can set any recurring frequency intuitively without hunting through a grid of preset chips — common frequencies are one tap away, custom intervals are freeform
|
||||||
|
**Verified:** 2026-03-18T23:00:00Z
|
||||||
|
**Status:** human_needed
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Frequency section shows 4 shortcut chips (Taeglich, Woechentlich, Alle 2 Wochen, Monatlich) above the freeform picker | VERIFIED | `_buildFrequencySelector()` at line 234 iterates `_ShortcutFrequency.values` in a `Wrap` with `ChoiceChip` for each of the 4 values; `_buildFrequencyPickerRow()` is called unconditionally below the Wrap |
|
||||||
|
| 2 | The freeform 'Every [N] [unit]' picker row is always visible — not hidden behind a Custom toggle | VERIFIED | `_buildFrequencyPickerRow(l10n, theme)` is called unconditionally at line 262 with no conditional wrapping; `_isCustomFrequency` field removed entirely |
|
||||||
|
| 3 | Tapping a shortcut chip highlights it AND populates the picker with the corresponding values | VERIFIED | `onSelected` at line 247 calls `shortcut.toPickerValues()` and `setState` setting both `_activeShortcut = shortcut` and updating `_customIntervalController.text` + `_customUnit`; `selected: _activeShortcut == shortcut` drives the highlight |
|
||||||
|
| 4 | Editing the picker number or unit manually deselects any highlighted chip | VERIFIED | `onChanged` in TextFormField at line 298 calls `_ShortcutFrequency.fromPickerValues(...)` — returns null for non-matching values, clearing `_activeShortcut`; `SegmentedButton.onSelectionChanged` at line 326 does the same |
|
||||||
|
| 5 | Any arbitrary interval (e.g., every 5 days, every 3 weeks, every 2 months) can be entered directly in the freeform picker | VERIFIED | Picker is a `TextFormField` with `FilteringTextInputFormatter.digitsOnly` (no max) and a 3-segment unit selector; `_resolveFrequency()` at line 393 maps all day/week/month combinations to the correct `IntervalType` values without requiring any mode switch |
|
||||||
|
| 6 | Editing an existing daily task shows 'Taeglich' chip highlighted and picker showing 1/Tage | VERIFIED | `_loadExistingTask()` at line 56: `case IntervalType.daily` sets `_customUnit = _CustomUnit.days` and `_customIntervalController.text = '1'`; then `_ShortcutFrequency.fromPickerValues(1, days)` returns `daily` — highlighting the chip |
|
||||||
|
| 7 | Editing an existing quarterly task (3 months) shows no chip highlighted and picker showing 3/Monate | VERIFIED | `case IntervalType.quarterly` sets `_customUnit = _CustomUnit.months` and `_customIntervalController.text = '3'`; `fromPickerValues(3, months)` returns `null` (3 months is not a shortcut), leaving `_activeShortcut` null |
|
||||||
|
| 8 | Saving a task from the new picker produces the correct IntervalType and intervalDays values | VERIFIED | `_resolveFrequency()` maps: 1 day → (daily, 1); N days → (everyNDays, N); 1 week → (weekly, 1); 2 weeks → (biweekly, 14); N weeks (N>2) → (everyNDays, N*7); 1 month → (monthly, 1, anchorDay); N months → (everyNMonths, N, anchorDay). Result is applied in `_onSave()` at line 423. All 144 existing tests pass. |
|
||||||
|
|
||||||
|
**Score:** 8/8 truths verified (automated)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `lib/features/tasks/presentation/task_form_screen.dart` | Reworked frequency picker with shortcut chips + freeform picker | VERIFIED | File exists, 536 lines; contains `_ShortcutFrequency` enum (line 504), `_activeShortcut` state (line 37), `_buildFrequencySelector()` (line 234), `_buildFrequencyPickerRow()` (line 280), `_resolveFrequency()` (line 393), `_loadExistingTask()` (line 56) |
|
||||||
|
| `lib/l10n/app_de.arb` | German l10n strings for shortcut chip labels | VERIFIED | Contains `frequencyShortcutDaily` ("Täglich"), `frequencyShortcutWeekly` ("Wöchentlich"), `frequencyShortcutBiweekly` ("Alle 2 Wochen"), `frequencyShortcutMonthly` ("Monatlich") at lines 51-54 |
|
||||||
|
| `lib/l10n/app_localizations.dart` | Abstract getters for new shortcut strings | VERIFIED | `frequencyShortcutDaily`, `frequencyShortcutWeekly`, `frequencyShortcutBiweekly`, `frequencyShortcutMonthly` abstract getters present at lines 325-347 |
|
||||||
|
| `lib/l10n/app_localizations_de.dart` | German implementations of shortcut string getters | VERIFIED | All 4 `@override` getter implementations present at lines 132-141 with correct German strings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `task_form_screen.dart` | `lib/features/tasks/domain/frequency.dart` | `IntervalType` enum in `_resolveFrequency()` and `_loadExistingTask()` | WIRED | `IntervalType.` referenced at 13 sites (lines 71, 74, 83, 86, 89, 92, 95, 98, 398, 400, 403, 406, 408, 411, 413); all 8 enum values handled; `frequency.dart` imported via `../domain/frequency.dart` |
|
||||||
|
| `task_form_screen.dart` | `lib/l10n/app_de.arb` | `AppLocalizations` for chip labels via `l10n.frequencyShortcut*` | WIRED | `l10n.frequencyShortcutDaily/Weekly/Biweekly/Monthly` called at lines 270-276 in `_shortcutLabel()`; `AppLocalizations` imported at line 10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|------------|-------------|--------|----------|
|
||||||
|
| TCX-01 | 09-01-PLAN.md | Frequency picker presents an intuitive "Every [N] [unit]" interface instead of a flat grid of preset chips | SATISFIED | `_buildFrequencyPickerRow()` always-visible TextFormField + SegmentedButton row replaces the old 10-chip `FrequencyInterval.presets` grid; `_isCustomFrequency` and `_selectedPreset` removed entirely |
|
||||||
|
| TCX-02 | 09-01-PLAN.md | Common frequencies (daily, weekly, biweekly, monthly) are available as quick-select shortcuts without scrolling through all options | SATISFIED | `_ShortcutFrequency.values` iterated in a `Wrap` at lines 243-257; 4 ChoiceChips one-tap select and populate the picker |
|
||||||
|
| TCX-03 | 09-01-PLAN.md | User can set any arbitrary interval without needing to select "Custom" first | SATISFIED | Picker is always visible; number field accepts any positive integer; no mode gate or "Custom" toggle exists in the code |
|
||||||
|
| TCX-04 | 09-01-PLAN.md | The frequency picker preserves all existing interval types and scheduling behavior (calendar-anchored monthly/quarterly/yearly with anchor memory) | SATISFIED | `_resolveFrequency()` passes `anchorDay: _dueDate.day` for monthly and everyNMonths; `_loadExistingTask()` handles all 8 `IntervalType` values in a complete exhaustive switch; `frequency.dart` not modified; 144 tests pass |
|
||||||
|
|
||||||
|
No orphaned requirements found — all 4 TCX-* IDs declared in PLAN frontmatter are present in REQUIREMENTS.md and verified above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
| File | Line | Pattern | Severity | Impact |
|
||||||
|
|------|------|---------|----------|--------|
|
||||||
|
| None | — | — | — | — |
|
||||||
|
|
||||||
|
No TODOs, FIXMEs, placeholders, empty implementations, or console.log-only handlers found in any modified file.
|
||||||
|
|
||||||
|
**Removed code confirmed absent:**
|
||||||
|
- `_selectedPreset` field: not found
|
||||||
|
- `_isCustomFrequency` field: not found
|
||||||
|
- `FrequencyInterval.presets` iteration loop: not found
|
||||||
|
- `_buildCustomFrequencyInput` (old name): not found (correctly renamed to `_buildFrequencyPickerRow`)
|
||||||
|
|
||||||
|
**Backward compatibility confirmed:**
|
||||||
|
- `frequency.dart` is unchanged; `FrequencyInterval.presets` remains in the model for `template_picker_sheet.dart` and `task_row.dart` usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
All automated checks passed. The following items require a running app to confirm the interactive UX behavior:
|
||||||
|
|
||||||
|
#### 1. Frequency Section Layout
|
||||||
|
|
||||||
|
**Test:** Launch the app, navigate to any room, tap "+" to create a new task, scroll to the "Wiederholung" section.
|
||||||
|
**Expected:** A row of 4 shortcut chips (Täglich, Wöchentlich, Alle 2 Wochen, Monatlich) appears in a compact Wrap; below them the always-visible "Alle [N] [Tage|Wochen|Monate]" picker row; "Wöchentlich" chip is highlighted by default; picker shows "1" with "Wochen" segment selected.
|
||||||
|
**Why human:** Visual layout, spacing, and default highlight state require running the app.
|
||||||
|
|
||||||
|
#### 2. Chip-to-Picker Bidirectional Sync
|
||||||
|
|
||||||
|
**Test:** Tap "Täglich" — verify chip highlights and picker updates to "1"/"Tage". Tap "Monatlich" — verify chip highlights and picker updates to "1"/"Monate". Previous chip deselects.
|
||||||
|
**Expected:** Smooth single-tap update of both chip highlight and picker values.
|
||||||
|
**Why human:** Widget interaction and visual state transitions require running the app.
|
||||||
|
|
||||||
|
#### 3. Picker-to-Chip Reverse Sync
|
||||||
|
|
||||||
|
**Test:** With "Wöchentlich" highlighted, type "5" in the number field. Verify all chips deselect. Change unit to "Tage" — still no chip selected. Type "1" with "Tage" selected — verify "Täglich" chip auto-highlights.
|
||||||
|
**Expected:** The picker editing recalculates chip highlight in real time.
|
||||||
|
**Why human:** Text field onChange and SegmentedButton interaction require running the app.
|
||||||
|
|
||||||
|
#### 4. Round-Trip Edit Mode — Shortcut Task
|
||||||
|
|
||||||
|
**Test:** Create a task using "Alle 2 Wochen" shortcut. Re-open it in edit mode.
|
||||||
|
**Expected:** "Alle 2 Wochen" chip is highlighted; picker shows "2" with "Wochen" selected.
|
||||||
|
**Why human:** Requires saving to database and reopening to test _loadExistingTask() end-to-end.
|
||||||
|
|
||||||
|
#### 5. Round-Trip Edit Mode — Non-Shortcut Task
|
||||||
|
|
||||||
|
**Test:** Create a task with freeform "5"/"Tage". Re-open it in edit mode.
|
||||||
|
**Expected:** No chip highlighted; picker shows "5" with "Tage" selected.
|
||||||
|
**Why human:** Requires running the app and database round-trip.
|
||||||
|
|
||||||
|
#### 6. Quarterly / Yearly Task Edit Mode
|
||||||
|
|
||||||
|
**Test:** If an existing quarterly or yearly task is available, open it in edit mode.
|
||||||
|
**Expected:** Quarterly task: no chip highlighted, picker shows "3"/"Monate". Yearly task: picker shows "12"/"Monate" with no chip.
|
||||||
|
**Why human:** Requires an existing task with IntervalType.quarterly or IntervalType.yearly in the database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Static Analysis and Tests
|
||||||
|
|
||||||
|
- `flutter analyze --no-fatal-infos`: **No issues found** (ran 2026-03-18)
|
||||||
|
- `flutter test`: **144/144 tests passed** (ran 2026-03-18)
|
||||||
|
- Commit `8a0b69b` verified: feat(09-01) with correct 4-file diff (task_form_screen.dart +179/-115, app_de.arb +4, app_localizations.dart +24, app_localizations_de.dart +12)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Gaps Summary
|
||||||
|
|
||||||
|
No gaps found. All automated must-haves are verified. The phase goal — intuitive frequency selection with shortcut chips and always-visible freeform picker — is fully implemented in the codebase. Human verification of interactive UX behavior is the only remaining item.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-18T23:00:00Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
134
.planning/phases/10-dead-code-cleanup/10-01-PLAN.md
Normal file
134
.planning/phases/10-dead-code-cleanup/10-01-PLAN.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
---
|
||||||
|
phase: 10-dead-code-cleanup
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- lib/features/home/presentation/daily_plan_providers.dart
|
||||||
|
- lib/features/home/presentation/daily_plan_task_row.dart
|
||||||
|
- lib/features/home/presentation/progress_card.dart
|
||||||
|
- lib/features/home/domain/daily_plan_models.dart
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CLN-01]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "daily_plan_providers.dart no longer exists in the codebase"
|
||||||
|
- "daily_plan_task_row.dart no longer exists in the codebase"
|
||||||
|
- "progress_card.dart no longer exists in the codebase"
|
||||||
|
- "DailyPlanDao is still registered in database.dart and functional"
|
||||||
|
- "TaskWithRoom class still exists and is importable by calendar system"
|
||||||
|
- "All 144 tests pass without failures"
|
||||||
|
- "dart analyze reports zero issues"
|
||||||
|
artifacts:
|
||||||
|
- path: "lib/features/home/domain/daily_plan_models.dart"
|
||||||
|
provides: "TaskWithRoom class (DailyPlanState removed)"
|
||||||
|
contains: "class TaskWithRoom"
|
||||||
|
key_links:
|
||||||
|
- from: "lib/features/home/data/calendar_dao.dart"
|
||||||
|
to: "lib/features/home/domain/daily_plan_models.dart"
|
||||||
|
via: "import for TaskWithRoom"
|
||||||
|
pattern: "import.*daily_plan_models"
|
||||||
|
- from: "lib/features/home/presentation/calendar_providers.dart"
|
||||||
|
to: "lib/features/home/domain/daily_plan_models.dart"
|
||||||
|
via: "import for TaskWithRoom"
|
||||||
|
pattern: "import.*daily_plan_models"
|
||||||
|
- from: "lib/core/database/database.dart"
|
||||||
|
to: "lib/features/home/data/daily_plan_dao.dart"
|
||||||
|
via: "DAO registration in @DriftDatabase annotation"
|
||||||
|
pattern: "DailyPlanDao"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Delete three orphaned v1.0 daily plan presentation files and clean up the orphaned DailyPlanState class from the domain models file, then verify no regressions.
|
||||||
|
|
||||||
|
Purpose: These files were superseded by the calendar strip (Phase 5, v1.1) but never removed. Cleaning them prevents confusion and reduces maintenance surface.
|
||||||
|
Output: Three files deleted, one file trimmed, zero test/analysis regressions.
|
||||||
|
</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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Delete orphaned files and remove DailyPlanState</name>
|
||||||
|
<files>
|
||||||
|
lib/features/home/presentation/daily_plan_providers.dart (DELETE)
|
||||||
|
lib/features/home/presentation/daily_plan_task_row.dart (DELETE)
|
||||||
|
lib/features/home/presentation/progress_card.dart (DELETE)
|
||||||
|
lib/features/home/domain/daily_plan_models.dart (MODIFY)
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Delete these three files entirely (use `rm` or equivalent):
|
||||||
|
- lib/features/home/presentation/daily_plan_providers.dart
|
||||||
|
- lib/features/home/presentation/daily_plan_task_row.dart
|
||||||
|
- lib/features/home/presentation/progress_card.dart
|
||||||
|
|
||||||
|
2. Edit lib/features/home/domain/daily_plan_models.dart:
|
||||||
|
- Remove the DailyPlanState class (lines 16-31) entirely. It is only used by the now-deleted daily_plan_providers.dart.
|
||||||
|
- Keep the TaskWithRoom class intact — it is used by calendar_dao.dart, calendar_models.dart, calendar_providers.dart, calendar_day_list.dart, calendar_task_row.dart, and daily_plan_dao.dart.
|
||||||
|
- Keep the existing import of database.dart at line 1.
|
||||||
|
|
||||||
|
3. DO NOT touch these files (they are still in use):
|
||||||
|
- lib/features/home/data/daily_plan_dao.dart (used by database.dart daos list and settings_screen.dart)
|
||||||
|
- lib/features/home/data/daily_plan_dao.g.dart (generated, paired with DAO)
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>ls lib/features/home/presentation/daily_plan_providers.dart lib/features/home/presentation/daily_plan_task_row.dart lib/features/home/presentation/progress_card.dart 2>&1 | grep -c "No such file" | grep -q 3 && grep -c "DailyPlanState" lib/features/home/domain/daily_plan_models.dart | grep -q 0 && grep -c "TaskWithRoom" lib/features/home/domain/daily_plan_models.dart | grep -qv 0 && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Three dead files deleted, DailyPlanState removed from daily_plan_models.dart, TaskWithRoom preserved</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Verify zero regressions</name>
|
||||||
|
<files>
|
||||||
|
(no files modified — verification only)
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Run `dart analyze` from the project root. Must report "No issues found!" with zero errors, warnings, or infos. If any issues appear related to the deleted files (unused imports, missing references), fix them — but based on codebase analysis, none are expected since the three files have zero importers.
|
||||||
|
|
||||||
|
2. Run `flutter test` from the project root. All 144 tests must pass. No test references the deleted files or DailyPlanState (confirmed via grep during planning).
|
||||||
|
|
||||||
|
3. If dart analyze reveals any issue (unexpected import of deleted file elsewhere), fix the import. This is a safety net — grep during planning found zero references, but the analyzer is authoritative.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dart analyze 2>&1 | tail -1 | grep -q "No issues found" && flutter test --reporter compact 2>&1 | tail -1 | grep -q "All tests passed" && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>dart analyze reports zero issues AND all 144+ tests pass — no regressions from dead code removal</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `ls lib/features/home/presentation/daily_plan_providers.dart` returns "No such file"
|
||||||
|
2. `ls lib/features/home/presentation/daily_plan_task_row.dart` returns "No such file"
|
||||||
|
3. `ls lib/features/home/presentation/progress_card.dart` returns "No such file"
|
||||||
|
4. `grep "DailyPlanDao" lib/core/database/database.dart` still shows the DAO in the daos list
|
||||||
|
5. `grep "TaskWithRoom" lib/features/home/domain/daily_plan_models.dart` still shows the class
|
||||||
|
6. `grep "DailyPlanState" lib/features/home/domain/daily_plan_models.dart` returns no matches
|
||||||
|
7. `dart analyze` reports zero issues
|
||||||
|
8. `flutter test` — all tests pass
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Three orphaned presentation files are deleted from the codebase
|
||||||
|
- DailyPlanState class is removed from daily_plan_models.dart
|
||||||
|
- TaskWithRoom class is preserved in daily_plan_models.dart
|
||||||
|
- DailyPlanDao is preserved and still registered in database.dart
|
||||||
|
- `dart analyze` reports zero issues
|
||||||
|
- All 144+ tests pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/10-dead-code-cleanup/10-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
107
.planning/phases/10-dead-code-cleanup/10-01-SUMMARY.md
Normal file
107
.planning/phases/10-dead-code-cleanup/10-01-SUMMARY.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
phase: 10-dead-code-cleanup
|
||||||
|
plan: 01
|
||||||
|
subsystem: ui
|
||||||
|
tags: [flutter, dead-code, cleanup, daily-plan, calendar]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 05-calendar-strip
|
||||||
|
provides: "Calendar strip that superseded daily_plan_providers.dart, daily_plan_task_row.dart, progress_card.dart"
|
||||||
|
provides:
|
||||||
|
- "Three orphaned v1.0 daily plan presentation files removed from codebase"
|
||||||
|
- "DailyPlanState class removed; TaskWithRoom class retained in daily_plan_models.dart"
|
||||||
|
affects: []
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns: []
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- lib/features/home/domain/daily_plan_models.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "DailyPlanDao kept in database.dart registration — still used by notification/settings service; only the presentation layer files were deleted"
|
||||||
|
- "TaskWithRoom retained in daily_plan_models.dart — actively imported by calendar_dao.dart, calendar_providers.dart, and related calendar files"
|
||||||
|
|
||||||
|
patterns-established: []
|
||||||
|
|
||||||
|
requirements-completed: [CLN-01]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 5min
|
||||||
|
completed: 2026-03-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10 Plan 01: Dead Code Cleanup Summary
|
||||||
|
|
||||||
|
**Deleted three orphaned v1.0 daily plan presentation files and stripped DailyPlanState from domain models, leaving TaskWithRoom intact for the calendar system — zero test/analysis regressions across all 144 tests.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~5 min
|
||||||
|
- **Started:** 2026-03-19T00:00:54Z
|
||||||
|
- **Completed:** 2026-03-19T00:05:00Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 4 (3 deleted, 1 trimmed)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Deleted `daily_plan_providers.dart`, `daily_plan_task_row.dart`, and `progress_card.dart` — all orphaned since Phase 5 replaced the daily plan UI with the calendar strip
|
||||||
|
- Removed `DailyPlanState` class from `daily_plan_models.dart` (it was only referenced by the now-deleted providers file)
|
||||||
|
- Preserved `TaskWithRoom` in `daily_plan_models.dart` — confirmed it remains importable by calendar system files
|
||||||
|
- `dart analyze` reports zero issues; all 144 tests pass with no regressions
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Delete orphaned files and remove DailyPlanState** - `510529a` (chore)
|
||||||
|
2. **Task 2: Verify zero regressions** - verification only, no file changes
|
||||||
|
|
||||||
|
**Plan metadata:** `80e7011` (docs: complete dead-code-cleanup plan)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `lib/features/home/domain/daily_plan_models.dart` - Removed DailyPlanState class (lines 16-31); TaskWithRoom preserved
|
||||||
|
- `lib/features/home/presentation/daily_plan_providers.dart` - DELETED (orphaned v1.0 file)
|
||||||
|
- `lib/features/home/presentation/daily_plan_task_row.dart` - DELETED (orphaned v1.0 file)
|
||||||
|
- `lib/features/home/presentation/progress_card.dart` - DELETED (orphaned v1.0 file)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- DailyPlanDao was NOT removed from `database.dart` — it is still registered in the `@DriftDatabase` annotation and used by `settings_screen.dart`. Only the presentation layer files were deleted.
|
||||||
|
- TaskWithRoom was kept because it is imported by: `calendar_dao.dart`, `calendar_providers.dart`, `calendar_models.dart`, `calendar_day_list.dart`, `calendar_task_row.dart`, and `daily_plan_dao.dart`.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- Phase 10 dead code cleanup complete
|
||||||
|
- No blockers — dead code that was tracked as a blocker in STATE.md is now resolved
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 10-dead-code-cleanup*
|
||||||
|
*Completed: 2026-03-19*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND (deleted): lib/features/home/presentation/daily_plan_providers.dart
|
||||||
|
- FOUND (deleted): lib/features/home/presentation/daily_plan_task_row.dart
|
||||||
|
- FOUND (deleted): lib/features/home/presentation/progress_card.dart
|
||||||
|
- FOUND: lib/features/home/domain/daily_plan_models.dart (with TaskWithRoom, without DailyPlanState)
|
||||||
|
- FOUND: commit 510529a (chore: delete orphaned files)
|
||||||
|
- FOUND: commit 80e7011 (docs: complete plan)
|
||||||
80
.planning/phases/10-dead-code-cleanup/10-VERIFICATION.md
Normal file
80
.planning/phases/10-dead-code-cleanup/10-VERIFICATION.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
phase: 10-dead-code-cleanup
|
||||||
|
verified: 2026-03-19T00:00:00Z
|
||||||
|
status: passed
|
||||||
|
score: 7/7 must-haves verified
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10: Dead Code Cleanup Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Remove orphaned v1.0 daily plan files that are no longer used after the calendar strip replacement, keeping the codebase clean
|
||||||
|
**Verified:** 2026-03-19
|
||||||
|
**Status:** PASSED
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|----|--------------------------------------------------------------|------------|--------------------------------------------------------------------------|
|
||||||
|
| 1 | `daily_plan_providers.dart` no longer exists in the codebase | VERIFIED | `ls` returns "No such file or directory"; no references in lib/ |
|
||||||
|
| 2 | `daily_plan_task_row.dart` no longer exists in the codebase | VERIFIED | `ls` returns "No such file or directory"; no references in lib/ |
|
||||||
|
| 3 | `progress_card.dart` no longer exists in the codebase | VERIFIED | `ls` returns "No such file or directory"; no references in lib/ |
|
||||||
|
| 4 | `DailyPlanDao` is still registered in `database.dart` | VERIFIED | Line 51: `daos: [RoomsDao, TasksDao, DailyPlanDao, CalendarDao]` |
|
||||||
|
| 5 | `TaskWithRoom` class still exists and is importable | VERIFIED | Defined in `daily_plan_models.dart:4`; imported by 6+ calendar files |
|
||||||
|
| 6 | All 144 tests pass without failures | VERIFIED | `flutter test` output: `+144: All tests passed!` |
|
||||||
|
| 7 | `dart analyze` reports zero issues | VERIFIED | `Analyzing HouseHoldKeaper... No issues found!` |
|
||||||
|
|
||||||
|
**Score:** 7/7 truths verified
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|-------------------------------------------------------|---------------------------------------|----------|---------------------------------------------------------------------------------------------|
|
||||||
|
| `lib/features/home/domain/daily_plan_models.dart` | TaskWithRoom class; DailyPlanState removed | VERIFIED | Contains `class TaskWithRoom` only; `DailyPlanState` grep returns no matches in entire lib/ |
|
||||||
|
| `lib/features/home/presentation/daily_plan_providers.dart` | DELETED | VERIFIED | File does not exist; confirmed by `ls` |
|
||||||
|
| `lib/features/home/presentation/daily_plan_task_row.dart` | DELETED | VERIFIED | File does not exist; confirmed by `ls` |
|
||||||
|
| `lib/features/home/presentation/progress_card.dart` | DELETED | VERIFIED | File does not exist; confirmed by `ls` |
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|-------------------------------------------------------------------|-------------------------------------------------|--------------------------------------|----------|---------------------------------------------------------------------------|
|
||||||
|
| `lib/features/home/data/calendar_dao.dart` | `lib/features/home/domain/daily_plan_models.dart` | `import.*daily_plan_models` | VERIFIED | Line 4: `import '../domain/daily_plan_models.dart';` |
|
||||||
|
| `lib/features/home/presentation/calendar_providers.dart` | `lib/features/home/domain/daily_plan_models.dart` | `import.*daily_plan_models` | VERIFIED | Line 5: `import 'package:household_keeper/features/home/domain/daily_plan_models.dart';` |
|
||||||
|
| `lib/core/database/database.dart` | `lib/features/home/data/daily_plan_dao.dart` | `DailyPlanDao` in `@DriftDatabase` | VERIFIED | Line 51: `daos: [RoomsDao, TasksDao, DailyPlanDao, CalendarDao]` |
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|-------------|-----------------------------------------------------------------------------------------------------------------|-----------|------------------------------------------------------------------------------------------------------|
|
||||||
|
| CLN-01 | 10-01-PLAN | Dead code from v1.0 daily plan (daily_plan_providers.dart, daily_plan_task_row.dart, progress_card.dart) is removed without breaking notification service (DailyPlanDao must be preserved) | SATISFIED | All three files deleted; DailyPlanDao still registered in database.dart; 144 tests pass; zero analyze issues |
|
||||||
|
|
||||||
|
No orphaned requirements detected. CLN-01 is the only requirement assigned to Phase 10 in REQUIREMENTS.md, and it is covered by plan 10-01.
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
None detected. No TODO/FIXME/placeholder comments or empty implementations found in modified files.
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
None. All success criteria for this cleanup phase are programmatically verifiable: file deletion, class presence/absence, DAO registration, test pass count, and static analysis output.
|
||||||
|
|
||||||
|
### Gaps Summary
|
||||||
|
|
||||||
|
No gaps. All seven must-have truths are verified against the actual codebase:
|
||||||
|
|
||||||
|
- Three orphaned presentation files (`daily_plan_providers.dart`, `daily_plan_task_row.dart`, `progress_card.dart`) are fully deleted with no import references remaining anywhere in `lib/`.
|
||||||
|
- `DailyPlanState` class is absent from `daily_plan_models.dart`; `TaskWithRoom` is intact and actively used by 6+ calendar files.
|
||||||
|
- `DailyPlanDao` remains registered in the `@DriftDatabase` annotation on `database.dart` (line 51).
|
||||||
|
- Both task commits (`510529a`, `80e7011`) exist in git history.
|
||||||
|
- `dart analyze` reports zero issues.
|
||||||
|
- All 144 tests pass.
|
||||||
|
|
||||||
|
Phase goal is fully achieved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-19_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
All notable changes to HouseHoldKeeper are documented in this file.
|
All notable changes to HouseHoldKeeper are documented in this file.
|
||||||
|
|
||||||
|
## [1.1.5] - 2026-03-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Install jq before Flutter setup in CI and release workflows (required by subosito/flutter-action)
|
||||||
|
- Remove `dart pub audit` step (not available in stable Flutter SDK on runner)
|
||||||
|
|
||||||
|
## [1.1.4] - 2026-03-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- CI workflow for branch pushes and pull requests with static analysis, tests, security audit, and debug build
|
||||||
|
- Security gate in release workflow — CI checks must pass before release build proceeds
|
||||||
|
- F-Droid store icon (512x512) for en-US and de-DE metadata
|
||||||
|
|
||||||
## [1.1.3] - 2026-03-17
|
## [1.1.3] - 2026-03-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
65
README.md
65
README.md
@@ -1,2 +1,65 @@
|
|||||||
# HouseHoldKeaper
|
# Household Keeper
|
||||||
|
|
||||||
|
Your household, effortlessly organized.
|
||||||
|
|
||||||
|
Household Keeper helps you organize and manage your household tasks. Create rooms, assign tasks, set recurring reminders, and keep your home running smoothly.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Room Management** — Create and organize rooms with drag-and-drop reordering
|
||||||
|
- **Task Templates** — Quickly add common household tasks or create your own
|
||||||
|
- **Recurring Scheduling** — Daily, weekly, monthly, or yearly task recurrence
|
||||||
|
- **Calendar View** — Day-by-day task overview with a floating "Today" button
|
||||||
|
- **Task History** — View past completions for each task
|
||||||
|
- **Task Sorting** — Sort by name, due date, or room with persistent preferences
|
||||||
|
- **Notifications** — Local reminders for due tasks
|
||||||
|
- **Light & Dark Theme** — Follows your system preference
|
||||||
|
- **Localization** — German and English
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p float="left">
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.household_keeper/en-US/phoneScreenshots/1_overview.png" width="200" />
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.household_keeper/en-US/phoneScreenshots/2_create_room.png" width="200" />
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.household_keeper/en-US/phoneScreenshots/3_task_templates.png" width="200" />
|
||||||
|
<img src="fdroid-metadata/de.jeanlucmakiola.household_keeper/en-US/phoneScreenshots/4_room_tasks.png" width="200" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Flutter** (SDK ^3.11.0)
|
||||||
|
- **Riverpod** — State management
|
||||||
|
- **Drift** — Local SQLite database
|
||||||
|
- **GoRouter** — Navigation
|
||||||
|
- **flutter_local_notifications** — Scheduled reminders
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo
|
||||||
|
git clone https://gitea.jeanlucmakiola.de/makiolaj/HouseHoldKeaper.git
|
||||||
|
cd HouseHoldKeaper
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
# Generate code (drift, riverpod, l10n)
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
|
# Run the app
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug APK
|
||||||
|
flutter build apk --debug
|
||||||
|
|
||||||
|
# Release APK (requires signing config)
|
||||||
|
flutter build apk --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||||
|
|||||||
352
drift_schemas/household_keeper/drift_schema_v3.json
Normal file
352
drift_schemas/household_keeper/drift_schema_v3.json
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"description": "This file contains a serialized version of schema entities for drift.",
|
||||||
|
"version": "1.3.0"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"store_date_time_values_as_text": false
|
||||||
|
},
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "rooms",
|
||||||
|
"was_declared_in_moor": false,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"defaultConstraints": "PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"dialectAwareDefaultConstraints": {
|
||||||
|
"sqlite": "PRIMARY KEY AUTOINCREMENT"
|
||||||
|
},
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"auto-increment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"getter_name": "name",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
{
|
||||||
|
"allowed-lengths": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_name",
|
||||||
|
"getter_name": "iconName",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sort_order",
|
||||||
|
"getter_name": "sortOrder",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": "const CustomExpression('0')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"getter_name": "createdAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": "() => DateTime.now()",
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"references": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "tasks",
|
||||||
|
"was_declared_in_moor": false,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"defaultConstraints": "PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"dialectAwareDefaultConstraints": {
|
||||||
|
"sqlite": "PRIMARY KEY AUTOINCREMENT"
|
||||||
|
},
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"auto-increment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "room_id",
|
||||||
|
"getter_name": "roomId",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"defaultConstraints": "REFERENCES rooms (id)",
|
||||||
|
"dialectAwareDefaultConstraints": {
|
||||||
|
"sqlite": "REFERENCES rooms (id)"
|
||||||
|
},
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
{
|
||||||
|
"foreign_key": {
|
||||||
|
"to": {
|
||||||
|
"table": "rooms",
|
||||||
|
"column": "id"
|
||||||
|
},
|
||||||
|
"initially_deferred": false,
|
||||||
|
"on_update": null,
|
||||||
|
"on_delete": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"getter_name": "name",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
{
|
||||||
|
"allowed-lengths": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"getter_name": "description",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "interval_type",
|
||||||
|
"getter_name": "intervalType",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [],
|
||||||
|
"type_converter": {
|
||||||
|
"dart_expr": "const EnumIndexConverter<IntervalType>(IntervalType.values)",
|
||||||
|
"dart_type_name": "IntervalType"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "interval_days",
|
||||||
|
"getter_name": "intervalDays",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": "const CustomExpression('1')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "anchor_day",
|
||||||
|
"getter_name": "anchorDay",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "effort_level",
|
||||||
|
"getter_name": "effortLevel",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [],
|
||||||
|
"type_converter": {
|
||||||
|
"dart_expr": "const EnumIndexConverter<EffortLevel>(EffortLevel.values)",
|
||||||
|
"dart_type_name": "EffortLevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "next_due_date",
|
||||||
|
"getter_name": "nextDueDate",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"getter_name": "createdAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": "() => DateTime.now()",
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_active",
|
||||||
|
"getter_name": "isActive",
|
||||||
|
"moor_type": "bool",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"defaultConstraints": "CHECK (\"is_active\" IN (0, 1))",
|
||||||
|
"dialectAwareDefaultConstraints": {
|
||||||
|
"sqlite": "CHECK (\"is_active\" IN (0, 1))"
|
||||||
|
},
|
||||||
|
"default_dart": "const CustomExpression('1')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"references": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "task_completions",
|
||||||
|
"was_declared_in_moor": false,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"defaultConstraints": "PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"dialectAwareDefaultConstraints": {
|
||||||
|
"sqlite": "PRIMARY KEY AUTOINCREMENT"
|
||||||
|
},
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"auto-increment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "task_id",
|
||||||
|
"getter_name": "taskId",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"defaultConstraints": "REFERENCES tasks (id)",
|
||||||
|
"dialectAwareDefaultConstraints": {
|
||||||
|
"sqlite": "REFERENCES tasks (id)"
|
||||||
|
},
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
{
|
||||||
|
"foreign_key": {
|
||||||
|
"to": {
|
||||||
|
"table": "tasks",
|
||||||
|
"column": "id"
|
||||||
|
},
|
||||||
|
"initially_deferred": false,
|
||||||
|
"on_update": null,
|
||||||
|
"on_delete": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "completed_at",
|
||||||
|
"getter_name": "completedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": null,
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fixed_sql": [
|
||||||
|
{
|
||||||
|
"name": "rooms",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"rooms\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"name\" TEXT NOT NULL, \"icon_name\" TEXT NOT NULL, \"sort_order\" INTEGER NOT NULL DEFAULT 0, \"created_at\" INTEGER NOT NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tasks",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"tasks\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"room_id\" INTEGER NOT NULL REFERENCES rooms (id), \"name\" TEXT NOT NULL, \"description\" TEXT NULL, \"interval_type\" INTEGER NOT NULL, \"interval_days\" INTEGER NOT NULL DEFAULT 1, \"anchor_day\" INTEGER NULL, \"effort_level\" INTEGER NOT NULL, \"next_due_date\" INTEGER NOT NULL, \"created_at\" INTEGER NOT NULL, \"is_active\" INTEGER NOT NULL DEFAULT 1 CHECK (\"is_active\" IN (0, 1)));"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "task_completions",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"task_completions\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"task_id\" INTEGER NOT NULL REFERENCES tasks (id), \"completed_at\" INTEGER NOT NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -35,6 +35,8 @@ class Tasks extends Table {
|
|||||||
DateTimeColumn get nextDueDate => dateTime()();
|
DateTimeColumn get nextDueDate => dateTime()();
|
||||||
DateTimeColumn get createdAt =>
|
DateTimeColumn get createdAt =>
|
||||||
dateTime().clientDefault(() => DateTime.now())();
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
BoolColumn get isActive =>
|
||||||
|
boolean().withDefault(const Constant(true))();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TaskCompletions table: records when a task was completed.
|
/// TaskCompletions table: records when a task was completed.
|
||||||
@@ -53,7 +55,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
: super(executor ?? _openConnection());
|
: super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 2;
|
int get schemaVersion => 3;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@@ -67,6 +69,9 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await m.createTable(tasks);
|
await m.createTable(tasks);
|
||||||
await m.createTable(taskCompletions);
|
await m.createTable(taskCompletions);
|
||||||
}
|
}
|
||||||
|
if (from < 3) {
|
||||||
|
await m.addColumn(tasks, tasks.isActive);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
await customStatement('PRAGMA foreign_keys = ON');
|
await customStatement('PRAGMA foreign_keys = ON');
|
||||||
|
|||||||
@@ -470,6 +470,21 @@ class $TasksTable extends Tasks with TableInfo<$TasksTable, Task> {
|
|||||||
requiredDuringInsert: false,
|
requiredDuringInsert: false,
|
||||||
clientDefault: () => DateTime.now(),
|
clientDefault: () => DateTime.now(),
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _isActiveMeta = const VerificationMeta(
|
||||||
|
'isActive',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<bool> isActive = GeneratedColumn<bool>(
|
||||||
|
'is_active',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.bool,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||||
|
'CHECK ("is_active" IN (0, 1))',
|
||||||
|
),
|
||||||
|
defaultValue: const Constant(true),
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
List<GeneratedColumn> get $columns => [
|
List<GeneratedColumn> get $columns => [
|
||||||
id,
|
id,
|
||||||
@@ -482,6 +497,7 @@ class $TasksTable extends Tasks with TableInfo<$TasksTable, Task> {
|
|||||||
effortLevel,
|
effortLevel,
|
||||||
nextDueDate,
|
nextDueDate,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
isActive,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -555,6 +571,12 @@ class $TasksTable extends Tasks with TableInfo<$TasksTable, Task> {
|
|||||||
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
|
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('is_active')) {
|
||||||
|
context.handle(
|
||||||
|
_isActiveMeta,
|
||||||
|
isActive.isAcceptableOrUnknown(data['is_active']!, _isActiveMeta),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +630,10 @@ class $TasksTable extends Tasks with TableInfo<$TasksTable, Task> {
|
|||||||
DriftSqlType.dateTime,
|
DriftSqlType.dateTime,
|
||||||
data['${effectivePrefix}created_at'],
|
data['${effectivePrefix}created_at'],
|
||||||
)!,
|
)!,
|
||||||
|
isActive: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.bool,
|
||||||
|
data['${effectivePrefix}is_active'],
|
||||||
|
)!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,6 +659,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
final EffortLevel effortLevel;
|
final EffortLevel effortLevel;
|
||||||
final DateTime nextDueDate;
|
final DateTime nextDueDate;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
final bool isActive;
|
||||||
const Task({
|
const Task({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.roomId,
|
required this.roomId,
|
||||||
@@ -644,6 +671,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
required this.effortLevel,
|
required this.effortLevel,
|
||||||
required this.nextDueDate,
|
required this.nextDueDate,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
required this.isActive,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -670,6 +698,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
}
|
}
|
||||||
map['next_due_date'] = Variable<DateTime>(nextDueDate);
|
map['next_due_date'] = Variable<DateTime>(nextDueDate);
|
||||||
map['created_at'] = Variable<DateTime>(createdAt);
|
map['created_at'] = Variable<DateTime>(createdAt);
|
||||||
|
map['is_active'] = Variable<bool>(isActive);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,6 +718,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
effortLevel: Value(effortLevel),
|
effortLevel: Value(effortLevel),
|
||||||
nextDueDate: Value(nextDueDate),
|
nextDueDate: Value(nextDueDate),
|
||||||
createdAt: Value(createdAt),
|
createdAt: Value(createdAt),
|
||||||
|
isActive: Value(isActive),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,6 +742,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
),
|
),
|
||||||
nextDueDate: serializer.fromJson<DateTime>(json['nextDueDate']),
|
nextDueDate: serializer.fromJson<DateTime>(json['nextDueDate']),
|
||||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||||
|
isActive: serializer.fromJson<bool>(json['isActive']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -732,6 +763,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
),
|
),
|
||||||
'nextDueDate': serializer.toJson<DateTime>(nextDueDate),
|
'nextDueDate': serializer.toJson<DateTime>(nextDueDate),
|
||||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||||
|
'isActive': serializer.toJson<bool>(isActive),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +778,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
EffortLevel? effortLevel,
|
EffortLevel? effortLevel,
|
||||||
DateTime? nextDueDate,
|
DateTime? nextDueDate,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
bool? isActive,
|
||||||
}) => Task(
|
}) => Task(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
roomId: roomId ?? this.roomId,
|
roomId: roomId ?? this.roomId,
|
||||||
@@ -757,6 +790,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
effortLevel: effortLevel ?? this.effortLevel,
|
effortLevel: effortLevel ?? this.effortLevel,
|
||||||
nextDueDate: nextDueDate ?? this.nextDueDate,
|
nextDueDate: nextDueDate ?? this.nextDueDate,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
);
|
);
|
||||||
Task copyWithCompanion(TasksCompanion data) {
|
Task copyWithCompanion(TasksCompanion data) {
|
||||||
return Task(
|
return Task(
|
||||||
@@ -780,6 +814,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
? data.nextDueDate.value
|
? data.nextDueDate.value
|
||||||
: this.nextDueDate,
|
: this.nextDueDate,
|
||||||
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
|
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
|
||||||
|
isActive: data.isActive.present ? data.isActive.value : this.isActive,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,7 +830,8 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
..write('anchorDay: $anchorDay, ')
|
..write('anchorDay: $anchorDay, ')
|
||||||
..write('effortLevel: $effortLevel, ')
|
..write('effortLevel: $effortLevel, ')
|
||||||
..write('nextDueDate: $nextDueDate, ')
|
..write('nextDueDate: $nextDueDate, ')
|
||||||
..write('createdAt: $createdAt')
|
..write('createdAt: $createdAt, ')
|
||||||
|
..write('isActive: $isActive')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -812,6 +848,7 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
effortLevel,
|
effortLevel,
|
||||||
nextDueDate,
|
nextDueDate,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
isActive,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -826,7 +863,8 @@ class Task extends DataClass implements Insertable<Task> {
|
|||||||
other.anchorDay == this.anchorDay &&
|
other.anchorDay == this.anchorDay &&
|
||||||
other.effortLevel == this.effortLevel &&
|
other.effortLevel == this.effortLevel &&
|
||||||
other.nextDueDate == this.nextDueDate &&
|
other.nextDueDate == this.nextDueDate &&
|
||||||
other.createdAt == this.createdAt);
|
other.createdAt == this.createdAt &&
|
||||||
|
other.isActive == this.isActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
class TasksCompanion extends UpdateCompanion<Task> {
|
class TasksCompanion extends UpdateCompanion<Task> {
|
||||||
@@ -840,6 +878,7 @@ class TasksCompanion extends UpdateCompanion<Task> {
|
|||||||
final Value<EffortLevel> effortLevel;
|
final Value<EffortLevel> effortLevel;
|
||||||
final Value<DateTime> nextDueDate;
|
final Value<DateTime> nextDueDate;
|
||||||
final Value<DateTime> createdAt;
|
final Value<DateTime> createdAt;
|
||||||
|
final Value<bool> isActive;
|
||||||
const TasksCompanion({
|
const TasksCompanion({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
this.roomId = const Value.absent(),
|
this.roomId = const Value.absent(),
|
||||||
@@ -851,6 +890,7 @@ class TasksCompanion extends UpdateCompanion<Task> {
|
|||||||
this.effortLevel = const Value.absent(),
|
this.effortLevel = const Value.absent(),
|
||||||
this.nextDueDate = const Value.absent(),
|
this.nextDueDate = const Value.absent(),
|
||||||
this.createdAt = const Value.absent(),
|
this.createdAt = const Value.absent(),
|
||||||
|
this.isActive = const Value.absent(),
|
||||||
});
|
});
|
||||||
TasksCompanion.insert({
|
TasksCompanion.insert({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
@@ -863,6 +903,7 @@ class TasksCompanion extends UpdateCompanion<Task> {
|
|||||||
required EffortLevel effortLevel,
|
required EffortLevel effortLevel,
|
||||||
required DateTime nextDueDate,
|
required DateTime nextDueDate,
|
||||||
this.createdAt = const Value.absent(),
|
this.createdAt = const Value.absent(),
|
||||||
|
this.isActive = const Value.absent(),
|
||||||
}) : roomId = Value(roomId),
|
}) : roomId = Value(roomId),
|
||||||
name = Value(name),
|
name = Value(name),
|
||||||
intervalType = Value(intervalType),
|
intervalType = Value(intervalType),
|
||||||
@@ -879,6 +920,7 @@ class TasksCompanion extends UpdateCompanion<Task> {
|
|||||||
Expression<int>? effortLevel,
|
Expression<int>? effortLevel,
|
||||||
Expression<DateTime>? nextDueDate,
|
Expression<DateTime>? nextDueDate,
|
||||||
Expression<DateTime>? createdAt,
|
Expression<DateTime>? createdAt,
|
||||||
|
Expression<bool>? isActive,
|
||||||
}) {
|
}) {
|
||||||
return RawValuesInsertable({
|
return RawValuesInsertable({
|
||||||
if (id != null) 'id': id,
|
if (id != null) 'id': id,
|
||||||
@@ -891,6 +933,7 @@ class TasksCompanion extends UpdateCompanion<Task> {
|
|||||||
if (effortLevel != null) 'effort_level': effortLevel,
|
if (effortLevel != null) 'effort_level': effortLevel,
|
||||||
if (nextDueDate != null) 'next_due_date': nextDueDate,
|
if (nextDueDate != null) 'next_due_date': nextDueDate,
|
||||||
if (createdAt != null) 'created_at': createdAt,
|
if (createdAt != null) 'created_at': createdAt,
|
||||||
|
if (isActive != null) 'is_active': isActive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,6 +948,7 @@ class TasksCompanion extends UpdateCompanion<Task> {
|
|||||||
Value<EffortLevel>? effortLevel,
|
Value<EffortLevel>? effortLevel,
|
||||||
Value<DateTime>? nextDueDate,
|
Value<DateTime>? nextDueDate,
|
||||||
Value<DateTime>? createdAt,
|
Value<DateTime>? createdAt,
|
||||||
|
Value<bool>? isActive,
|
||||||
}) {
|
}) {
|
||||||
return TasksCompanion(
|
return TasksCompanion(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -917,6 +961,7 @@ class TasksCompanion extends UpdateCompanion<Task> {
|
|||||||
effortLevel: effortLevel ?? this.effortLevel,
|
effortLevel: effortLevel ?? this.effortLevel,
|
||||||
nextDueDate: nextDueDate ?? this.nextDueDate,
|
nextDueDate: nextDueDate ?? this.nextDueDate,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -957,6 +1002,9 @@ class TasksCompanion extends UpdateCompanion<Task> {
|
|||||||
if (createdAt.present) {
|
if (createdAt.present) {
|
||||||
map['created_at'] = Variable<DateTime>(createdAt.value);
|
map['created_at'] = Variable<DateTime>(createdAt.value);
|
||||||
}
|
}
|
||||||
|
if (isActive.present) {
|
||||||
|
map['is_active'] = Variable<bool>(isActive.value);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,7 +1020,8 @@ class TasksCompanion extends UpdateCompanion<Task> {
|
|||||||
..write('anchorDay: $anchorDay, ')
|
..write('anchorDay: $anchorDay, ')
|
||||||
..write('effortLevel: $effortLevel, ')
|
..write('effortLevel: $effortLevel, ')
|
||||||
..write('nextDueDate: $nextDueDate, ')
|
..write('nextDueDate: $nextDueDate, ')
|
||||||
..write('createdAt: $createdAt')
|
..write('createdAt: $createdAt, ')
|
||||||
|
..write('isActive: $isActive')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1556,6 +1605,7 @@ typedef $$TasksTableCreateCompanionBuilder =
|
|||||||
required EffortLevel effortLevel,
|
required EffortLevel effortLevel,
|
||||||
required DateTime nextDueDate,
|
required DateTime nextDueDate,
|
||||||
Value<DateTime> createdAt,
|
Value<DateTime> createdAt,
|
||||||
|
Value<bool> isActive,
|
||||||
});
|
});
|
||||||
typedef $$TasksTableUpdateCompanionBuilder =
|
typedef $$TasksTableUpdateCompanionBuilder =
|
||||||
TasksCompanion Function({
|
TasksCompanion Function({
|
||||||
@@ -1569,6 +1619,7 @@ typedef $$TasksTableUpdateCompanionBuilder =
|
|||||||
Value<EffortLevel> effortLevel,
|
Value<EffortLevel> effortLevel,
|
||||||
Value<DateTime> nextDueDate,
|
Value<DateTime> nextDueDate,
|
||||||
Value<DateTime> createdAt,
|
Value<DateTime> createdAt,
|
||||||
|
Value<bool> isActive,
|
||||||
});
|
});
|
||||||
|
|
||||||
final class $$TasksTableReferences
|
final class $$TasksTableReferences
|
||||||
@@ -1668,6 +1719,11 @@ class $$TasksTableFilterComposer extends Composer<_$AppDatabase, $TasksTable> {
|
|||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<bool> get isActive => $composableBuilder(
|
||||||
|
column: $table.isActive,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
$$RoomsTableFilterComposer get roomId {
|
$$RoomsTableFilterComposer get roomId {
|
||||||
final $$RoomsTableFilterComposer composer = $composerBuilder(
|
final $$RoomsTableFilterComposer composer = $composerBuilder(
|
||||||
composer: this,
|
composer: this,
|
||||||
@@ -1771,6 +1827,11 @@ class $$TasksTableOrderingComposer
|
|||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<bool> get isActive => $composableBuilder(
|
||||||
|
column: $table.isActive,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
$$RoomsTableOrderingComposer get roomId {
|
$$RoomsTableOrderingComposer get roomId {
|
||||||
final $$RoomsTableOrderingComposer composer = $composerBuilder(
|
final $$RoomsTableOrderingComposer composer = $composerBuilder(
|
||||||
composer: this,
|
composer: this,
|
||||||
@@ -1843,6 +1904,9 @@ class $$TasksTableAnnotationComposer
|
|||||||
GeneratedColumn<DateTime> get createdAt =>
|
GeneratedColumn<DateTime> get createdAt =>
|
||||||
$composableBuilder(column: $table.createdAt, builder: (column) => column);
|
$composableBuilder(column: $table.createdAt, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<bool> get isActive =>
|
||||||
|
$composableBuilder(column: $table.isActive, builder: (column) => column);
|
||||||
|
|
||||||
$$RoomsTableAnnotationComposer get roomId {
|
$$RoomsTableAnnotationComposer get roomId {
|
||||||
final $$RoomsTableAnnotationComposer composer = $composerBuilder(
|
final $$RoomsTableAnnotationComposer composer = $composerBuilder(
|
||||||
composer: this,
|
composer: this,
|
||||||
@@ -1930,6 +1994,7 @@ class $$TasksTableTableManager
|
|||||||
Value<EffortLevel> effortLevel = const Value.absent(),
|
Value<EffortLevel> effortLevel = const Value.absent(),
|
||||||
Value<DateTime> nextDueDate = const Value.absent(),
|
Value<DateTime> nextDueDate = const Value.absent(),
|
||||||
Value<DateTime> createdAt = const Value.absent(),
|
Value<DateTime> createdAt = const Value.absent(),
|
||||||
|
Value<bool> isActive = const Value.absent(),
|
||||||
}) => TasksCompanion(
|
}) => TasksCompanion(
|
||||||
id: id,
|
id: id,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
@@ -1941,6 +2006,7 @@ class $$TasksTableTableManager
|
|||||||
effortLevel: effortLevel,
|
effortLevel: effortLevel,
|
||||||
nextDueDate: nextDueDate,
|
nextDueDate: nextDueDate,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
|
isActive: isActive,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
@@ -1954,6 +2020,7 @@ class $$TasksTableTableManager
|
|||||||
required EffortLevel effortLevel,
|
required EffortLevel effortLevel,
|
||||||
required DateTime nextDueDate,
|
required DateTime nextDueDate,
|
||||||
Value<DateTime> createdAt = const Value.absent(),
|
Value<DateTime> createdAt = const Value.absent(),
|
||||||
|
Value<bool> isActive = const Value.absent(),
|
||||||
}) => TasksCompanion.insert(
|
}) => TasksCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
@@ -1965,6 +2032,7 @@ class $$TasksTableTableManager
|
|||||||
effortLevel: effortLevel,
|
effortLevel: effortLevel,
|
||||||
nextDueDate: nextDueDate,
|
nextDueDate: nextDueDate,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
|
isActive: isActive,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
]);
|
]);
|
||||||
query.where(
|
query.where(
|
||||||
tasks.nextDueDate.isBiggerOrEqualValue(startOfDay) &
|
tasks.nextDueDate.isBiggerOrEqualValue(startOfDay) &
|
||||||
tasks.nextDueDate.isSmallerThanValue(endOfDay),
|
tasks.nextDueDate.isSmallerThanValue(endOfDay) &
|
||||||
|
tasks.isActive.equals(true),
|
||||||
);
|
);
|
||||||
query.orderBy([OrderingTerm.asc(tasks.name)]);
|
query.orderBy([OrderingTerm.asc(tasks.name)]);
|
||||||
|
|
||||||
@@ -45,12 +46,14 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the total count of tasks across all rooms and dates.
|
/// Returns the total count of active tasks across all rooms and dates.
|
||||||
///
|
///
|
||||||
/// Used by the UI to distinguish first-run empty state from celebration state.
|
/// Used by the UI to distinguish first-run empty state from celebration state.
|
||||||
Future<int> getTaskCount() async {
|
Future<int> getTaskCount() async {
|
||||||
final countExp = tasks.id.count();
|
final countExp = tasks.id.count();
|
||||||
final query = selectOnly(tasks)..addColumns([countExp]);
|
final query = selectOnly(tasks)
|
||||||
|
..addColumns([countExp])
|
||||||
|
..where(tasks.isActive.equals(true));
|
||||||
final result = await query.getSingle();
|
final result = await query.getSingle();
|
||||||
return result.read(countExp) ?? 0;
|
return result.read(countExp) ?? 0;
|
||||||
}
|
}
|
||||||
@@ -69,7 +72,8 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
query.where(
|
query.where(
|
||||||
tasks.nextDueDate.isBiggerOrEqualValue(startOfDay) &
|
tasks.nextDueDate.isBiggerOrEqualValue(startOfDay) &
|
||||||
tasks.nextDueDate.isSmallerThanValue(endOfDay) &
|
tasks.nextDueDate.isSmallerThanValue(endOfDay) &
|
||||||
tasks.roomId.equals(roomId),
|
tasks.roomId.equals(roomId) &
|
||||||
|
tasks.isActive.equals(true),
|
||||||
);
|
);
|
||||||
query.orderBy([OrderingTerm.asc(tasks.name)]);
|
query.orderBy([OrderingTerm.asc(tasks.name)]);
|
||||||
|
|
||||||
@@ -96,7 +100,10 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
final query = select(tasks).join([
|
final query = select(tasks).join([
|
||||||
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
|
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
|
||||||
]);
|
]);
|
||||||
query.where(tasks.nextDueDate.isSmallerThanValue(startOfReferenceDay));
|
query.where(
|
||||||
|
tasks.nextDueDate.isSmallerThanValue(startOfReferenceDay) &
|
||||||
|
tasks.isActive.equals(true),
|
||||||
|
);
|
||||||
query.orderBy([OrderingTerm.asc(tasks.nextDueDate)]);
|
query.orderBy([OrderingTerm.asc(tasks.nextDueDate)]);
|
||||||
|
|
||||||
return query.watch().map((rows) {
|
return query.watch().map((rows) {
|
||||||
@@ -128,7 +135,8 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
]);
|
]);
|
||||||
query.where(
|
query.where(
|
||||||
tasks.nextDueDate.isSmallerThanValue(startOfReferenceDay) &
|
tasks.nextDueDate.isSmallerThanValue(startOfReferenceDay) &
|
||||||
tasks.roomId.equals(roomId),
|
tasks.roomId.equals(roomId) &
|
||||||
|
tasks.isActive.equals(true),
|
||||||
);
|
);
|
||||||
query.orderBy([OrderingTerm.asc(tasks.nextDueDate)]);
|
query.orderBy([OrderingTerm.asc(tasks.nextDueDate)]);
|
||||||
|
|
||||||
@@ -145,7 +153,7 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total task count within a specific room.
|
/// Total active task count within a specific room.
|
||||||
///
|
///
|
||||||
/// Used to distinguish first-run empty state from celebration state
|
/// Used to distinguish first-run empty state from celebration state
|
||||||
/// in the room calendar view.
|
/// in the room calendar view.
|
||||||
@@ -153,7 +161,7 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
final countExp = tasks.id.count();
|
final countExp = tasks.id.count();
|
||||||
final query = selectOnly(tasks)
|
final query = selectOnly(tasks)
|
||||||
..addColumns([countExp])
|
..addColumns([countExp])
|
||||||
..where(tasks.roomId.equals(roomId));
|
..where(tasks.roomId.equals(roomId) & tasks.isActive.equals(true));
|
||||||
final result = await query.getSingle();
|
final result = await query.getSingle();
|
||||||
return result.read(countExp) ?? 0;
|
return result.read(countExp) ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ class DailyPlanDao extends DatabaseAccessor<AppDatabase>
|
|||||||
with _$DailyPlanDaoMixin {
|
with _$DailyPlanDaoMixin {
|
||||||
DailyPlanDao(super.attachedDatabase);
|
DailyPlanDao(super.attachedDatabase);
|
||||||
|
|
||||||
/// Watch all tasks joined with room name, sorted by nextDueDate ascending.
|
/// Watch all active tasks joined with room name, sorted by nextDueDate ascending.
|
||||||
/// Includes ALL tasks (overdue, today, future) -- filtering is done in the
|
/// Includes overdue, today, and future tasks -- filtering is done in the
|
||||||
/// provider layer to avoid multiple queries.
|
/// provider layer to avoid multiple queries. Excludes soft-deleted tasks.
|
||||||
Stream<List<TaskWithRoom>> watchAllTasksWithRoomName() {
|
Stream<List<TaskWithRoom>> watchAllTasksWithRoomName() {
|
||||||
final query = select(tasks).join([
|
final query = select(tasks).join([
|
||||||
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
|
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
|
||||||
]);
|
]);
|
||||||
|
query.where(tasks.isActive.equals(true));
|
||||||
query.orderBy([OrderingTerm.asc(tasks.nextDueDate)]);
|
query.orderBy([OrderingTerm.asc(tasks.nextDueDate)]);
|
||||||
|
|
||||||
return query.watch().map((rows) {
|
return query.watch().map((rows) {
|
||||||
@@ -32,24 +33,30 @@ class DailyPlanDao extends DatabaseAccessor<AppDatabase>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot count of overdue + today tasks (for notification body).
|
/// One-shot count of overdue + today active tasks (for notification body).
|
||||||
Future<int> getOverdueAndTodayTaskCount({DateTime? today}) async {
|
Future<int> getOverdueAndTodayTaskCount({DateTime? today}) async {
|
||||||
final now = today ?? DateTime.now();
|
final now = today ?? DateTime.now();
|
||||||
final endOfToday = DateTime(now.year, now.month, now.day + 1);
|
final endOfToday = DateTime(now.year, now.month, now.day + 1);
|
||||||
final result = await (selectOnly(tasks)
|
final result = await (selectOnly(tasks)
|
||||||
..addColumns([tasks.id.count()])
|
..addColumns([tasks.id.count()])
|
||||||
..where(tasks.nextDueDate.isSmallerThanValue(endOfToday)))
|
..where(
|
||||||
|
tasks.nextDueDate.isSmallerThanValue(endOfToday) &
|
||||||
|
tasks.isActive.equals(true),
|
||||||
|
))
|
||||||
.getSingle();
|
.getSingle();
|
||||||
return result.read(tasks.id.count()) ?? 0;
|
return result.read(tasks.id.count()) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot count of overdue tasks only (for notification body split).
|
/// One-shot count of overdue active tasks only (for notification body split).
|
||||||
Future<int> getOverdueTaskCount({DateTime? today}) async {
|
Future<int> getOverdueTaskCount({DateTime? today}) async {
|
||||||
final now = today ?? DateTime.now();
|
final now = today ?? DateTime.now();
|
||||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||||
final result = await (selectOnly(tasks)
|
final result = await (selectOnly(tasks)
|
||||||
..addColumns([tasks.id.count()])
|
..addColumns([tasks.id.count()])
|
||||||
..where(tasks.nextDueDate.isSmallerThanValue(startOfToday)))
|
..where(
|
||||||
|
tasks.nextDueDate.isSmallerThanValue(startOfToday) &
|
||||||
|
tasks.isActive.equals(true),
|
||||||
|
))
|
||||||
.getSingle();
|
.getSingle();
|
||||||
return result.read(tasks.id.count()) ?? 0;
|
return result.read(tasks.id.count()) ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,19 +13,3 @@ class TaskWithRoom {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Daily plan data categorized into sections with progress tracking.
|
|
||||||
class DailyPlanState {
|
|
||||||
final List<TaskWithRoom> overdueTasks;
|
|
||||||
final List<TaskWithRoom> todayTasks;
|
|
||||||
final List<TaskWithRoom> tomorrowTasks;
|
|
||||||
final int completedTodayCount;
|
|
||||||
final int totalTodayCount;
|
|
||||||
|
|
||||||
const DailyPlanState({
|
|
||||||
required this.overdueTasks,
|
|
||||||
required this.todayTasks,
|
|
||||||
required this.tomorrowTasks,
|
|
||||||
required this.completedTodayCount,
|
|
||||||
required this.totalTodayCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import 'package:household_keeper/core/providers/database_provider.dart';
|
|
||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
|
||||||
|
|
||||||
/// Reactive daily plan data: tasks categorized into overdue/today/tomorrow
|
|
||||||
/// with progress tracking.
|
|
||||||
///
|
|
||||||
/// Defined manually (not @riverpod) because riverpod_generator has trouble
|
|
||||||
/// with drift's generated [Task] type. Same pattern as [tasksInRoomProvider].
|
|
||||||
final dailyPlanProvider =
|
|
||||||
StreamProvider.autoDispose<DailyPlanState>((ref) {
|
|
||||||
final db = ref.watch(appDatabaseProvider);
|
|
||||||
final taskStream = db.dailyPlanDao.watchAllTasksWithRoomName();
|
|
||||||
|
|
||||||
return taskStream.asyncMap((allTasks) async {
|
|
||||||
// Get today's completion count (latest value from stream)
|
|
||||||
final completedToday =
|
|
||||||
await db.dailyPlanDao.watchCompletionsToday().first;
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
|
||||||
final tomorrow = today.add(const Duration(days: 1));
|
|
||||||
final dayAfterTomorrow = tomorrow.add(const Duration(days: 1));
|
|
||||||
|
|
||||||
final overdue = <TaskWithRoom>[];
|
|
||||||
final todayList = <TaskWithRoom>[];
|
|
||||||
final tomorrowList = <TaskWithRoom>[];
|
|
||||||
|
|
||||||
for (final tw in allTasks) {
|
|
||||||
final dueDate = DateTime(
|
|
||||||
tw.task.nextDueDate.year,
|
|
||||||
tw.task.nextDueDate.month,
|
|
||||||
tw.task.nextDueDate.day,
|
|
||||||
);
|
|
||||||
if (dueDate.isBefore(today)) {
|
|
||||||
overdue.add(tw);
|
|
||||||
} else if (dueDate.isBefore(tomorrow)) {
|
|
||||||
todayList.add(tw);
|
|
||||||
} else if (dueDate.isBefore(dayAfterTomorrow)) {
|
|
||||||
tomorrowList.add(tw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// totalTodayCount includes completedTodayCount so the denominator
|
|
||||||
// stays stable as tasks are completed (their nextDueDate moves to
|
|
||||||
// the future, shrinking overdue+today, but completedToday grows).
|
|
||||||
return DailyPlanState(
|
|
||||||
overdueTasks: overdue,
|
|
||||||
todayTasks: todayList,
|
|
||||||
tomorrowTasks: tomorrowList,
|
|
||||||
completedTodayCount: completedToday,
|
|
||||||
totalTodayCount: overdue.length + todayList.length + completedToday,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
|
||||||
import 'package:household_keeper/features/tasks/domain/relative_date.dart';
|
|
||||||
|
|
||||||
/// Warm coral/terracotta color for overdue indicators.
|
|
||||||
const _overdueColor = Color(0xFFE07A5F);
|
|
||||||
|
|
||||||
/// A task row for the daily plan screen.
|
|
||||||
///
|
|
||||||
/// Shows task name, a tappable room name tag (navigates to room task list),
|
|
||||||
/// relative due date (coral if overdue), and an optional checkbox.
|
|
||||||
///
|
|
||||||
/// Per user decisions:
|
|
||||||
/// - NO onTap or onLongPress on the row itself
|
|
||||||
/// - Only the checkbox and room name tag are interactive
|
|
||||||
/// - Checkbox is hidden for tomorrow (read-only preview) tasks
|
|
||||||
class DailyPlanTaskRow extends StatelessWidget {
|
|
||||||
const DailyPlanTaskRow({
|
|
||||||
super.key,
|
|
||||||
required this.taskWithRoom,
|
|
||||||
required this.showCheckbox,
|
|
||||||
this.onCompleted,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TaskWithRoom taskWithRoom;
|
|
||||||
final bool showCheckbox;
|
|
||||||
final VoidCallback? onCompleted;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final task = taskWithRoom.task;
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
|
||||||
final dueDate = DateTime(
|
|
||||||
task.nextDueDate.year,
|
|
||||||
task.nextDueDate.month,
|
|
||||||
task.nextDueDate.day,
|
|
||||||
);
|
|
||||||
final isOverdue = dueDate.isBefore(today);
|
|
||||||
final relativeDateText = formatRelativeDate(task.nextDueDate, now);
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: showCheckbox
|
|
||||||
? Checkbox(
|
|
||||||
value: false,
|
|
||||||
onChanged: (_) => onCompleted?.call(),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
title: Text(
|
|
||||||
task.name,
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Row(
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => context.go('/rooms/${taskWithRoom.roomId}'),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.secondaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
taskWithRoom.roomName,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSecondaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
relativeDateText,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: isOverdue
|
|
||||||
? _overdueColor
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import 'package:household_keeper/l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
/// A progress banner card showing "X von Y erledigt" with a linear
|
|
||||||
/// progress bar. Displayed at the top of the daily plan screen.
|
|
||||||
class ProgressCard extends StatelessWidget {
|
|
||||||
const ProgressCard({
|
|
||||||
super.key,
|
|
||||||
required this.completed,
|
|
||||||
required this.total,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int completed;
|
|
||||||
final int total;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.dailyPlanProgress(completed, total),
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: total > 0 ? completed / total : 0.0,
|
|
||||||
minHeight: 8,
|
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,7 +44,9 @@ class RoomsDao extends DatabaseAccessor<AppDatabase> with _$RoomsDaoMixin {
|
|||||||
final stats = <RoomWithStats>[];
|
final stats = <RoomWithStats>[];
|
||||||
for (final room in roomList) {
|
for (final room in roomList) {
|
||||||
final taskList = await (select(tasks)
|
final taskList = await (select(tasks)
|
||||||
..where((t) => t.roomId.equals(room.id)))
|
..where(
|
||||||
|
(t) => t.roomId.equals(room.id) & t.isActive.equals(true),
|
||||||
|
))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
final totalTasks = taskList.length;
|
final totalTasks = taskList.length;
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
|
|||||||
TasksDao(super.attachedDatabase);
|
TasksDao(super.attachedDatabase);
|
||||||
|
|
||||||
/// Watch tasks in a room sorted by nextDueDate ascending.
|
/// Watch tasks in a room sorted by nextDueDate ascending.
|
||||||
|
/// Only returns active tasks (isActive = true).
|
||||||
Stream<List<Task>> watchTasksInRoom(int roomId) {
|
Stream<List<Task>> watchTasksInRoom(int roomId) {
|
||||||
return (select(tasks)
|
return (select(tasks)
|
||||||
..where((t) => t.roomId.equals(roomId))
|
..where((t) => t.roomId.equals(roomId) & t.isActive.equals(true))
|
||||||
..orderBy([(t) => OrderingTerm.asc(t.nextDueDate)]))
|
..orderBy([(t) => OrderingTerm.asc(t.nextDueDate)]))
|
||||||
.watch();
|
.watch();
|
||||||
}
|
}
|
||||||
@@ -90,12 +91,13 @@ class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Count overdue tasks in a room (nextDueDate before today).
|
/// Count overdue tasks in a room (nextDueDate before today).
|
||||||
|
/// Only counts active tasks (isActive = true).
|
||||||
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();
|
||||||
final todayDateOnly = DateTime(now.year, now.month, now.day);
|
final todayDateOnly = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
final taskList = await (select(tasks)
|
final taskList = await (select(tasks)
|
||||||
..where((t) => t.roomId.equals(roomId)))
|
..where((t) => t.roomId.equals(roomId) & t.isActive.equals(true)))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
return taskList.where((task) {
|
return taskList.where((task) {
|
||||||
@@ -107,4 +109,21 @@ class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
|
|||||||
return dueDate.isBefore(todayDateOnly);
|
return dueDate.isBefore(todayDateOnly);
|
||||||
}).length;
|
}).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a task by setting isActive to false.
|
||||||
|
/// The task and its completions remain in the database.
|
||||||
|
Future<void> softDeleteTask(int taskId) {
|
||||||
|
return (update(tasks)..where((t) => t.id.equals(taskId)))
|
||||||
|
.write(const TasksCompanion(isActive: Value(false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count completions for a task.
|
||||||
|
Future<int> getCompletionCount(int taskId) async {
|
||||||
|
final count = taskCompletions.id.count();
|
||||||
|
final query = selectOnly(taskCompletions)
|
||||||
|
..addColumns([count])
|
||||||
|
..where(taskCompletions.taskId.equals(taskId));
|
||||||
|
final result = await query.getSingle();
|
||||||
|
return result.read(count) ?? 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,10 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _nameController = TextEditingController();
|
final _nameController = TextEditingController();
|
||||||
final _descriptionController = TextEditingController();
|
final _descriptionController = TextEditingController();
|
||||||
final _customIntervalController = TextEditingController(text: '2');
|
final _customIntervalController = TextEditingController(text: '1');
|
||||||
|
|
||||||
FrequencyInterval? _selectedPreset;
|
_ShortcutFrequency? _activeShortcut;
|
||||||
bool _isCustomFrequency = false;
|
_CustomUnit _customUnit = _CustomUnit.weeks;
|
||||||
_CustomUnit _customUnit = _CustomUnit.days;
|
|
||||||
EffortLevel _effortLevel = EffortLevel.medium;
|
EffortLevel _effortLevel = EffortLevel.medium;
|
||||||
DateTime _dueDate = DateTime.now();
|
DateTime _dueDate = DateTime.now();
|
||||||
Task? _existingTask;
|
Task? _existingTask;
|
||||||
@@ -45,7 +44,9 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedPreset = FrequencyInterval.presets[3]; // Default: weekly
|
_activeShortcut = _ShortcutFrequency.weekly;
|
||||||
|
_customIntervalController.text = '1';
|
||||||
|
_customUnit = _CustomUnit.weeks;
|
||||||
_dueDate = _dateOnly(DateTime.now());
|
_dueDate = _dateOnly(DateTime.now());
|
||||||
if (widget.isEditing) {
|
if (widget.isEditing) {
|
||||||
_loadExistingTask();
|
_loadExistingTask();
|
||||||
@@ -65,38 +66,45 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
_effortLevel = task.effortLevel;
|
_effortLevel = task.effortLevel;
|
||||||
_dueDate = task.nextDueDate;
|
_dueDate = task.nextDueDate;
|
||||||
|
|
||||||
// Find matching preset
|
// Populate picker from stored interval
|
||||||
_selectedPreset = null;
|
switch (task.intervalType) {
|
||||||
_isCustomFrequency = true;
|
case IntervalType.daily:
|
||||||
for (final preset in FrequencyInterval.presets) {
|
_customUnit = _CustomUnit.days;
|
||||||
if (preset.intervalType == task.intervalType &&
|
_customIntervalController.text = '1';
|
||||||
preset.days == task.intervalDays) {
|
case IntervalType.everyNDays:
|
||||||
_selectedPreset = preset;
|
// Check if it's a clean week multiple
|
||||||
_isCustomFrequency = false;
|
if (task.intervalDays % 7 == 0) {
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isCustomFrequency) {
|
|
||||||
// Determine custom unit from stored interval
|
|
||||||
switch (task.intervalType) {
|
|
||||||
case IntervalType.everyNMonths:
|
|
||||||
_customUnit = _CustomUnit.months;
|
|
||||||
_customIntervalController.text = task.intervalDays.toString();
|
|
||||||
case IntervalType.monthly:
|
|
||||||
_customUnit = _CustomUnit.months;
|
|
||||||
_customIntervalController.text = '1';
|
|
||||||
case IntervalType.weekly:
|
|
||||||
_customUnit = _CustomUnit.weeks;
|
_customUnit = _CustomUnit.weeks;
|
||||||
_customIntervalController.text = '1';
|
_customIntervalController.text = (task.intervalDays ~/ 7).toString();
|
||||||
case IntervalType.biweekly:
|
} else {
|
||||||
_customUnit = _CustomUnit.weeks;
|
|
||||||
_customIntervalController.text = '2';
|
|
||||||
default:
|
|
||||||
_customUnit = _CustomUnit.days;
|
_customUnit = _CustomUnit.days;
|
||||||
_customIntervalController.text = task.intervalDays.toString();
|
_customIntervalController.text = task.intervalDays.toString();
|
||||||
}
|
}
|
||||||
|
case IntervalType.weekly:
|
||||||
|
_customUnit = _CustomUnit.weeks;
|
||||||
|
_customIntervalController.text = '1';
|
||||||
|
case IntervalType.biweekly:
|
||||||
|
_customUnit = _CustomUnit.weeks;
|
||||||
|
_customIntervalController.text = '2';
|
||||||
|
case IntervalType.monthly:
|
||||||
|
_customUnit = _CustomUnit.months;
|
||||||
|
_customIntervalController.text = '1';
|
||||||
|
case IntervalType.everyNMonths:
|
||||||
|
_customUnit = _CustomUnit.months;
|
||||||
|
_customIntervalController.text = task.intervalDays.toString();
|
||||||
|
case IntervalType.quarterly:
|
||||||
|
_customUnit = _CustomUnit.months;
|
||||||
|
_customIntervalController.text = '3';
|
||||||
|
case IntervalType.yearly:
|
||||||
|
_customUnit = _CustomUnit.months;
|
||||||
|
_customIntervalController.text = '12';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect matching shortcut chip
|
||||||
|
_activeShortcut = _ShortcutFrequency.fromPickerValues(
|
||||||
|
int.tryParse(_customIntervalController.text) ?? 1,
|
||||||
|
_customUnit,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +209,21 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
taskId: widget.taskId!,
|
taskId: widget.taskId!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// DELETE BUTTON
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: theme.colorScheme.error,
|
||||||
|
foregroundColor: theme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: _isLoading ? null : _onDelete,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: Text(l10n.taskDeleteConfirmAction),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -209,59 +232,52 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFrequencySelector(AppLocalizations l10n, ThemeData theme) {
|
Widget _buildFrequencySelector(AppLocalizations l10n, ThemeData theme) {
|
||||||
final items = <Widget>[];
|
|
||||||
|
|
||||||
// Preset intervals
|
|
||||||
for (final preset in FrequencyInterval.presets) {
|
|
||||||
items.add(
|
|
||||||
ChoiceChip(
|
|
||||||
label: Text(preset.label()),
|
|
||||||
selected: !_isCustomFrequency && _selectedPreset == preset,
|
|
||||||
onSelected: (selected) {
|
|
||||||
if (selected) {
|
|
||||||
setState(() {
|
|
||||||
_selectedPreset = preset;
|
|
||||||
_isCustomFrequency = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom option
|
|
||||||
items.add(
|
|
||||||
ChoiceChip(
|
|
||||||
label: Text(l10n.taskFormFrequencyCustom),
|
|
||||||
selected: _isCustomFrequency,
|
|
||||||
onSelected: (selected) {
|
|
||||||
if (selected) {
|
|
||||||
setState(() {
|
|
||||||
_isCustomFrequency = true;
|
|
||||||
_selectedPreset = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Shortcut chips row (always visible)
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
children: items,
|
children: [
|
||||||
|
for (final shortcut in _ShortcutFrequency.values)
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(_shortcutLabel(shortcut, l10n)),
|
||||||
|
selected: _activeShortcut == shortcut,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
final values = shortcut.toPickerValues();
|
||||||
|
setState(() {
|
||||||
|
_activeShortcut = shortcut;
|
||||||
|
_customIntervalController.text = values.number.toString();
|
||||||
|
_customUnit = values.unit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (_isCustomFrequency) ...[
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
// Freeform picker row (ALWAYS visible — not conditional)
|
||||||
_buildCustomFrequencyInput(l10n, theme),
|
_buildFrequencyPickerRow(l10n, theme),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCustomFrequencyInput(AppLocalizations l10n, ThemeData theme) {
|
String _shortcutLabel(_ShortcutFrequency shortcut, AppLocalizations l10n) {
|
||||||
|
switch (shortcut) {
|
||||||
|
case _ShortcutFrequency.daily:
|
||||||
|
return l10n.frequencyShortcutDaily;
|
||||||
|
case _ShortcutFrequency.weekly:
|
||||||
|
return l10n.frequencyShortcutWeekly;
|
||||||
|
case _ShortcutFrequency.biweekly:
|
||||||
|
return l10n.frequencyShortcutBiweekly;
|
||||||
|
case _ShortcutFrequency.monthly:
|
||||||
|
return l10n.frequencyShortcutMonthly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFrequencyPickerRow(AppLocalizations l10n, ThemeData theme) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@@ -279,6 +295,14 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
),
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_activeShortcut = _ShortcutFrequency.fromPickerValues(
|
||||||
|
int.tryParse(value) ?? 1,
|
||||||
|
_customUnit,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -300,8 +324,13 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
],
|
],
|
||||||
selected: {_customUnit},
|
selected: {_customUnit},
|
||||||
onSelectionChanged: (newSelection) {
|
onSelectionChanged: (newSelection) {
|
||||||
|
final newUnit = newSelection.first;
|
||||||
setState(() {
|
setState(() {
|
||||||
_customUnit = newSelection.first;
|
_customUnit = newUnit;
|
||||||
|
_activeShortcut = _ShortcutFrequency.fromPickerValues(
|
||||||
|
int.tryParse(_customIntervalController.text) ?? 1,
|
||||||
|
newUnit,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -359,53 +388,32 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the frequency from either selected preset or custom input.
|
/// Resolve the frequency from the freeform picker (single source of truth).
|
||||||
|
/// The picker is always the source of truth; shortcut chips just populate it.
|
||||||
({IntervalType type, int days, int? anchorDay}) _resolveFrequency() {
|
({IntervalType type, int days, int? anchorDay}) _resolveFrequency() {
|
||||||
if (!_isCustomFrequency && _selectedPreset != null) {
|
|
||||||
final preset = _selectedPreset!;
|
|
||||||
// For calendar-anchored intervals, set anchorDay to due date's day
|
|
||||||
int? anchorDay;
|
|
||||||
if (_isCalendarAnchored(preset.intervalType)) {
|
|
||||||
anchorDay = _dueDate.day;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
type: preset.intervalType,
|
|
||||||
days: preset.days,
|
|
||||||
anchorDay: anchorDay,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom frequency
|
|
||||||
final number = int.tryParse(_customIntervalController.text) ?? 1;
|
final number = int.tryParse(_customIntervalController.text) ?? 1;
|
||||||
switch (_customUnit) {
|
switch (_customUnit) {
|
||||||
case _CustomUnit.days:
|
case _CustomUnit.days:
|
||||||
return (
|
if (number == 1) {
|
||||||
type: IntervalType.everyNDays,
|
return (type: IntervalType.daily, days: 1, anchorDay: null);
|
||||||
days: number,
|
}
|
||||||
anchorDay: null,
|
return (type: IntervalType.everyNDays, days: number, anchorDay: null);
|
||||||
);
|
|
||||||
case _CustomUnit.weeks:
|
case _CustomUnit.weeks:
|
||||||
return (
|
if (number == 1) {
|
||||||
type: IntervalType.everyNDays,
|
return (type: IntervalType.weekly, days: 1, anchorDay: null);
|
||||||
days: number * 7,
|
}
|
||||||
anchorDay: null,
|
if (number == 2) {
|
||||||
);
|
return (type: IntervalType.biweekly, days: 14, anchorDay: null);
|
||||||
|
}
|
||||||
|
return (type: IntervalType.everyNDays, days: number * 7, anchorDay: null);
|
||||||
case _CustomUnit.months:
|
case _CustomUnit.months:
|
||||||
return (
|
if (number == 1) {
|
||||||
type: IntervalType.everyNMonths,
|
return (type: IntervalType.monthly, days: 1, anchorDay: _dueDate.day);
|
||||||
days: number,
|
}
|
||||||
anchorDay: _dueDate.day,
|
return (type: IntervalType.everyNMonths, days: number, anchorDay: _dueDate.day);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isCalendarAnchored(IntervalType type) {
|
|
||||||
return type == IntervalType.monthly ||
|
|
||||||
type == IntervalType.everyNMonths ||
|
|
||||||
type == IntervalType.quarterly ||
|
|
||||||
type == IntervalType.yearly;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onSave() async {
|
Future<void> _onSave() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
@@ -452,7 +460,76 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onDelete() async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(l10n.taskDeleteConfirmTitle),
|
||||||
|
content: Text(l10n.taskDeleteConfirmMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(l10n.taskDeleteConfirmAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
await ref.read(taskActionsProvider.notifier).smartDeleteTask(widget.taskId!);
|
||||||
|
if (mounted) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unit options for custom frequency input.
|
/// Shortcut frequency options for quick selection chips.
|
||||||
|
enum _ShortcutFrequency {
|
||||||
|
daily,
|
||||||
|
weekly,
|
||||||
|
biweekly,
|
||||||
|
monthly;
|
||||||
|
|
||||||
|
/// Returns the picker values (number + unit) that this shortcut represents.
|
||||||
|
({int number, _CustomUnit unit}) toPickerValues() {
|
||||||
|
switch (this) {
|
||||||
|
case _ShortcutFrequency.daily:
|
||||||
|
return (number: 1, unit: _CustomUnit.days);
|
||||||
|
case _ShortcutFrequency.weekly:
|
||||||
|
return (number: 1, unit: _CustomUnit.weeks);
|
||||||
|
case _ShortcutFrequency.biweekly:
|
||||||
|
return (number: 2, unit: _CustomUnit.weeks);
|
||||||
|
case _ShortcutFrequency.monthly:
|
||||||
|
return (number: 1, unit: _CustomUnit.months);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the matching shortcut for given picker values, or null if no match.
|
||||||
|
static _ShortcutFrequency? fromPickerValues(int number, _CustomUnit unit) {
|
||||||
|
if (number == 1 && unit == _CustomUnit.days) return _ShortcutFrequency.daily;
|
||||||
|
if (number == 1 && unit == _CustomUnit.weeks) return _ShortcutFrequency.weekly;
|
||||||
|
if (number == 2 && unit == _CustomUnit.weeks) return _ShortcutFrequency.biweekly;
|
||||||
|
if (number == 1 && unit == _CustomUnit.months) return _ShortcutFrequency.monthly;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unit options for freeform frequency picker.
|
||||||
enum _CustomUnit { days, weeks, months }
|
enum _CustomUnit { days, weeks, months }
|
||||||
|
|||||||
@@ -89,4 +89,15 @@ class TaskActions extends _$TaskActions {
|
|||||||
final db = ref.read(appDatabaseProvider);
|
final db = ref.read(appDatabaseProvider);
|
||||||
await db.tasksDao.completeTask(taskId);
|
await db.tasksDao.completeTask(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Smart delete: hard-deletes tasks with no completions, soft-deletes tasks with completions.
|
||||||
|
Future<void> smartDeleteTask(int taskId) async {
|
||||||
|
final db = ref.read(appDatabaseProvider);
|
||||||
|
final completionCount = await db.tasksDao.getCompletionCount(taskId);
|
||||||
|
if (completionCount == 0) {
|
||||||
|
await db.tasksDao.deleteTask(taskId);
|
||||||
|
} else {
|
||||||
|
await db.tasksDao.softDeleteTask(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ final class TaskActionsProvider
|
|||||||
TaskActions create() => TaskActions();
|
TaskActions create() => TaskActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$taskActionsHash() => r'62f1739263e3cfb379b83de10d712b17fd087f92';
|
String _$taskActionsHash() => r'4ef782496ca32338f12281bab258a63a59a293e5';
|
||||||
|
|
||||||
/// Notifier for task mutations: create, update, delete, complete.
|
/// Notifier for task mutations: create, update, delete, complete.
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
"taskFormFrequencyLabel": "Wiederholung",
|
"taskFormFrequencyLabel": "Wiederholung",
|
||||||
"taskFormFrequencyCustom": "Benutzerdefiniert",
|
"taskFormFrequencyCustom": "Benutzerdefiniert",
|
||||||
"taskFormFrequencyEvery": "Alle",
|
"taskFormFrequencyEvery": "Alle",
|
||||||
|
"frequencyShortcutDaily": "Täglich",
|
||||||
|
"frequencyShortcutWeekly": "Wöchentlich",
|
||||||
|
"frequencyShortcutBiweekly": "Alle 2 Wochen",
|
||||||
|
"frequencyShortcutMonthly": "Monatlich",
|
||||||
"taskFormFrequencyUnitDays": "Tage",
|
"taskFormFrequencyUnitDays": "Tage",
|
||||||
"taskFormFrequencyUnitWeeks": "Wochen",
|
"taskFormFrequencyUnitWeeks": "Wochen",
|
||||||
"taskFormFrequencyUnitMonths": "Monate",
|
"taskFormFrequencyUnitMonths": "Monate",
|
||||||
|
|||||||
@@ -322,6 +322,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'Alle'**
|
/// **'Alle'**
|
||||||
String get taskFormFrequencyEvery;
|
String get taskFormFrequencyEvery;
|
||||||
|
|
||||||
|
/// No description provided for @frequencyShortcutDaily.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Täglich'**
|
||||||
|
String get frequencyShortcutDaily;
|
||||||
|
|
||||||
|
/// No description provided for @frequencyShortcutWeekly.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Wöchentlich'**
|
||||||
|
String get frequencyShortcutWeekly;
|
||||||
|
|
||||||
|
/// No description provided for @frequencyShortcutBiweekly.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Alle 2 Wochen'**
|
||||||
|
String get frequencyShortcutBiweekly;
|
||||||
|
|
||||||
|
/// No description provided for @frequencyShortcutMonthly.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Monatlich'**
|
||||||
|
String get frequencyShortcutMonthly;
|
||||||
|
|
||||||
/// No description provided for @taskFormFrequencyUnitDays.
|
/// No description provided for @taskFormFrequencyUnitDays.
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
|
|||||||
@@ -128,6 +128,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get taskFormFrequencyEvery => 'Alle';
|
String get taskFormFrequencyEvery => 'Alle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get frequencyShortcutDaily => 'Täglich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get frequencyShortcutWeekly => 'Wöchentlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get frequencyShortcutBiweekly => 'Alle 2 Wochen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get frequencyShortcutMonthly => 'Monatlich';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get taskFormFrequencyUnitDays => 'Tage';
|
String get taskFormFrequencyUnitDays => 'Tage';
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ void main() {
|
|||||||
expect(db, isNotNull);
|
expect(db, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has schemaVersion 2', () {
|
test('has schemaVersion 3', () {
|
||||||
expect(db.schemaVersion, equals(2));
|
expect(db.schemaVersion, equals(3));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can be closed without error', () async {
|
test('can be closed without error', () async {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift/internal/migrations.dart';
|
import 'package:drift/internal/migrations.dart';
|
||||||
import 'schema_v1.dart' as v1;
|
import 'schema_v1.dart' as v1;
|
||||||
import 'schema_v2.dart' as v2;
|
import 'schema_v2.dart' as v2;
|
||||||
|
import 'schema_v3.dart' as v3;
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
@@ -15,10 +16,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
return v1.DatabaseAtV1(db);
|
return v1.DatabaseAtV1(db);
|
||||||
case 2:
|
case 2:
|
||||||
return v2.DatabaseAtV2(db);
|
return v2.DatabaseAtV2(db);
|
||||||
|
case 3:
|
||||||
|
return v3.DatabaseAtV3(db);
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static const versions = const [1, 2];
|
static const versions = const [1, 2, 3];
|
||||||
}
|
}
|
||||||
|
|||||||
283
test/drift/household_keeper/generated/schema_v3.dart
Normal file
283
test/drift/household_keeper/generated/schema_v3.dart
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
// dart format width=80
|
||||||
|
// GENERATED BY drift_dev, DO NOT MODIFY.
|
||||||
|
// ignore_for_file: type=lint,unused_import
|
||||||
|
//
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
class Rooms extends Table with TableInfo {
|
||||||
|
@override
|
||||||
|
final GeneratedDatabase attachedDatabase;
|
||||||
|
final String? _alias;
|
||||||
|
Rooms(this.attachedDatabase, [this._alias]);
|
||||||
|
late final GeneratedColumn<int> id = GeneratedColumn<int>(
|
||||||
|
'id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
hasAutoIncrement: true,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
$customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<String> name = GeneratedColumn<String>(
|
||||||
|
'name',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<String> iconName = GeneratedColumn<String>(
|
||||||
|
'icon_name',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> sortOrder = GeneratedColumn<int>(
|
||||||
|
'sort_order',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
$customConstraints: 'NOT NULL DEFAULT 0',
|
||||||
|
defaultValue: const CustomExpression('0'),
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
|
||||||
|
'created_at',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<GeneratedColumn> get $columns => [
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
iconName,
|
||||||
|
sortOrder,
|
||||||
|
createdAt,
|
||||||
|
];
|
||||||
|
@override
|
||||||
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
@override
|
||||||
|
String get actualTableName => $name;
|
||||||
|
static const String $name = 'rooms';
|
||||||
|
@override
|
||||||
|
Set<GeneratedColumn> get $primaryKey => {id};
|
||||||
|
@override
|
||||||
|
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||||
|
throw UnsupportedError('TableInfo.map in schema verification code');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Rooms createAlias(String alias) {
|
||||||
|
return Rooms(attachedDatabase, alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get dontWriteConstraints => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Tasks extends Table with TableInfo {
|
||||||
|
@override
|
||||||
|
final GeneratedDatabase attachedDatabase;
|
||||||
|
final String? _alias;
|
||||||
|
Tasks(this.attachedDatabase, [this._alias]);
|
||||||
|
late final GeneratedColumn<int> id = GeneratedColumn<int>(
|
||||||
|
'id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
hasAutoIncrement: true,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
$customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> roomId = GeneratedColumn<int>(
|
||||||
|
'room_id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL REFERENCES rooms(id)',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<String> name = GeneratedColumn<String>(
|
||||||
|
'name',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<String> description = GeneratedColumn<String>(
|
||||||
|
'description',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
$customConstraints: 'NULL',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> intervalType = GeneratedColumn<int>(
|
||||||
|
'interval_type',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> intervalDays = GeneratedColumn<int>(
|
||||||
|
'interval_days',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
$customConstraints: 'NOT NULL DEFAULT 1',
|
||||||
|
defaultValue: const CustomExpression('1'),
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> anchorDay = GeneratedColumn<int>(
|
||||||
|
'anchor_day',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
$customConstraints: 'NULL',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> effortLevel = GeneratedColumn<int>(
|
||||||
|
'effort_level',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> nextDueDate = GeneratedColumn<int>(
|
||||||
|
'next_due_date',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
|
||||||
|
'created_at',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> isActive = GeneratedColumn<int>(
|
||||||
|
'is_active',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
$customConstraints: 'NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1))',
|
||||||
|
defaultValue: const CustomExpression('1'),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<GeneratedColumn> get $columns => [
|
||||||
|
id,
|
||||||
|
roomId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
intervalType,
|
||||||
|
intervalDays,
|
||||||
|
anchorDay,
|
||||||
|
effortLevel,
|
||||||
|
nextDueDate,
|
||||||
|
createdAt,
|
||||||
|
isActive,
|
||||||
|
];
|
||||||
|
@override
|
||||||
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
@override
|
||||||
|
String get actualTableName => $name;
|
||||||
|
static const String $name = 'tasks';
|
||||||
|
@override
|
||||||
|
Set<GeneratedColumn> get $primaryKey => {id};
|
||||||
|
@override
|
||||||
|
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||||
|
throw UnsupportedError('TableInfo.map in schema verification code');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Tasks createAlias(String alias) {
|
||||||
|
return Tasks(attachedDatabase, alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get dontWriteConstraints => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaskCompletions extends Table with TableInfo {
|
||||||
|
@override
|
||||||
|
final GeneratedDatabase attachedDatabase;
|
||||||
|
final String? _alias;
|
||||||
|
TaskCompletions(this.attachedDatabase, [this._alias]);
|
||||||
|
late final GeneratedColumn<int> id = GeneratedColumn<int>(
|
||||||
|
'id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
hasAutoIncrement: true,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
$customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> taskId = GeneratedColumn<int>(
|
||||||
|
'task_id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL REFERENCES tasks(id)',
|
||||||
|
);
|
||||||
|
late final GeneratedColumn<int> completedAt = GeneratedColumn<int>(
|
||||||
|
'completed_at',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<GeneratedColumn> get $columns => [id, taskId, completedAt];
|
||||||
|
@override
|
||||||
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
@override
|
||||||
|
String get actualTableName => $name;
|
||||||
|
static const String $name = 'task_completions';
|
||||||
|
@override
|
||||||
|
Set<GeneratedColumn> get $primaryKey => {id};
|
||||||
|
@override
|
||||||
|
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||||
|
throw UnsupportedError('TableInfo.map in schema verification code');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TaskCompletions createAlias(String alias) {
|
||||||
|
return TaskCompletions(attachedDatabase, alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get dontWriteConstraints => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DatabaseAtV3 extends GeneratedDatabase {
|
||||||
|
DatabaseAtV3(QueryExecutor e) : super(e);
|
||||||
|
late final Rooms rooms = Rooms(this);
|
||||||
|
late final Tasks tasks = Tasks(this);
|
||||||
|
late final TaskCompletions taskCompletions = TaskCompletions(this);
|
||||||
|
@override
|
||||||
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
|
@override
|
||||||
|
List<DatabaseSchemaEntity> get allSchemaEntities => [
|
||||||
|
rooms,
|
||||||
|
tasks,
|
||||||
|
taskCompletions,
|
||||||
|
];
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 3;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import 'generated/schema.dart';
|
|||||||
|
|
||||||
import 'generated/schema_v1.dart' as v1;
|
import 'generated/schema_v1.dart' as v1;
|
||||||
import 'generated/schema_v2.dart' as v2;
|
import 'generated/schema_v2.dart' as v2;
|
||||||
|
import 'generated/schema_v3.dart' as v3;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
@@ -21,39 +22,28 @@ void main() {
|
|||||||
// These simple tests verify all possible schema updates with a simple (no
|
// These simple tests verify all possible schema updates with a simple (no
|
||||||
// data) migration. This is a quick way to ensure that written database
|
// data) migration. This is a quick way to ensure that written database
|
||||||
// migrations properly alter the schema.
|
// migrations properly alter the schema.
|
||||||
const versions = GeneratedHelper.versions;
|
//
|
||||||
for (final (i, fromVersion) in versions.indexed) {
|
// Note: since AppDatabase.schemaVersion == 3, all migration paths
|
||||||
group('from $fromVersion', () {
|
// end at v3. We only test migrations to the current schema version.
|
||||||
for (final toVersion in versions.skip(i + 1)) {
|
final fromVersions = [1, 2];
|
||||||
test('to $toVersion', () async {
|
for (final fromVersion in fromVersions) {
|
||||||
final schema = await verifier.schemaAt(fromVersion);
|
test('from $fromVersion to 3', () async {
|
||||||
final db = AppDatabase(schema.newConnection());
|
final schema = await verifier.schemaAt(fromVersion);
|
||||||
await verifier.migrateAndValidate(db, toVersion);
|
final db = AppDatabase(schema.newConnection());
|
||||||
await db.close();
|
await verifier.migrateAndValidate(db, 3);
|
||||||
});
|
await db.close();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// The following template shows how to write tests ensuring your migrations
|
test('migration from v2 to v3 does not corrupt data', () async {
|
||||||
// preserve existing data.
|
// The v2 -> v3 migration adds the isActive column (default true).
|
||||||
// Testing this can be useful for migrations that change existing columns
|
// Existing tasks should remain and be accessible with isActive = true.
|
||||||
// (e.g. by alterating their type or constraints). Migrations that only add
|
|
||||||
// tables or columns typically don't need these advanced tests. For more
|
|
||||||
// information, see https://drift.simonbinder.eu/migrations/tests/#verifying-data-integrity
|
|
||||||
// TODO: This generated template shows how these tests could be written. Adopt
|
|
||||||
// it to your own needs when testing migrations with data integrity.
|
|
||||||
test('migration from v1 to v2 does not corrupt data', () async {
|
|
||||||
// Add data to insert into the old database, and the expected rows after the
|
|
||||||
// migration.
|
|
||||||
// TODO: Fill these lists
|
|
||||||
|
|
||||||
await verifier.testWithDataIntegrity(
|
await verifier.testWithDataIntegrity(
|
||||||
oldVersion: 1,
|
oldVersion: 2,
|
||||||
newVersion: 2,
|
newVersion: 3,
|
||||||
createOld: v1.DatabaseAtV1.new,
|
createOld: v2.DatabaseAtV2.new,
|
||||||
createNew: v2.DatabaseAtV2.new,
|
createNew: v3.DatabaseAtV3.new,
|
||||||
openTestedDatabase: AppDatabase.new,
|
openTestedDatabase: AppDatabase.new,
|
||||||
createItems: (batch, oldDb) {},
|
createItems: (batch, oldDb) {},
|
||||||
validateItems: (newDb) async {},
|
validateItems: (newDb) async {},
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Task _makeTask({
|
|||||||
effortLevel: EffortLevel.medium,
|
effortLevel: EffortLevel.medium,
|
||||||
nextDueDate: nextDueDate,
|
nextDueDate: nextDueDate,
|
||||||
createdAt: DateTime(2026, 1, 1),
|
createdAt: DateTime(2026, 1, 1),
|
||||||
|
isActive: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,5 +204,137 @@ void main() {
|
|||||||
// Only the task due Mar 10 is overdue (before Mar 15)
|
// Only the task due Mar 10 is overdue (before Mar 15)
|
||||||
expect(overdueCount, 1);
|
expect(overdueCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('softDeleteTask sets isActive to false without removing the task', () async {
|
||||||
|
final id = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Abspuelen',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.tasksDao.softDeleteTask(id);
|
||||||
|
|
||||||
|
// Task should still exist in DB but isActive == false
|
||||||
|
final allTasks = await (db.select(db.tasks)).get();
|
||||||
|
expect(allTasks.length, 1);
|
||||||
|
expect(allTasks.first.isActive, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCompletionCount returns 0 for task with no completions', () async {
|
||||||
|
final id = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Abspuelen',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final count = await db.tasksDao.getCompletionCount(id);
|
||||||
|
expect(count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCompletionCount returns correct count after completions', () async {
|
||||||
|
final id = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Abspuelen',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 13),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 13));
|
||||||
|
await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 14));
|
||||||
|
await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 15));
|
||||||
|
|
||||||
|
final count = await db.tasksDao.getCompletionCount(id);
|
||||||
|
expect(count, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchTasksInRoom excludes soft-deleted (isActive=false) tasks', () async {
|
||||||
|
final activeId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Active Task',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final inactiveId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Inactive Task',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.tasksDao.softDeleteTask(inactiveId);
|
||||||
|
|
||||||
|
final tasks = await db.tasksDao.watchTasksInRoom(roomId).first;
|
||||||
|
expect(tasks.length, 1);
|
||||||
|
expect(tasks.first.id, activeId);
|
||||||
|
expect(tasks.first.name, 'Active Task');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOverdueTaskCount excludes soft-deleted (isActive=false) tasks', () async {
|
||||||
|
// Active overdue task
|
||||||
|
await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Active Overdue',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Inactive overdue task
|
||||||
|
final inactiveId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Inactive Overdue',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.tasksDao.softDeleteTask(inactiveId);
|
||||||
|
|
||||||
|
final overdueCount = await db.tasksDao.getOverdueTaskCount(
|
||||||
|
roomId,
|
||||||
|
today: DateTime(2026, 3, 15),
|
||||||
|
);
|
||||||
|
expect(overdueCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hard deleteTask still removes task and its completions', () async {
|
||||||
|
final id = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Abspuelen',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 13),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 13));
|
||||||
|
|
||||||
|
await db.tasksDao.deleteTask(id);
|
||||||
|
|
||||||
|
final tasks = await (db.select(db.tasks)).get();
|
||||||
|
final completions = await (db.select(db.taskCompletions)).get();
|
||||||
|
expect(tasks, isEmpty);
|
||||||
|
expect(completions, isEmpty);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Task _makeTask({
|
|||||||
effortLevel: EffortLevel.medium,
|
effortLevel: EffortLevel.medium,
|
||||||
nextDueDate: nextDueDate,
|
nextDueDate: nextDueDate,
|
||||||
createdAt: DateTime(2026, 1, 1),
|
createdAt: DateTime(2026, 1, 1),
|
||||||
|
isActive: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user