6 Commits

Author SHA1 Message Date
2a4b14cb43 chore(release): improve F-Droid release workflow for repo persistence
- Download entire `fdroid/` directory from Hetzner to retain older APKs, repo keystore, and config.yml.
- Add steps to ensure repo signing key and icons during initialization.
- Adjust SCP upload to include the entire `fdroid/` directory for better state continuity.
2026-03-16 22:13:32 +01:00
7a2c1b81de docs(phase-06): complete phase execution 2026-03-16 22:02:03 +01:00
7344933278 docs(06-01): complete task history plan
- Create 06-01-SUMMARY.md with full execution documentation
- Update STATE.md: Phase 6 Plan 1 complete, decisions recorded
- Update ROADMAP.md: Phase 6 marked complete (1/1 plans)
- Mark HIST-01 and HIST-02 requirements complete
2026-03-16 21:59:00 +01:00
9f902ff2c7 feat(06-01): build task history sheet, wire into TaskFormScreen, add CalendarTaskRow navigation
- Create task_history_sheet.dart: showTaskHistorySheet() modal bottom sheet
- Sheet uses StreamBuilder on watchCompletionsForTask, shows dates in dd.MM.yyyy + HH:mm format
- Empty state: Icons.history + 'Noch nie erledigt' message
- Count summary shown above list when completions exist
- Add Verlauf ListTile to TaskFormScreen (edit mode only) opening history sheet
- Add onTap to CalendarTaskRow navigating to /rooms/:roomId/tasks/:taskId
- All 106 tests pass, zero analyze issues
2026-03-16 21:57:11 +01:00
ceae7d7d61 feat(06-01): add watchCompletionsForTask DAO method and history localization strings
- Add watchCompletionsForTask(taskId) to TasksDao: Stream<List<TaskCompletion>> sorted newest first
- Regenerate tasks_dao.g.dart with build_runner
- Add taskHistoryTitle, taskHistoryEmpty, taskHistoryCount to app_de.arb
- Regenerate app_localizations.dart and app_localizations_de.dart
- All 5 new DAO tests pass, zero analyze issues
2026-03-16 21:55:44 +01:00
2687f5e31e test(06-01): add failing tests for watchCompletionsForTask DAO method
- Tests cover empty state, single completion, multiple completions in reverse order
- Tests cover task isolation (different taskIds do not cross-contaminate)
- Tests cover stream reactivity (new completion triggers emission)
2026-03-16 21:53:21 +01:00
14 changed files with 651 additions and 28 deletions

View File

@@ -132,9 +132,27 @@ jobs:
-mkdir dev/fdroid/repo
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.
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
run: |
@@ -162,7 +180,6 @@ jobs:
PASS: ${{ secrets.HETZNER_PASS }}
run: |
set -euo pipefail
REMOTE_REPO_DIR="dev/fdroid/repo"
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
# Create remote directory tree via SFTP batch (no exec channel needed).
@@ -173,5 +190,6 @@ jobs:
-mkdir dev/fdroid/repo
SFTP
# Upload all files from fdroid/repo into the remote directory.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo/. "$USER@$HOST:$REMOTE_REPO_DIR/"
# Upload the entire fdroid/ directory (repo + keystore + config)
# so the signing key persists across runs.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/. "$USER@$HOST:dev/fdroid/"

View File

@@ -17,8 +17,8 @@ Requirements for milestone v1.1 Calendar & Polish. Each maps to roadmap phases.
### Task History
- [ ] **HIST-01**: Each task completion is recorded with a timestamp
- [ ] **HIST-02**: User can view past completion dates for any individual task
- [x] **HIST-01**: Each task completion is recorded with a timestamp
- [x] **HIST-02**: User can view past completion dates for any individual task
### Task Sorting
@@ -65,8 +65,8 @@ Which phases cover which requirements. Updated during roadmap creation.
| CAL-03 | Phase 5 | Complete |
| CAL-04 | Phase 5 | Complete |
| CAL-05 | Phase 5 | Complete |
| HIST-01 | Phase 6 | Pending |
| HIST-02 | Phase 6 | Pending |
| HIST-01 | Phase 6 | Complete |
| HIST-02 | Phase 6 | Complete |
| SORT-01 | Phase 7 | Pending |
| SORT-02 | Phase 7 | Pending |
| SORT-03 | Phase 7 | Pending |

View File

@@ -22,7 +22,7 @@ See `milestones/v1.0-ROADMAP.md` for full phase details.
**v1.1 Calendar & Polish (Phases 5-7):**
- [x] **Phase 5: Calendar Strip** - Replace the stacked daily plan home screen with a horizontal scrollable date-strip and day-task list (completed 2026-03-16)
- [ ] **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 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
2. From a task's detail or context menu the user can open a history view listing all past completion dates for that task in reverse-chronological order
3. The history view shows a meaningful empty state if the task has never been completed
**Plans:** 1 plan
**Plans:** 1/1 plans complete
Plans:
- [ ] 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 |
| 4. Notifications | v1.0 | 3/3 | 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 | - |

View File

