Compare commits
6 Commits
97eaa6dacc
...
2a4b14cb43
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a4b14cb43 | |||
| 7a2c1b81de | |||
| 7344933278 | |||
| 9f902ff2c7 | |||
| ceae7d7d61 | |||
| 2687f5e31e |
@@ -132,9 +132,27 @@ jobs:
|
|||||||
-mkdir dev/fdroid/repo
|
-mkdir dev/fdroid/repo
|
||||||
SFTP
|
SFTP
|
||||||
|
|
||||||
# Try to download the existing repo/ folder from Hetzner to keep older versions and the keystore.
|
# Try to download the entire fdroid/ directory from Hetzner to keep
|
||||||
|
# older APKs, the repo keystore, and config.yml across runs.
|
||||||
# If it fails (first time), initialize a new local repo.
|
# If it fails (first time), initialize a new local repo.
|
||||||
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/repo" fdroid/ || (cd fdroid && fdroid init)
|
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no -r "$USER@$HOST:dev/fdroid/." fdroid/ || (cd fdroid && fdroid init)
|
||||||
|
|
||||||
|
- name: Ensure F-Droid repo signing key and icon
|
||||||
|
run: |
|
||||||
|
cd fdroid
|
||||||
|
|
||||||
|
# Ensure repo icon exists (use app launcher icon)
|
||||||
|
mkdir -p repo/icons
|
||||||
|
if [ ! -f repo/icons/icon.png ]; then
|
||||||
|
cp ../android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png repo/icons/icon.png
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If keystore doesn't exist, create the signing key.
|
||||||
|
# This only runs on the very first deployment; subsequent runs
|
||||||
|
# download the keystore from Hetzner via the scp step above.
|
||||||
|
if [ ! -f keystore.p12 ]; then
|
||||||
|
fdroid update --create-key
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Copy new APK to repo
|
- name: Copy new APK to repo
|
||||||
run: |
|
run: |
|
||||||
@@ -162,7 +180,6 @@ jobs:
|
|||||||
PASS: ${{ secrets.HETZNER_PASS }}
|
PASS: ${{ secrets.HETZNER_PASS }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
REMOTE_REPO_DIR="dev/fdroid/repo"
|
|
||||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
||||||
|
|
||||||
# Create remote directory tree via SFTP batch (no exec channel needed).
|
# Create remote directory tree via SFTP batch (no exec channel needed).
|
||||||
@@ -173,5 +190,6 @@ jobs:
|
|||||||
-mkdir dev/fdroid/repo
|
-mkdir dev/fdroid/repo
|
||||||
SFTP
|
SFTP
|
||||||
|
|
||||||
# Upload all files from fdroid/repo into the remote directory.
|
# Upload the entire fdroid/ directory (repo + keystore + config)
|
||||||
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo/. "$USER@$HOST:$REMOTE_REPO_DIR/"
|
# so the signing key persists across runs.
|
||||||
|
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/"
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ Requirements for milestone v1.1 Calendar & Polish. Each maps to roadmap phases.
|
|||||||
|
|
||||||
### Task History
|
### Task History
|
||||||
|
|
||||||
- [ ] **HIST-01**: Each task completion is recorded with a timestamp
|
- [x] **HIST-01**: Each task completion is recorded with a timestamp
|
||||||
- [ ] **HIST-02**: User can view past completion dates for any individual task
|
- [x] **HIST-02**: User can view past completion dates for any individual task
|
||||||
|
|
||||||
### Task Sorting
|
### Task Sorting
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
| CAL-03 | Phase 5 | Complete |
|
| CAL-03 | Phase 5 | Complete |
|
||||||
| CAL-04 | Phase 5 | Complete |
|
| CAL-04 | Phase 5 | Complete |
|
||||||
| CAL-05 | Phase 5 | Complete |
|
| CAL-05 | Phase 5 | Complete |
|
||||||
| HIST-01 | Phase 6 | Pending |
|
| HIST-01 | Phase 6 | Complete |
|
||||||
| HIST-02 | Phase 6 | Pending |
|
| HIST-02 | Phase 6 | Complete |
|
||||||
| SORT-01 | Phase 7 | Pending |
|
| SORT-01 | Phase 7 | Pending |
|
||||||
| SORT-02 | Phase 7 | Pending |
|
| SORT-02 | Phase 7 | Pending |
|
||||||
| SORT-03 | Phase 7 | Pending |
|
| SORT-03 | Phase 7 | Pending |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ See `milestones/v1.0-ROADMAP.md` for full phase details.
|
|||||||
**v1.1 Calendar & Polish (Phases 5-7):**
|
**v1.1 Calendar & Polish (Phases 5-7):**
|
||||||
|
|
||||||
- [x] **Phase 5: Calendar Strip** - Replace the stacked daily plan home screen with a horizontal scrollable date-strip and day-task list (completed 2026-03-16)
|
- [x] **Phase 5: Calendar Strip** - Replace the stacked daily plan home screen with a horizontal scrollable date-strip and day-task list (completed 2026-03-16)
|
||||||
- [ ] **Phase 6: Task History** - Record every task completion with a timestamp and expose a per-task history view
|
- [x] **Phase 6: Task History** - Record every task completion with a timestamp and expose a per-task history view (completed 2026-03-16)
|
||||||
- [ ] **Phase 7: Task Sorting** - Add alphabetical, interval, and effort sort options to task lists
|
- [ ] **Phase 7: Task Sorting** - Add alphabetical, interval, and effort sort options to task lists
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
@@ -50,7 +50,7 @@ Plans:
|
|||||||
1. Every task completion (tap done in any view) is recorded in the database with a precise timestamp — data persists across app restarts
|
1. Every task completion (tap done in any view) is recorded in the database with a precise timestamp — data persists across app restarts
|
||||||
2. From a task's detail or context menu the user can open a history view listing all past completion dates for that task in reverse-chronological order
|
2. From a task's detail or context menu the user can open a history view listing all past completion dates for that task in reverse-chronological order
|
||||||
3. The history view shows a meaningful empty state if the task has never been completed
|
3. The history view shows a meaningful empty state if the task has never been completed
|
||||||
**Plans:** 1 plan
|
**Plans:** 1/1 plans complete
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 06-01-PLAN.md — DAO query + history bottom sheet + TaskFormScreen integration + CalendarTaskRow navigation
|
- [ ] 06-01-PLAN.md — DAO query + history bottom sheet + TaskFormScreen integration + CalendarTaskRow navigation
|
||||||
|
|
||||||
@@ -74,5 +74,5 @@ Plans:
|
|||||||
| 3. Daily Plan and Cleanliness | v1.0 | 3/3 | Complete | 2026-03-16 |
|
| 3. Daily Plan and Cleanliness | v1.0 | 3/3 | Complete | 2026-03-16 |
|
||||||
| 4. Notifications | v1.0 | 3/3 | Complete | 2026-03-16 |
|
| 4. Notifications | v1.0 | 3/3 | Complete | 2026-03-16 |
|
||||||
| 5. Calendar Strip | 2/2 | Complete | 2026-03-16 | - |
|
| 5. Calendar Strip | 2/2 | Complete | 2026-03-16 | - |
|
||||||
| 6. Task History | v1.1 | 0/1 | Planned | - |
|
| 6. Task History | 1/1 | Complete | 2026-03-16 | - |
|
||||||
| 7. Task Sorting | v1.1 | 0/? | Not started | - |
|
| 7. Task Sorting | v1.1 | 0/? | Not started | - |
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: milestone
|
milestone_name: milestone
|
||||||
status: completed
|
status: completed
|
||||||
stopped_at: Phase 6 context gathered
|
stopped_at: Completed Phase 6 Plan 01 (task history)
|
||||||
last_updated: "2026-03-16T20:46:23.402Z"
|
last_updated: "2026-03-16T21:01:58.162Z"
|
||||||
last_activity: 2026-03-16 — Completed Phase 5 Plan 02 (calendar strip UI)
|
last_activity: 2026-03-16 — Completed Phase 6 Plan 01 (task completion history)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 3
|
total_phases: 3
|
||||||
completed_phases: 1
|
completed_phases: 2
|
||||||
total_plans: 2
|
total_plans: 3
|
||||||
completed_plans: 2
|
completed_plans: 3
|
||||||
percent: 100
|
percent: 100
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,17 +21,17 @@ progress:
|
|||||||
See: .planning/PROJECT.md (updated 2026-03-16)
|
See: .planning/PROJECT.md (updated 2026-03-16)
|
||||||
|
|
||||||
**Core value:** Users can see what needs doing today, mark it done, and trust the app to schedule the next occurrence — without thinking about it.
|
**Core value:** Users can see what needs doing today, mark it done, and trust the app to schedule the next occurrence — without thinking about it.
|
||||||
**Current focus:** v1.1 Calendar & Polish — Phase 5: Calendar Strip
|
**Current focus:** v1.1 Calendar & Polish — Phase 6: Task History
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 5 — Calendar Strip
|
Phase: 6 — Task History
|
||||||
Plan: 2/2 complete (Phase 5 done)
|
Plan: 1/1 complete (Phase 6 done)
|
||||||
Status: Phase Complete
|
Status: Phase Complete
|
||||||
Last activity: 2026-03-16 — Completed Phase 5 Plan 02 (calendar strip UI)
|
Last activity: 2026-03-16 — Completed Phase 6 Plan 01 (task completion history)
|
||||||
|
|
||||||
```
|
```
|
||||||
Progress: [██████████] 100% (2/2 plans in Phase 5)
|
Progress: [██████████] 100% (1/1 plans in Phase 6)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
@@ -44,6 +44,7 @@ Progress: [██████████] 100% (2/2 plans in Phase 5)
|
|||||||
| Tests | 89 | TBD |
|
| Tests | 89 | TBD |
|
||||||
| Phase 05-calendar-strip P01 | 5 | 2 tasks | 10 files |
|
| Phase 05-calendar-strip P01 | 5 | 2 tasks | 10 files |
|
||||||
| Phase 05-calendar-strip P02 | 8 | 3 tasks | 9 files |
|
| Phase 05-calendar-strip P02 | 8 | 3 tasks | 9 files |
|
||||||
|
| Phase 06-task-history P01 | 5 | 2 tasks | 9 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -60,6 +61,8 @@ Progress: [██████████] 100% (2/2 plans in Phase 5)
|
|||||||
| watchTasksForDate sorts alphabetically by task name | Same-day tasks have no meaningful time-based order; alpha sort is deterministic and user-friendly |
|
| watchTasksForDate sorts alphabetically by task name | Same-day tasks have no meaningful time-based order; alpha sort is deterministic and user-friendly |
|
||||||
| CalendarStripController as VoidCallback holder | Avoids GlobalKey for single imperative scroll-to-today action — simpler |
|
| CalendarStripController as VoidCallback holder | Avoids GlobalKey for single imperative scroll-to-today action — simpler |
|
||||||
| Tests use pump()+pump(Duration) instead of pumpAndSettle() | CalendarStrip animation controllers cause pumpAndSettle timeout — fixed-duration pump steps are reliable |
|
| Tests use pump()+pump(Duration) instead of pumpAndSettle() | CalendarStrip animation controllers cause pumpAndSettle timeout — fixed-duration pump steps are reliable |
|
||||||
|
| No separate Riverpod provider for history sheet | ref.read(appDatabaseProvider) directly in ConsumerWidget — one-shot modals do not need a dedicated provider |
|
||||||
|
| CalendarTaskRow onTap navigates to task edit form | Makes history accessible in one tap from home screen, consistent with GoRouter route patterns |
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ None.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-16T20:46:23.400Z
|
Last session: 2026-03-16T20:57:30Z
|
||||||
Stopped at: Phase 6 context gathered
|
Stopped at: Completed Phase 6 Plan 01 (task history)
|
||||||
Resume file: .planning/phases/06-task-history/06-CONTEXT.md
|
Resume file: .planning/phases/06-task-history/06-01-SUMMARY.md
|
||||||
Next action: `/gsd:plan-phase 5`
|
Next action: Phase 7 (task sorting) or release
|
||||||
|
|||||||
131
.planning/phases/06-task-history/06-01-SUMMARY.md
Normal file
131
.planning/phases/06-task-history/06-01-SUMMARY.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
phase: 06-task-history
|
||||||
|
plan: 01
|
||||||
|
subsystem: database, ui
|
||||||
|
tags: [drift, flutter, riverpod, go_router, intl, bottom-sheet, stream]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 05-calendar-strip
|
||||||
|
provides: CalendarTaskRow widget and CalendarDayList that render tasks in the home screen
|
||||||
|
provides:
|
||||||
|
- watchCompletionsForTask(taskId) DAO stream on TasksDao — sorted newest-first
|
||||||
|
- task_history_sheet.dart with showTaskHistorySheet() function
|
||||||
|
- Verlauf ListTile in TaskFormScreen (edit mode) opening history bottom sheet
|
||||||
|
- CalendarTaskRow onTap navigation to TaskFormScreen for the tapped task
|
||||||
|
affects: [07-task-sorting, future-phases-using-TaskFormScreen]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Bottom sheet follows icon_picker_sheet pattern: showModalBottomSheet with isScrollControlled, ConsumerWidget inside, SafeArea > Padding > Column(mainAxisSize.min)"
|
||||||
|
- "StreamBuilder on DAO stream directly accessed via ref.read(appDatabaseProvider).tasksDao.methodName (no separate Riverpod provider for one-shot modals)"
|
||||||
|
- "TDD: RED test commit followed by GREEN implementation commit"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- lib/features/tasks/presentation/task_history_sheet.dart
|
||||||
|
- test/features/tasks/data/task_history_dao_test.dart
|
||||||
|
modified:
|
||||||
|
- lib/features/tasks/data/tasks_dao.dart
|
||||||
|
- lib/features/tasks/data/tasks_dao.g.dart
|
||||||
|
- lib/features/tasks/presentation/task_form_screen.dart
|
||||||
|
- lib/features/home/presentation/calendar_task_row.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "No separate Riverpod provider for history sheet — ref.read(appDatabaseProvider) directly in ConsumerWidget keeps it simple for a one-shot modal"
|
||||||
|
- "CalendarTaskRow onTap routes to /rooms/:roomId/tasks/:taskId so history is always one tap away from the home screen"
|
||||||
|
- "Count summary line shown above list when completions > 0; not shown for empty state"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "History sheet: showModalBottomSheet returning Future<void>, ConsumerWidget sheet with StreamBuilder on DAO stream"
|
||||||
|
- "Edit-mode-only ListTile pattern: if (widget.isEditing) [...] in TaskFormScreen ListView children"
|
||||||
|
|
||||||
|
requirements-completed: [HIST-01, HIST-02]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 5min
|
||||||
|
completed: 2026-03-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6 Plan 1: Task History Summary
|
||||||
|
|
||||||
|
**Drift DAO stream for task completion history, bottom sheet with reverse-chronological German-formatted dates, wired from CalendarTaskRow tap through TaskFormScreen Verlauf button**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 5 min
|
||||||
|
- **Started:** 2026-03-16T20:52:49Z
|
||||||
|
- **Completed:** 2026-03-16T20:57:19Z
|
||||||
|
- **Tasks:** 2 (Task 1 TDD: RED + GREEN + localization; Task 2: sheet + wiring + navigation)
|
||||||
|
- **Files modified:** 9
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- `watchCompletionsForTask(int taskId)` added to TasksDao: returns `Stream<List<TaskCompletion>>` sorted by completedAt DESC
|
||||||
|
- Task history bottom sheet (`task_history_sheet.dart`) with StreamBuilder, empty state, German date/time formatting via intl
|
||||||
|
- Verlauf ListTile added to TaskFormScreen edit mode, opens history sheet on tap
|
||||||
|
- CalendarTaskRow gains `onTap` that navigates via GoRouter to the task edit form, making history one tap away from the calendar
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **RED - Failing DAO tests** - `2687f5e` (test)
|
||||||
|
2. **Task 1: DAO method, localization** - `ceae7d7` (feat)
|
||||||
|
3. **Task 2: History sheet, form wiring, navigation** - `9f902ff` (feat)
|
||||||
|
|
||||||
|
**Plan metadata:** (docs commit — see below)
|
||||||
|
|
||||||
|
_Note: TDD tasks have separate RED (test) and GREEN (feat) commits_
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `lib/features/tasks/data/tasks_dao.dart` - Added watchCompletionsForTask stream method
|
||||||
|
- `lib/features/tasks/data/tasks_dao.g.dart` - Regenerated by build_runner
|
||||||
|
- `lib/features/tasks/presentation/task_history_sheet.dart` - New: bottom sheet with StreamBuilder, empty state, completion list
|
||||||
|
- `lib/features/tasks/presentation/task_form_screen.dart` - Added Verlauf ListTile in edit mode
|
||||||
|
- `lib/features/home/presentation/calendar_task_row.dart` - Added onTap navigation to task edit form
|
||||||
|
- `lib/l10n/app_de.arb` - Added taskHistoryTitle, taskHistoryEmpty, taskHistoryCount strings
|
||||||
|
- `lib/l10n/app_localizations.dart` - Regenerated (abstract class updated)
|
||||||
|
- `lib/l10n/app_localizations_de.dart` - Regenerated (German implementation updated)
|
||||||
|
- `test/features/tasks/data/task_history_dao_test.dart` - New: 5 tests covering empty state, single/multiple completions, task isolation, stream reactivity
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- No separate Riverpod provider for history sheet: `ref.read(appDatabaseProvider).tasksDao.watchCompletionsForTask(taskId)` directly in the ConsumerWidget. One-shot modals do not need a dedicated provider.
|
||||||
|
- CalendarTaskRow navigation uses `context.go('/rooms/.../tasks/...')` consistent with existing GoRouter route patterns.
|
||||||
|
- Removed unused `import 'package:drift/drift.dart'` from test file (Rule 1 auto-fix during GREEN verification).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Removed unused import from test file**
|
||||||
|
- **Found during:** Task 1 (GREEN phase, flutter analyze)
|
||||||
|
- **Issue:** `import 'package:drift/drift.dart'` was copied from the existing tasks_dao_test.dart pattern but not needed in the new history test file (no `Value()` usage)
|
||||||
|
- **Fix:** Removed the unused import line
|
||||||
|
- **Files modified:** test/features/tasks/data/task_history_dao_test.dart
|
||||||
|
- **Verification:** flutter analyze reports zero issues
|
||||||
|
- **Committed in:** ceae7d7 (Task 1 feat commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (1 bug — unused import)
|
||||||
|
**Impact on plan:** Trivial cleanup. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
None — plan executed smoothly. All 106 tests pass (101 pre-existing + 5 new DAO tests), zero analyze issues.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- Phase 6 Plan 1 complete. Task history is fully functional.
|
||||||
|
- Phase 7 (task sorting) can proceed independently.
|
||||||
|
- No blockers.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 06-task-history*
|
||||||
|
*Completed: 2026-03-16*
|
||||||
114
.planning/phases/06-task-history/6-VERIFICATION.md
Normal file
114
.planning/phases/06-task-history/6-VERIFICATION.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
phase: 06-task-history
|
||||||
|
verified: 2026-03-16T22:15:00Z
|
||||||
|
status: passed
|
||||||
|
score: 3/3 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6: Task History Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Users can see exactly when each task was completed in the past, building trust that the scheduling loop is working correctly
|
||||||
|
**Verified:** 2026-03-16T22:15:00Z
|
||||||
|
**Status:** PASSED
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Every task completion is recorded with a timestamp and persists across app restarts | VERIFIED | `watchCompletionsForTask` reads from `TaskCompletions` table (persistent SQLite); `completeTask` already wrote timestamps; 5 DAO tests confirm stream returns correct data including stream reactivity test |
|
||||||
|
| 2 | User can open a history view from the task edit form showing all past completion dates in reverse-chronological order | VERIFIED | `task_form_screen.dart` lines 192-204: `if (widget.isEditing)` guard shows `ListTile` with `onTap: () => showTaskHistorySheet(...)`. Sheet uses `StreamBuilder` on `watchCompletionsForTask` with `..orderBy([(c) => OrderingTerm.desc(c.completedAt)])`, renders dates as `dd.MM.yyyy` + `HH:mm` via intl |
|
||||||
|
| 3 | History view shows a meaningful empty state if the task has never been completed | VERIFIED | `task_history_sheet.dart` lines 70-87: `if (completions.isEmpty)` branch renders `Icon(Icons.history, size: 48)` + `Text(l10n.taskHistoryEmpty)` ("Noch nie erledigt") |
|
||||||
|
|
||||||
|
**Score:** 3/3 truths verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Provides | Status | Details |
|
||||||
|
|----------|---------|--------|---------|
|
||||||
|
| `lib/features/tasks/data/tasks_dao.dart` | `watchCompletionsForTask(int taskId)` stream method | VERIFIED | Method exists at line 85, returns `Stream<List<TaskCompletion>>`, ordered by `completedAt DESC`, 110 lines total |
|
||||||
|
| `lib/features/tasks/presentation/task_history_sheet.dart` | Bottom sheet displaying task completion history | VERIFIED | 137 lines, exports top-level `showTaskHistorySheet()`, `_TaskHistorySheet` is a `ConsumerWidget` with full StreamBuilder, empty state, date list |
|
||||||
|
| `lib/features/tasks/presentation/task_form_screen.dart` | Verlauf button in edit mode opening history sheet | VERIFIED | Imports `task_history_sheet.dart` (line 13), `showTaskHistorySheet` called at line 199, guarded by `if (widget.isEditing)` |
|
||||||
|
| `lib/features/home/presentation/calendar_task_row.dart` | onTap navigation to task edit form | VERIFIED | `ListTile.onTap` at line 39 calls `context.go('/rooms/${taskWithRoom.roomId}/tasks/${taskWithRoom.task.id}')` |
|
||||||
|
| `test/features/tasks/data/task_history_dao_test.dart` | Tests for completion history DAO query | VERIFIED | 158 lines, 5 tests: empty state, single completion, multiple reverse-chronological, task isolation, stream reactivity — all pass |
|
||||||
|
| `lib/features/tasks/data/tasks_dao.g.dart` | Drift-generated mixin (build_runner output) | VERIFIED | Exists, 25 lines, regenerated with `taskCompletions` table accessor present |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `task_form_screen.dart` | `task_history_sheet.dart` | `showTaskHistorySheet` call in Verlauf `onTap` | WIRED | Import at line 13; called at line 199 inside `if (widget.isEditing)` block |
|
||||||
|
| `task_history_sheet.dart` | `tasks_dao.dart` | `watchCompletionsForTask` stream consumption | WIRED | `ref.read(appDatabaseProvider).tasksDao.watchCompletionsForTask(taskId)` at lines 59-62; stream result consumed by `StreamBuilder` builder |
|
||||||
|
| `calendar_task_row.dart` | `TaskFormScreen` | GoRouter navigation on row tap | WIRED | `context.go('/rooms/${taskWithRoom.roomId}/tasks/${taskWithRoom.task.id}')` at line 39-41; route `/rooms/:roomId/tasks/:taskId` resolves to `TaskFormScreen` per router.dart |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|------------|-------------|--------|----------|
|
||||||
|
| HIST-01 | 06-01-PLAN.md | Each task completion is recorded with a timestamp | SATISFIED | `TasksDao.completeTask()` inserts into `TaskCompletions` (pre-existing); `watchCompletionsForTask` surfaces data; 5 DAO tests confirm timestamps are stored and retrieved correctly |
|
||||||
|
| HIST-02 | 06-01-PLAN.md | User can view past completion dates for any individual task | SATISFIED | Full UI chain: `CalendarTaskRow.onTap` -> `TaskFormScreen` (edit mode) -> "Verlauf" `ListTile` -> `showTaskHistorySheet` -> `_TaskHistorySheet` StreamBuilder showing reverse-chronological German-formatted dates |
|
||||||
|
|
||||||
|
No orphaned requirements — REQUIREMENTS.md Traceability table shows only HIST-01 and HIST-02 mapped to Phase 6, both accounted for and marked Complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
None. No TODOs, FIXMEs, placeholder returns, empty handlers, or stub implementations found in any of the 5 modified source files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
#### 1. Tap-to-edit navigation in running app
|
||||||
|
|
||||||
|
**Test:** Launch app, ensure at least one task exists on the calendar, tap the task row (not the checkbox).
|
||||||
|
**Expected:** App navigates to `TaskFormScreen` in edit mode showing the task's fields and a "Verlauf" row at the bottom.
|
||||||
|
**Why human:** GoRouter navigation with `context.go` cannot be verified by static analysis; requires runtime rendering.
|
||||||
|
|
||||||
|
#### 2. History sheet opens with correct content
|
||||||
|
|
||||||
|
**Test:** In `TaskFormScreen` edit mode, tap the "Verlauf" ListTile.
|
||||||
|
**Expected:** Bottom sheet slides up showing either: (a) the empty state with a history icon and "Noch nie erledigt", or (b) a list of past completions with `dd.MM.yyyy` dates as titles and `HH:mm` times as subtitles, newest first.
|
||||||
|
**Why human:** `showModalBottomSheet` rendering and visual layout cannot be verified by static analysis.
|
||||||
|
|
||||||
|
#### 3. Live update after completing a task
|
||||||
|
|
||||||
|
**Test:** Complete a task via checkbox in the calendar, then navigate to that task's edit form and tap "Verlauf".
|
||||||
|
**Expected:** The newly recorded completion appears at the top of the history sheet with today's date and approximate current time.
|
||||||
|
**Why human:** Real-time stream reactivity through the full UI stack (checkbox -> DAO write -> stream emit -> sheet UI update) requires runtime observation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verification Summary
|
||||||
|
|
||||||
|
All automated checks passed with no gaps found.
|
||||||
|
|
||||||
|
**Test suite:** 106/106 tests pass (101 pre-existing + 5 new DAO tests covering all specified behaviors).
|
||||||
|
**Static analysis:** `flutter analyze --no-fatal-infos` — zero issues.
|
||||||
|
**Commits verified:** All three phase commits exist (`2687f5e`, `ceae7d7`, `9f902ff`) with expected file changes.
|
||||||
|
|
||||||
|
The full feature chain is intact:
|
||||||
|
- `TaskCompletions` table stores timestamps (HIST-01, pre-existing from data layer)
|
||||||
|
- `watchCompletionsForTask` surfaces completions as a live Drift stream
|
||||||
|
- `task_history_sheet.dart` renders them in German locale with reverse-chronological ordering and a meaningful empty state
|
||||||
|
- `TaskFormScreen` (edit mode only) provides the "Verlauf" entry point
|
||||||
|
- `CalendarTaskRow` onTap makes history reachable from the home calendar in two taps
|
||||||
|
|
||||||
|
Three human-only items remain for final sign-off: tap navigation, sheet rendering, and live update after completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-16T22:15:00Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
@@ -36,6 +36,9 @@ class CalendarTaskRow extends StatelessWidget {
|
|||||||
final task = taskWithRoom.task;
|
final task = taskWithRoom.task;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
onTap: () => context.go(
|
||||||
|
'/rooms/${taskWithRoom.roomId}/tasks/${taskWithRoom.task.id}',
|
||||||
|
),
|
||||||
leading: Checkbox(
|
leading: Checkbox(
|
||||||
value: false,
|
value: false,
|
||||||
onChanged: (_) => onCompleted(),
|
onChanged: (_) => onCompleted(),
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Watch all completions for a task, newest first.
|
||||||
|
Stream<List<TaskCompletion>> watchCompletionsForTask(int taskId) {
|
||||||
|
return (select(taskCompletions)
|
||||||
|
..where((c) => c.taskId.equals(taskId))
|
||||||
|
..orderBy([(c) => OrderingTerm.desc(c.completedAt)]))
|
||||||
|
.watch();
|
||||||
|
}
|
||||||
|
|
||||||
/// Count overdue tasks in a room (nextDueDate before today).
|
/// Count overdue tasks in a room (nextDueDate before today).
|
||||||
Future<int> getOverdueTaskCount(int roomId, {DateTime? today}) async {
|
Future<int> getOverdueTaskCount(int roomId, {DateTime? today}) async {
|
||||||
final now = today ?? DateTime.now();
|
final now = today ?? DateTime.now();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../../../core/providers/database_provider.dart';
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../domain/effort_level.dart';
|
import '../domain/effort_level.dart';
|
||||||
import '../domain/frequency.dart';
|
import '../domain/frequency.dart';
|
||||||
|
import 'task_history_sheet.dart';
|
||||||
import 'task_providers.dart';
|
import 'task_providers.dart';
|
||||||
|
|
||||||
/// Full-screen form for task creation and editing.
|
/// Full-screen form for task creation and editing.
|
||||||
@@ -186,6 +187,21 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildDueDatePicker(theme),
|
_buildDueDatePicker(theme),
|
||||||
|
|
||||||
|
// History section (edit mode only)
|
||||||
|
if (widget.isEditing) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.history),
|
||||||
|
title: Text(l10n.taskHistoryTitle),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => showTaskHistorySheet(
|
||||||
|
context: context,
|
||||||
|
taskId: widget.taskId!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
136
lib/features/tasks/presentation/task_history_sheet.dart
Normal file
136
lib/features/tasks/presentation/task_history_sheet.dart
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../../../core/database/database.dart';
|
||||||
|
import '../../../core/providers/database_provider.dart';
|
||||||
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Shows a modal bottom sheet displaying the completion history for a task.
|
||||||
|
///
|
||||||
|
/// The sheet displays all past completions in reverse-chronological order
|
||||||
|
/// (newest first). If the task has never been completed, an empty state is shown.
|
||||||
|
Future<void> showTaskHistorySheet({
|
||||||
|
required BuildContext context,
|
||||||
|
required int taskId,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => _TaskHistorySheet(taskId: taskId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskHistorySheet extends ConsumerWidget {
|
||||||
|
const _TaskHistorySheet({required this.taskId});
|
||||||
|
|
||||||
|
final int taskId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Drag handle
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
l10n.taskHistoryTitle,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Completion list via StreamBuilder
|
||||||
|
StreamBuilder<List<TaskCompletion>>(
|
||||||
|
stream: ref
|
||||||
|
.read(appDatabaseProvider)
|
||||||
|
.tasksDao
|
||||||
|
.watchCompletionsForTask(taskId),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final completions = snapshot.data!;
|
||||||
|
|
||||||
|
if (completions.isEmpty) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.history,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.taskHistoryEmpty,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show count summary
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.taskHistoryCount(completions.length),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight:
|
||||||
|
MediaQuery.of(context).size.height * 0.4,
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: completions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final completion = completions[index];
|
||||||
|
final dateStr = DateFormat('dd.MM.yyyy', 'de')
|
||||||
|
.format(completion.completedAt);
|
||||||
|
final timeStr = DateFormat('HH:mm', 'de')
|
||||||
|
.format(completion.completedAt);
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: Text(dateStr),
|
||||||
|
subtitle: Text(timeStr),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,5 +107,13 @@
|
|||||||
"overdue": { "type": "int" }
|
"overdue": { "type": "int" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"calendarTodayButton": "Heute"
|
"calendarTodayButton": "Heute",
|
||||||
|
"taskHistoryTitle": "Verlauf",
|
||||||
|
"taskHistoryEmpty": "Noch nie erledigt",
|
||||||
|
"taskHistoryCount": "{count} Mal erledigt",
|
||||||
|
"@taskHistoryCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": { "type": "int" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,6 +519,24 @@ abstract class AppLocalizations {
|
|||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
/// **'Heute'**
|
/// **'Heute'**
|
||||||
String get calendarTodayButton;
|
String get calendarTodayButton;
|
||||||
|
|
||||||
|
/// No description provided for @taskHistoryTitle.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Verlauf'**
|
||||||
|
String get taskHistoryTitle;
|
||||||
|
|
||||||
|
/// No description provided for @taskHistoryEmpty.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Noch nie erledigt'**
|
||||||
|
String get taskHistoryEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @taskHistoryCount.
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'{count} Mal erledigt'**
|
||||||
|
String taskHistoryCount(int count);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -239,4 +239,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get calendarTodayButton => 'Heute';
|
String get calendarTodayButton => 'Heute';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get taskHistoryTitle => 'Verlauf';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get taskHistoryEmpty => 'Noch nie erledigt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String taskHistoryCount(int count) {
|
||||||
|
return '$count Mal erledigt';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
157
test/features/tasks/data/task_history_dao_test.dart
Normal file
157
test/features/tasks/data/task_history_dao_test.dart
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:household_keeper/core/database/database.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/domain/effort_level.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/domain/frequency.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late int roomId;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
roomId = await db.roomsDao.insertRoom(
|
||||||
|
RoomsCompanion.insert(name: 'Kueche', iconName: 'kitchen'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('TasksDao.watchCompletionsForTask', () {
|
||||||
|
test('returns empty list when task has no completions', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Staubsaugen',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final completions =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId).first;
|
||||||
|
|
||||||
|
expect(completions, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns completion after completeTask is called', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Abspuelen',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final completionTime = DateTime(2026, 3, 15, 10, 30);
|
||||||
|
await db.tasksDao.completeTask(taskId, now: completionTime);
|
||||||
|
|
||||||
|
final completions =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId).first;
|
||||||
|
|
||||||
|
expect(completions.length, 1);
|
||||||
|
expect(completions.first.taskId, taskId);
|
||||||
|
expect(completions.first.completedAt, completionTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns multiple completions in reverse-chronological order', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Fenster putzen',
|
||||||
|
intervalType: IntervalType.monthly,
|
||||||
|
effortLevel: EffortLevel.high,
|
||||||
|
nextDueDate: DateTime(2026, 1, 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Complete multiple times with specific timestamps
|
||||||
|
final time1 = DateTime(2026, 1, 10, 9, 0);
|
||||||
|
final time2 = DateTime(2026, 2, 12, 14, 30);
|
||||||
|
final time3 = DateTime(2026, 3, 15, 8, 0);
|
||||||
|
|
||||||
|
// Insert out of order to verify ordering is enforced by query
|
||||||
|
await db.tasksDao.completeTask(taskId, now: time1);
|
||||||
|
await db.tasksDao.completeTask(taskId, now: time2);
|
||||||
|
await db.tasksDao.completeTask(taskId, now: time3);
|
||||||
|
|
||||||
|
final completions =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId).first;
|
||||||
|
|
||||||
|
expect(completions.length, 3);
|
||||||
|
// Newest first (reverse-chronological)
|
||||||
|
expect(completions[0].completedAt, time3);
|
||||||
|
expect(completions[1].completedAt, time2);
|
||||||
|
expect(completions[2].completedAt, time1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completions for different tasks are isolated', () async {
|
||||||
|
final taskId1 = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Task A',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final taskId2 = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Task B',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.tasksDao.completeTask(taskId1, now: DateTime(2026, 3, 15));
|
||||||
|
|
||||||
|
final completionsForTask1 =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId1).first;
|
||||||
|
final completionsForTask2 =
|
||||||
|
await db.tasksDao.watchCompletionsForTask(taskId2).first;
|
||||||
|
|
||||||
|
expect(completionsForTask1.length, 1);
|
||||||
|
expect(completionsForTask1.first.taskId, taskId1);
|
||||||
|
expect(completionsForTask2, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stream emits updated list after new completion is added', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(
|
||||||
|
TasksCompanion.insert(
|
||||||
|
roomId: roomId,
|
||||||
|
name: 'Bodenwischen',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect stream emissions
|
||||||
|
final emissions = <List<TaskCompletion>>[];
|
||||||
|
final subscription = db.tasksDao
|
||||||
|
.watchCompletionsForTask(taskId)
|
||||||
|
.listen(emissions.add);
|
||||||
|
|
||||||
|
// Wait for initial empty emission
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(emissions.isNotEmpty, isTrue);
|
||||||
|
expect(emissions.last, isEmpty);
|
||||||
|
|
||||||
|
// Complete the task
|
||||||
|
await db.tasksDao.completeTask(taskId, now: DateTime(2026, 3, 15, 9, 0));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(emissions.last.length, 1);
|
||||||
|
|
||||||
|
await subscription.cancel();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user