@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: completed
stopped_at: Phase 6 context gathered
last_updated: "2026-03-16T20:46:23.402Z"
last_activity: 2026-03-16 — Completed Phase 5 Plan 02 (calendar strip UI)
stopped_at: Completed Phase 6 Plan 01 (task history)
last_updated: "2026-03-16T21:01:58.162Z"
last_activity: 2026-03-16 — Completed Phase 6 Plan 01 (task completion history)
progress:
total_phases: 3
completed_phases: 1
total_plans: 2
completed_plans: 2
completed_phases: 2
total_plans: 3
completed_plans: 3
percent: 100
---
@@ -21,17 +21,17 @@ progress:
See: .planning/PROJECT.md (updated 2026-03-16)
**Core value:** Users can see what needs doing today, mark it done, and trust the app to schedule the next occurrence — without thinking about it.
**Current focus:** v1.1 Calendar & Polish — Phase 5: Calendar Strip
**Current focus:** v1.1 Calendar & Polish — Phase 6: Task History
## Current Position
Phase: 5Calendar Strip
Plan: 2/2 complete (Phase 5 done)
Phase: 6Task History
Plan: 1/1 complete (Phase 6 done)
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
@@ -44,6 +44,7 @@ Progress: [██████████] 100% (2/2 plans in Phase 5)
| Tests | 89 | TBD |
| Phase 05-calendar-strip P01 | 5 | 2 tasks | 10 files |
| Phase 05-calendar-strip P02 | 8 | 3 tasks | 9 files |
| Phase 06-task-history P01 | 5 | 2 tasks | 9 files |
## 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 |
| CalendarStripController as VoidCallback holder | Avoids GlobalKey for single imperative scroll-to-today action — simpler |
| Tests use pump()+pump(Duration) instead of pumpAndSettle() | CalendarStrip animation controllers cause pumpAndSettle timeout — fixed-duration pump steps are reliable |
| No separate Riverpod provider for history sheet | ref.read(appDatabaseProvider) directly in ConsumerWidget — one-shot modals do not need a dedicated provider |
| CalendarTaskRow onTap navigates to task edit form | Makes history accessible in one tap from home screen, consistent with GoRouter route patterns |
### Pending Todos
@@ -71,7 +74,7 @@ None.
## Session Continuity
Last session: 2026-03-16T20:46:23.400Z
Stopped at: Phase 6 context gathered
Resume file: .planning/phases/06-task-history/06-CONTEXT.md
Next action: `/gsd:plan-phase 5`
Last session: 2026-03-16T20:57:30Z
Stopped at: Completed Phase 6 Plan 01 (task history)
Resume file: .planning/phases/06-task-history/06-01-SUMMARY.md
Next action: Phase 7 (task sorting) or release

View 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*

View 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)_

View File

@@ -36,6 +36,9 @@ class CalendarTaskRow extends StatelessWidget {
final task = taskWithRoom.task;
return ListTile(
onTap: () => context.go(
'/rooms/${taskWithRoom.roomId}/tasks/${taskWithRoom.task.id}',
),
leading: Checkbox(
value: false,
onChanged: (_) => onCompleted(),

View File

@@ -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).
Future<int> getOverdueTaskCount(int roomId, {DateTime? today}) async {
final now = today ?? DateTime.now();

View File

@@ -10,6 +10,7 @@ import '../../../core/providers/database_provider.dart';
import '../../../l10n/app_localizations.dart';
import '../domain/effort_level.dart';
import '../domain/frequency.dart';
import 'task_history_sheet.dart';
import 'task_providers.dart';
/// Full-screen form for task creation and editing.
@@ -186,6 +187,21 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
),
const SizedBox(height: 8),
_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!,
),
),
],
],
),
),

View 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),
],
),
),
);
}
}

View File

@@ -107,5 +107,13 @@
"overdue": { "type": "int" }
}
},
"calendarTodayButton": "Heute"
"calendarTodayButton": "Heute",
"taskHistoryTitle": "Verlauf",
"taskHistoryEmpty": "Noch nie erledigt",
"taskHistoryCount": "{count} Mal erledigt",
"@taskHistoryCount": {
"placeholders": {
"count": { "type": "int" }
}
}
}

View File

@@ -519,6 +519,24 @@ abstract class AppLocalizations {
/// In de, this message translates to:
/// **'Heute'**
String get calendarTodayButton;
/// No description provided for @taskHistoryTitle.
///
/// In de, this message translates to:
/// **'Verlauf'**
String get taskHistoryTitle;
/// No description provided for @taskHistoryEmpty.
///
/// In de, this message translates to:
/// **'Noch nie erledigt'**
String get taskHistoryEmpty;
/// No description provided for @taskHistoryCount.
///
/// In de, this message translates to:
/// **'{count} Mal erledigt'**
String taskHistoryCount(int count);
}
class _AppLocalizationsDelegate

View File

@@ -239,4 +239,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get calendarTodayButton => 'Heute';
@override
String get taskHistoryTitle => 'Verlauf';
@override
String get taskHistoryEmpty => 'Noch nie erledigt';
@override
String taskHistoryCount(int count) {
return '$count Mal erledigt';
}
}

View 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();
});
});
}