Compare commits
5 Commits
588f215078
...
7536f2f759
| Author | SHA1 | Date | |
|---|---|---|---|
| 7536f2f759 | |||
| 27b1a80f29 | |||
| 88ef248a33 | |||
| f718ee8483 | |||
| 01de2d0f9c |
@@ -114,7 +114,7 @@ jobs:
|
|||||||
$SUDO apt-get update
|
$SUDO apt-get update
|
||||||
# sshpass from apt, fdroidserver via pip to get a newer androguard that
|
# sshpass from apt, fdroidserver via pip to get a newer androguard that
|
||||||
# can parse modern Flutter/AGP APKs (apt ships fdroidserver 2.2.1 which crashes)
|
# can parse modern Flutter/AGP APKs (apt ships fdroidserver 2.2.1 which crashes)
|
||||||
$SUDO apt-get install -y sshpass rsync python3-pip
|
$SUDO apt-get install -y sshpass python3-pip
|
||||||
pip3 install --break-system-packages --upgrade fdroidserver
|
pip3 install --break-system-packages --upgrade fdroidserver
|
||||||
|
|
||||||
- name: Initialize or fetch F-Droid Repository
|
- name: Initialize or fetch F-Droid Repository
|
||||||
@@ -157,24 +157,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
REMOTE_REPO_DIR="dev/fdroid/repo"
|
REMOTE_REPO_DIR="dev/fdroid/repo"
|
||||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20 -o ServerAliveInterval=30 -o ServerAliveCountMax=5"
|
SCP_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20"
|
||||||
|
|
||||||
# Ensure target directory exists before upload.
|
# Use SCP/SFTP path only (some hosts deny SSH exec channels required by rsync/ssh).
|
||||||
sshpass -p "$PASS" ssh $SSH_OPTS "$USER@$HOST" "mkdir -p '$REMOTE_REPO_DIR'"
|
if sshpass -p "$PASS" scp $SCP_OPTS -r fdroid/repo/. "$USER@$HOST:$REMOTE_REPO_DIR/"; then
|
||||||
|
exit 0
|
||||||
if sshpass -p "$PASS" ssh $SSH_OPTS "$USER@$HOST" "command -v rsync >/dev/null 2>&1"; then
|
|
||||||
ATTEMPT=1
|
|
||||||
until [ "$ATTEMPT" -gt 3 ]; do
|
|
||||||
echo "Rsync upload attempt $ATTEMPT/3"
|
|
||||||
if sshpass -p "$PASS" rsync -avz --timeout=60 -e "ssh $SSH_OPTS" fdroid/repo/ "$USER@$HOST:$REMOTE_REPO_DIR/"; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep $((ATTEMPT * 5))
|
|
||||||
ATTEMPT=$((ATTEMPT + 1))
|
|
||||||
done
|
|
||||||
echo "Rsync failed after retries, falling back to scp"
|
|
||||||
else
|
|
||||||
echo "Remote rsync not found, using scp fallback"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo/. "$USER@$HOST:$REMOTE_REPO_DIR/"
|
# Fallback for older SSH servers that require legacy SCP protocol.
|
||||||
|
sshpass -p "$PASS" scp -O $SCP_OPTS -r fdroid/repo/. "$USER@$HOST:$REMOTE_REPO_DIR/"
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ Requirements for milestone v1.1 Calendar & Polish. Each maps to roadmap phases.
|
|||||||
|
|
||||||
### Calendar UI
|
### Calendar UI
|
||||||
|
|
||||||
- [ ] **CAL-01**: User sees a horizontal scrollable date-strip with day abbreviation (Mo, Di...) and date number per card
|
- [x] **CAL-01**: User sees a horizontal scrollable date-strip with day abbreviation (Mo, Di...) and date number per card
|
||||||
- [ ] **CAL-02**: User can tap a day card to see that day's tasks in a list below the strip
|
- [x] **CAL-02**: User can tap a day card to see that day's tasks in a list below the strip
|
||||||
- [ ] **CAL-03**: User sees a subtle color shift at month boundaries for visual orientation
|
- [x] **CAL-03**: User sees a subtle color shift at month boundaries for visual orientation
|
||||||
- [ ] **CAL-04**: Calendar strip auto-scrolls to today on app launch
|
- [x] **CAL-04**: Calendar strip auto-scrolls to today on app launch
|
||||||
- [ ] **CAL-05**: Undone tasks carry over to the next day with a red/orange color accent marking them as overdue
|
- [x] **CAL-05**: Undone tasks carry over to the next day with a red/orange color accent marking them as overdue
|
||||||
|
|
||||||
### Task History
|
### Task History
|
||||||
|
|
||||||
@@ -60,11 +60,11 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| CAL-01 | Phase 5 | Pending |
|
| CAL-01 | Phase 5 | Complete |
|
||||||
| CAL-02 | Phase 5 | Pending |
|
| CAL-02 | Phase 5 | Complete |
|
||||||
| CAL-03 | Phase 5 | Pending |
|
| CAL-03 | Phase 5 | Complete |
|
||||||
| CAL-04 | Phase 5 | Pending |
|
| CAL-04 | Phase 5 | Complete |
|
||||||
| CAL-05 | Phase 5 | Pending |
|
| CAL-05 | Phase 5 | Complete |
|
||||||
| HIST-01 | Phase 6 | Pending |
|
| HIST-01 | Phase 6 | Pending |
|
||||||
| HIST-02 | Phase 6 | Pending |
|
| HIST-02 | Phase 6 | Pending |
|
||||||
| SORT-01 | Phase 7 | Pending |
|
| SORT-01 | Phase 7 | Pending |
|
||||||
|
|||||||
@@ -21,7 +21,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):**
|
||||||
|
|
||||||
- [ ] **Phase 5: Calendar Strip** - Replace the stacked daily plan home screen with a horizontal scrollable date-strip and day-task list
|
- [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
|
- [ ] **Phase 6: Task History** - Record every task completion with a timestamp and expose a per-task history view
|
||||||
- [ ] **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
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ See `milestones/v1.0-ROADMAP.md` for full phase details.
|
|||||||
3. On app launch the strip auto-scrolls so today's card is centered and selected by default
|
3. On app launch the strip auto-scrolls so today's card is centered and selected by default
|
||||||
4. When two adjacent day cards span a month boundary, a subtle color shift or divider makes the boundary visible without extra chrome
|
4. When two adjacent day cards span a month boundary, a subtle color shift or divider makes the boundary visible without extra chrome
|
||||||
5. Tasks that were not completed on their due date appear in subsequent days' lists with a red/orange accent marking them as overdue
|
5. Tasks that were not completed on their due date appear in subsequent days' lists with a red/orange accent marking them as overdue
|
||||||
**Plans:** 2 plans
|
**Plans:** 2/2 plans complete
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 05-01-PLAN.md — Data layer: CalendarDao, CalendarDayState model, Riverpod providers, localization, DAO tests
|
- [ ] 05-01-PLAN.md — Data layer: CalendarDao, CalendarDayState model, Riverpod providers, localization, DAO tests
|
||||||
- [ ] 05-02-PLAN.md — UI: CalendarStrip, CalendarDayList, CalendarTaskRow widgets, HomeScreen replacement
|
- [ ] 05-02-PLAN.md — UI: CalendarStrip, CalendarDayList, CalendarTaskRow widgets, HomeScreen replacement
|
||||||
@@ -71,6 +71,6 @@ Plans:
|
|||||||
| 2. Rooms and Tasks | v1.0 | 5/5 | Complete | 2026-03-15 |
|
| 2. Rooms and Tasks | v1.0 | 5/5 | Complete | 2026-03-15 |
|
||||||
| 3. Daily Plan and Cleanliness | v1.0 | 3/3 | Complete | 2026-03-16 |
|
| 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 | v1.1 | 0/2 | Planned | - |
|
| 5. Calendar Strip | 2/2 | Complete | 2026-03-16 | - |
|
||||||
| 6. Task History | v1.1 | 0/? | Not started | - |
|
| 6. Task History | v1.1 | 0/? | Not started | - |
|
||||||
| 7. Task Sorting | v1.1 | 0/? | Not started | - |
|
| 7. Task Sorting | v1.1 | 0/? | Not started | - |
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.1
|
milestone: v1.0
|
||||||
milestone_name: Calendar & Polish
|
milestone_name: milestone
|
||||||
status: ready
|
status: executing
|
||||||
stopped_at: Roadmap created — ready for Phase 5
|
stopped_at: Completed 05-calendar-strip 05-02-PLAN.md
|
||||||
last_updated: "2026-03-16T21:00:00.000Z"
|
last_updated: "2026-03-16T20:37:30.052Z"
|
||||||
last_activity: 2026-03-16 — Roadmap created for v1.1 (phases 5-7)
|
last_activity: 2026-03-16 — Completed Phase 5 Plan 01 (CalendarDao + providers)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 3
|
total_phases: 3
|
||||||
completed_phases: 0
|
completed_phases: 1
|
||||||
total_plans: 0
|
total_plans: 2
|
||||||
completed_plans: 0
|
completed_plans: 2
|
||||||
percent: 0
|
percent: 50
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -26,12 +26,12 @@ See: .planning/PROJECT.md (updated 2026-03-16)
|
|||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 5 — Calendar Strip
|
Phase: 5 — Calendar Strip
|
||||||
Plan: Not started
|
Plan: 2/2 complete (Phase 5 done)
|
||||||
Status: Ready to plan Phase 5
|
Status: Phase Complete
|
||||||
Last activity: 2026-03-16 — Roadmap for v1.1 written (phases 5-7)
|
Last activity: 2026-03-16 — Completed Phase 5 Plan 02 (calendar strip UI)
|
||||||
|
|
||||||
```
|
```
|
||||||
Progress: [ ░░░░░░░░░░░░░░░░░░░░ ] 0% (0/3 phases)
|
Progress: [██████████] 100% (2/2 plans in Phase 5)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
@@ -42,6 +42,8 @@ Progress: [ ░░░░░░░░░░░░░░░░░░░░ ] 0% (0
|
|||||||
| Plans | 13 | TBD |
|
| Plans | 13 | TBD |
|
||||||
| LOC (lib) | 7,773 | TBD |
|
| LOC (lib) | 7,773 | TBD |
|
||||||
| Tests | 89 | TBD |
|
| Tests | 89 | TBD |
|
||||||
|
| Phase 05-calendar-strip P01 | 5 | 2 tasks | 10 files |
|
||||||
|
| Phase 05-calendar-strip P02 | 8 | 3 tasks | 9 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -53,6 +55,11 @@ Progress: [ ░░░░░░░░░░░░░░░░░░░░ ] 0% (0
|
|||||||
| Phase 5 before Phase 6 and 7 | Calendar strip is the primary UI surface; history and sorting operate within or alongside it |
|
| Phase 5 before Phase 6 and 7 | Calendar strip is the primary UI surface; history and sorting operate within or alongside it |
|
||||||
| Phase 6 and 7 both depend on Phase 5 only | History and sorting are independent of each other — could execute in either order |
|
| Phase 6 and 7 both depend on Phase 5 only | History and sorting are independent of each other — could execute in either order |
|
||||||
| HIST-01 and HIST-02 in same phase | Data layer (HIST-01) is only 1-2 DAO additions; grouping with the UI (HIST-02) keeps the phase coherent |
|
| HIST-01 and HIST-02 in same phase | Data layer (HIST-01) is only 1-2 DAO additions; grouping with the UI (HIST-02) keeps the phase coherent |
|
||||||
|
| Used NotifierProvider<SelectedDateNotifier> instead of deprecated StateProvider | Riverpod 3.x removed StateProvider; NotifierProvider is the correct replacement |
|
||||||
|
| calendarDayProvider fetches overdue tasks with .first in asyncMap when isToday | Consistent with dailyPlanProvider pattern; avoids combining two streams |
|
||||||
|
| watchTasksForDate sorts alphabetically by task name | Same-day tasks have no meaningful time-based order; alpha sort is deterministic and user-friendly |
|
||||||
|
| CalendarStripController as VoidCallback holder | Avoids GlobalKey for single imperative scroll-to-today action — simpler |
|
||||||
|
| Tests use pump()+pump(Duration) instead of pumpAndSettle() | CalendarStrip animation controllers cause pumpAndSettle timeout — fixed-duration pump steps are reliable |
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -60,12 +67,11 @@ None.
|
|||||||
|
|
||||||
### Blockers/Concerns
|
### Blockers/Concerns
|
||||||
|
|
||||||
- The existing HomeScreen (daily plan with overdue/today/upcoming) will be replaced entirely in Phase 5. Verify no other screen references the daily plan provider before deleting it, or migrate references.
|
- Phase 5 complete. daily_plan_providers.dart, daily_plan_task_row.dart, and progress_card.dart are now dead code (safe to clean up in a future phase). DailyPlanDao must NOT be deleted — still used by the notification service.
|
||||||
- CAL-05 (overdue carry-over with color accent) requires a query that returns tasks by their original due date relative to a selected day — confirm the existing DailyPlanDao can be adapted or a new CalendarDao is needed.
|
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-16
|
Last session: 2026-03-16T20:37:30.050Z
|
||||||
Stopped at: Roadmap created, ready for Phase 5 planning
|
Stopped at: Completed 05-calendar-strip 05-02-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
Next action: `/gsd:plan-phase 5`
|
Next action: `/gsd:plan-phase 5`
|
||||||
|
|||||||
142
.planning/phases/05-calendar-strip/05-01-SUMMARY.md
Normal file
142
.planning/phases/05-calendar-strip/05-01-SUMMARY.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
phase: 05-calendar-strip
|
||||||
|
plan: 01
|
||||||
|
subsystem: database
|
||||||
|
tags: [drift, riverpod, dart, flutter, localization, tdd]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- CalendarDao with watchTasksForDate and watchOverdueTasks date-parameterized queries
|
||||||
|
- CalendarDayState domain model with selectedDate/dayTasks/overdueTasks
|
||||||
|
- selectedDateProvider (NotifierProvider, persists while app is alive)
|
||||||
|
- calendarDayProvider (StreamProvider.autoDispose, overdue only for today)
|
||||||
|
- calendarTodayButton l10n string in ARB and generated dart files
|
||||||
|
- 11 DAO unit tests covering all query behaviors
|
||||||
|
affects:
|
||||||
|
- 05-calendar-strip plan 02 (calendar strip UI uses these providers and state model)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "CalendarDao follows @DriftAccessor pattern with DatabaseAccessor<AppDatabase>"
|
||||||
|
- "Manual NotifierProvider<SelectedDateNotifier, DateTime> instead of @riverpod (Riverpod 3.x pattern)"
|
||||||
|
- "StreamProvider.autoDispose with asyncMap for combining day + overdue streams"
|
||||||
|
- "TDD: failing test commit, then implementation commit"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- lib/features/home/data/calendar_dao.dart
|
||||||
|
- lib/features/home/data/calendar_dao.g.dart
|
||||||
|
- lib/features/home/domain/calendar_models.dart
|
||||||
|
- lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- test/features/home/data/calendar_dao_test.dart
|
||||||
|
modified:
|
||||||
|
- lib/core/database/database.dart
|
||||||
|
- lib/core/database/database.g.dart
|
||||||
|
- lib/l10n/app_de.arb
|
||||||
|
- lib/l10n/app_localizations.dart
|
||||||
|
- lib/l10n/app_localizations_de.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Used NotifierProvider<SelectedDateNotifier, DateTime> instead of deprecated StateProvider — Riverpod 3.x removed StateProvider in favour of Notifier-based providers"
|
||||||
|
- "calendarDayProvider fetches overdue tasks with .first when isToday, keeping asyncMap pattern consistent with dailyPlanProvider"
|
||||||
|
- "watchTasksForDate sorts alphabetically by name (not by due time) — arbitrary due time on same day has no meaningful sort order"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "CalendarDao: @DriftAccessor with join + where filter + orderBy, mapped to TaskWithRoom — same shape as DailyPlanDao"
|
||||||
|
- "Manual Notifier subclass for simple value-holding state provider (not @riverpod) to avoid code gen constraints"
|
||||||
|
|
||||||
|
requirements-completed: [CAL-02, CAL-05]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 5min
|
||||||
|
completed: 2026-03-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 5 Plan 01: Calendar Data Layer Summary
|
||||||
|
|
||||||
|
**CalendarDao with date-exact and overdue-before-date Drift queries, CalendarDayState model, Riverpod providers for selected date and day state, and "Heute" l10n string — full data foundation for the calendar strip UI**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 5 min
|
||||||
|
- **Started:** 2026-03-16T20:18:55Z
|
||||||
|
- **Completed:** 2026-03-16T20:24:12Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 10
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- CalendarDao registered in AppDatabase with two reactive Drift streams: `watchTasksForDate` (exact day, sorted by name) and `watchOverdueTasks` (strictly before reference date, sorted by due date)
|
||||||
|
- CalendarDayState domain model separating dayTasks and overdueTasks with isEmpty helper
|
||||||
|
- selectedDateProvider (NotifierProvider, keeps alive) + calendarDayProvider (StreamProvider.autoDispose) following existing Riverpod patterns
|
||||||
|
- 11 unit tests passing via TDD red-green cycle; full 100-test suite passes with no regressions
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: RED - CalendarDao tests** - `f5c4b49` (test)
|
||||||
|
2. **Task 1: GREEN - CalendarDao implementation** - `c666f9a` (feat)
|
||||||
|
3. **Task 2: CalendarDayState, providers, l10n** - `68ba7c6` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `lib/features/home/data/calendar_dao.dart` - CalendarDao with watchTasksForDate and watchOverdueTasks
|
||||||
|
- `lib/features/home/data/calendar_dao.g.dart` - Generated Drift mixin for CalendarDao
|
||||||
|
- `lib/features/home/domain/calendar_models.dart` - CalendarDayState model
|
||||||
|
- `lib/features/home/presentation/calendar_providers.dart` - selectedDateProvider and calendarDayProvider
|
||||||
|
- `test/features/home/data/calendar_dao_test.dart` - 11 DAO unit tests (TDD RED phase)
|
||||||
|
- `lib/core/database/database.dart` - Added CalendarDao import and registration in @DriftDatabase
|
||||||
|
- `lib/core/database/database.g.dart` - Regenerated with CalendarDao accessor
|
||||||
|
- `lib/l10n/app_de.arb` - Added calendarTodayButton: "Heute"
|
||||||
|
- `lib/l10n/app_localizations.dart` - Regenerated with calendarTodayButton getter
|
||||||
|
- `lib/l10n/app_localizations_de.dart` - Regenerated with calendarTodayButton implementation
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- **NotifierProvider instead of StateProvider:** Riverpod 3.x dropped `StateProvider` — replaced with `NotifierProvider<SelectedDateNotifier, DateTime>` pattern (manual, not @riverpod) to keep consistent with the codebase's non-generated providers.
|
||||||
|
- **Overdue fetched with .first inside asyncMap:** When isToday, the overdue tasks stream's first emission is awaited inside asyncMap on the day tasks stream. This avoids combining two streams and stays consistent with the `dailyPlanProvider` pattern.
|
||||||
|
- **watchTasksForDate sorts alphabetically by name:** Tasks due on the same calendar day have no meaningful relative order by time. Alphabetical name sort gives deterministic, user-friendly ordering.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] StateProvider unavailable in Riverpod 3.x**
|
||||||
|
- **Found during:** Task 2 (calendar providers)
|
||||||
|
- **Issue:** Plan specified `StateProvider<DateTime>` but flutter_riverpod 3.3.1 removed StateProvider; analyzer reported `undefined_function`
|
||||||
|
- **Fix:** Replaced with `NotifierProvider<SelectedDateNotifier, DateTime>` using a minimal `Notifier` subclass with a `selectDate(DateTime)` method
|
||||||
|
- **Files modified:** lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- **Verification:** `flutter analyze --no-fatal-infos` reports no issues
|
||||||
|
- **Committed in:** 68ba7c6 (Task 2 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (Rule 1 - Bug)
|
||||||
|
**Impact on plan:** Fix was required for compilation. The API surface is equivalent — consumers call `ref.watch(selectedDateProvider)` to read the date and `ref.read(selectedDateProvider.notifier).selectDate(date)` to update it. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- None beyond the StateProvider API change documented above.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- CalendarDao, CalendarDayState, selectedDateProvider, and calendarDayProvider are all ready for consumption by Plan 02 (calendar strip UI)
|
||||||
|
- The `selectDate` method on SelectedDateNotifier is the correct way to update the selected date from the UI
|
||||||
|
- Existing dailyPlanProvider is unchanged — Plan 02 will decide whether to replace or retain it in the HomeScreen
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 05-calendar-strip*
|
||||||
|
*Completed: 2026-03-16*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: lib/features/home/data/calendar_dao.dart
|
||||||
|
- FOUND: lib/features/home/domain/calendar_models.dart
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- FOUND: test/features/home/data/calendar_dao_test.dart
|
||||||
|
- FOUND: .planning/phases/05-calendar-strip/05-01-SUMMARY.md
|
||||||
|
- FOUND: commit f5c4b49 (test RED phase)
|
||||||
|
- FOUND: commit c666f9a (feat GREEN phase)
|
||||||
|
- FOUND: commit 68ba7c6 (feat Task 2)
|
||||||
148
.planning/phases/05-calendar-strip/05-02-SUMMARY.md
Normal file
148
.planning/phases/05-calendar-strip/05-02-SUMMARY.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
phase: 05-calendar-strip
|
||||||
|
plan: 02
|
||||||
|
subsystem: ui
|
||||||
|
tags: [flutter, riverpod, dart, intl, animation, calendar]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 05-calendar-strip plan 01
|
||||||
|
provides: CalendarDao, CalendarDayState, selectedDateProvider, calendarDayProvider
|
||||||
|
provides:
|
||||||
|
- CalendarStrip widget (181-day horizontal scroll, German abbreviations, month boundary labels)
|
||||||
|
- CalendarTaskRow widget (task name + room tag chip + checkbox, no relative date)
|
||||||
|
- CalendarDayList widget (loading/empty/celebration/tasks states, overdue section today-only)
|
||||||
|
- Rewritten HomeScreen composing strip + day list with floating Today button
|
||||||
|
- totalTaskCount field on CalendarDayState and getTaskCount() on CalendarDao
|
||||||
|
- Updated home screen and app shell tests for new calendar providers
|
||||||
|
affects:
|
||||||
|
- 06-task-history (uses CalendarStrip as the navigation surface)
|
||||||
|
- 07-task-sorting (task display within CalendarDayList)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "CalendarStrip uses CalendarStripController (simple VoidCallback holder) for parent-to-child imperative scrolling"
|
||||||
|
- "CalendarDayList manages _completingTaskIds Set<int> for slide-out animation the same way as old HomeScreen"
|
||||||
|
- "Tests use tester.pump() + pump(Duration) instead of pumpAndSettle() to avoid timeout from animation controllers"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- lib/features/home/presentation/calendar_strip.dart
|
||||||
|
- lib/features/home/presentation/calendar_task_row.dart
|
||||||
|
- lib/features/home/presentation/calendar_day_list.dart
|
||||||
|
modified:
|
||||||
|
- lib/features/home/presentation/home_screen.dart
|
||||||
|
- lib/features/home/domain/calendar_models.dart
|
||||||
|
- lib/features/home/data/calendar_dao.dart
|
||||||
|
- lib/features/home/presentation/calendar_providers.dart
|
||||||
|
- test/features/home/presentation/home_screen_test.dart
|
||||||
|
- test/shell/app_shell_test.dart
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "CalendarStripController holds a VoidCallback instead of using GlobalKey — simpler for this one-direction imperative call"
|
||||||
|
- "totalTaskCount fetched via getTaskCount() inside calendarDayProvider asyncMap — avoids a third stream, consistent with existing pattern"
|
||||||
|
- "Tests use pump() + pump(Duration) instead of pumpAndSettle() — CalendarStrip's ScrollController postFrameCallback and animation controllers cause pumpAndSettle to timeout"
|
||||||
|
- "month label height always reserved with SizedBox(height:16) on non-boundary cards — prevents strip height jitter as you scroll through months"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "ImperativeController pattern: class with VoidCallback? _action; void action() => _action?.call(); widget sets _action in initState"
|
||||||
|
- "CalendarDayList state machine: first-run (totalTaskCount==0) > celebration (isToday + isEmpty + totalTaskCount>0) > emptyDay (isEmpty) > hasTasks"
|
||||||
|
|
||||||
|
requirements-completed: [CAL-01, CAL-03, CAL-04, CAL-05]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 8min
|
||||||
|
completed: 2026-03-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 5 Plan 02: Calendar Strip UI Summary
|
||||||
|
|
||||||
|
**Horizontal 181-day calendar strip with German day cards, month boundaries, floating Today button, and day task list with overdue section — replaces the stacked daily-plan HomeScreen**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 8 min
|
||||||
|
- **Started:** 2026-03-16T20:27:39Z
|
||||||
|
- **Completed:** 2026-03-16T20:35:55Z
|
||||||
|
- **Tasks:** 3 (Task 3 auto-approved in auto-advance mode)
|
||||||
|
- **Files modified:** 9
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- CalendarStrip: horizontal ListView with 181 day cards (90 past + today + 90 future), German abbreviations via `DateFormat('E', 'de')`, selected card highlighted (stronger primaryContainer + border), today card with bold text + 2px accent underline, month boundary wider gap + month label, auto-scrolls to center today on init, CalendarStripController enables Today-button → strip communication
|
||||||
|
- CalendarDayList: five-state machine (loading, first-run empty, celebration, empty day, has tasks) with overdue section when viewing today, slide-out completion animation reusing the same SizeTransition + SlideTransition pattern from the old HomeScreen
|
||||||
|
- CalendarTaskRow: simplified from DailyPlanTaskRow — no relative date, name + room chip + checkbox, coral text when isOverdue
|
||||||
|
- HomeScreen rewritten: Stack with Column(CalendarStrip + Expanded(CalendarDayList)) and conditionally-visible FloatingActionButton.extended for "Heute" navigation
|
||||||
|
- Added totalTaskCount to CalendarDayState and getTaskCount() SELECT COUNT to CalendarDao for first-run vs. celebration disambiguation
|
||||||
|
- Updated 2 test files (home_screen_test.dart, app_shell_test.dart) to test new providers; test count grew from 100 to 101
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Build CalendarStrip, CalendarTaskRow, CalendarDayList widgets** - `f718ee8` (feat)
|
||||||
|
2. **Task 2: Replace HomeScreen with calendar composition** - `88ef248` (feat)
|
||||||
|
3. **Task 3: Verify calendar strip visually** - auto-approved (checkpoint:human-verify in auto-advance mode)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `lib/features/home/presentation/calendar_strip.dart` - 181-day horizontal scrollable strip with German abbreviations, today/selected highlights, month boundary labels
|
||||||
|
- `lib/features/home/presentation/calendar_task_row.dart` - Task row: name + room chip + checkbox, isOverdue coral styling, no relative date
|
||||||
|
- `lib/features/home/presentation/calendar_day_list.dart` - Day task list with 5-state machine, overdue section (today only), slide-out animation
|
||||||
|
- `lib/features/home/presentation/home_screen.dart` - Rewritten: CalendarStrip + CalendarDayList + floating Today FAB
|
||||||
|
- `lib/features/home/domain/calendar_models.dart` - Added totalTaskCount field
|
||||||
|
- `lib/features/home/data/calendar_dao.dart` - Added getTaskCount() query
|
||||||
|
- `lib/features/home/presentation/calendar_providers.dart` - calendarDayProvider now fetches and includes totalTaskCount
|
||||||
|
- `test/features/home/presentation/home_screen_test.dart` - Rewritten for CalendarDayState / calendarDayProvider
|
||||||
|
- `test/shell/app_shell_test.dart` - Updated from dailyPlanProvider to calendarDayProvider
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- **CalendarStripController as simple VoidCallback holder:** Avoids GlobalKey complexity for a single imperative scroll-to-today action; parent holds controller, widget registers its implementation in initState.
|
||||||
|
- **totalTaskCount fetched in asyncMap:** Consistent with existing calendarDayProvider asyncMap pattern; avoids a third reactive stream just for a count.
|
||||||
|
- **Tests use pump() + pump(Duration) instead of pumpAndSettle():** ScrollController's postFrameCallback animation and _completingTaskIds AnimationController keep the tester busy indefinitely; fixed-duration pump steps are reliable.
|
||||||
|
- **Month label height always reserved:** Non-boundary cards get `SizedBox(height: 16)` to match the label row height — prevents strip height from changing as you scroll across month edges.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Updated existing tests broken by the HomeScreen rewrite**
|
||||||
|
- **Found during:** Task 2 verification (flutter test)
|
||||||
|
- **Issue:** `home_screen_test.dart` and `app_shell_test.dart` both imported `dailyPlanProvider` and `DailyPlanState` and used `pumpAndSettle()`, which now times out because CalendarStrip animation controllers never settle
|
||||||
|
- **Fix:** Rewrote both test files to use `calendarDayProvider`/`CalendarDayState` and replaced `pumpAndSettle()` with `pump() + pump(Duration(milliseconds: 500))`; updated all assertions to match new UI (removed progress card / tomorrow section assertions, added strip-visible assertion)
|
||||||
|
- **Files modified:** test/features/home/presentation/home_screen_test.dart, test/shell/app_shell_test.dart
|
||||||
|
- **Verification:** `flutter test` — 101 tests all pass; `flutter analyze --no-fatal-infos` — zero issues
|
||||||
|
- **Committed in:** f718ee8 (Task 1 commit, as tests were fixed alongside widget creation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (Rule 1 - Bug)
|
||||||
|
**Impact on plan:** Required to maintain working test suite. The new tests cover the same behaviors (empty state, overdue section, celebration, checkboxes) but against the calendar API. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- None beyond the test migration documented above.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- HomeScreen is fully replaced; CalendarStrip and CalendarDayList are composable widgets ready for Phase 6/7 integration
|
||||||
|
- The old daily_plan_providers.dart, daily_plan_task_row.dart, and progress_card.dart are now dead code; safe to clean up in a future phase
|
||||||
|
- DailyPlanDao is still used by the notification service and must NOT be deleted
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 05-calendar-strip*
|
||||||
|
*Completed: 2026-03-16*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_strip.dart
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_task_row.dart
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_day_list.dart
|
||||||
|
- FOUND: lib/features/home/presentation/home_screen.dart (rewritten)
|
||||||
|
- FOUND: lib/features/home/domain/calendar_models.dart (updated)
|
||||||
|
- FOUND: lib/features/home/data/calendar_dao.dart (updated)
|
||||||
|
- FOUND: lib/features/home/presentation/calendar_providers.dart (updated)
|
||||||
|
- FOUND: .planning/phases/05-calendar-strip/05-02-SUMMARY.md
|
||||||
|
- FOUND: commit f718ee8 (Task 1)
|
||||||
|
- FOUND: commit 88ef248 (Task 2)
|
||||||
@@ -45,6 +45,16 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the total count of tasks across all rooms and dates.
|
||||||
|
///
|
||||||
|
/// Used by the UI to distinguish first-run empty state from celebration state.
|
||||||
|
Future<int> getTaskCount() async {
|
||||||
|
final countExp = tasks.id.count();
|
||||||
|
final query = selectOnly(tasks)..addColumns([countExp]);
|
||||||
|
final result = await query.getSingle();
|
||||||
|
return result.read(countExp) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Watch tasks whose [nextDueDate] is strictly before [referenceDate].
|
/// Watch tasks whose [nextDueDate] is strictly before [referenceDate].
|
||||||
///
|
///
|
||||||
/// Returns tasks sorted by [nextDueDate] ascending (oldest first).
|
/// Returns tasks sorted by [nextDueDate] ascending (oldest first).
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ class CalendarDayState {
|
|||||||
final List<TaskWithRoom> dayTasks;
|
final List<TaskWithRoom> dayTasks;
|
||||||
final List<TaskWithRoom> overdueTasks;
|
final List<TaskWithRoom> overdueTasks;
|
||||||
|
|
||||||
|
/// Total number of tasks in the database (across all days/rooms).
|
||||||
|
/// Used by the UI to distinguish first-run empty state (no tasks exist at all)
|
||||||
|
/// from celebration state (tasks exist but today's are all done).
|
||||||
|
final int totalTaskCount;
|
||||||
|
|
||||||
const CalendarDayState({
|
const CalendarDayState({
|
||||||
required this.selectedDate,
|
required this.selectedDate,
|
||||||
required this.dayTasks,
|
required this.dayTasks,
|
||||||
required this.overdueTasks,
|
required this.overdueTasks,
|
||||||
|
required this.totalTaskCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// True when both day tasks and overdue tasks are empty.
|
/// True when both day tasks and overdue tasks are empty.
|
||||||
|
|||||||
310
lib/features/home/presentation/calendar_day_list.dart
Normal file
310
lib/features/home/presentation/calendar_day_list.dart
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
||||||
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
|
import 'package:household_keeper/features/home/presentation/calendar_task_row.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/presentation/task_providers.dart';
|
||||||
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Warm coral/terracotta color for overdue section header.
|
||||||
|
const _overdueColor = Color(0xFFE07A5F);
|
||||||
|
|
||||||
|
/// Shows the task list for the selected calendar day.
|
||||||
|
///
|
||||||
|
/// Watches [calendarDayProvider] and renders one of several states:
|
||||||
|
/// - Loading spinner while data loads
|
||||||
|
/// - Error text on failure
|
||||||
|
/// - First-run empty state (no rooms/tasks at all) — prompts to create a room
|
||||||
|
/// - Empty day state (tasks exist elsewhere but not this day)
|
||||||
|
/// - Celebration state (today is selected and all tasks are done)
|
||||||
|
/// - Has-tasks state with optional overdue section (today only) and checkboxes
|
||||||
|
class CalendarDayList extends ConsumerStatefulWidget {
|
||||||
|
const CalendarDayList({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CalendarDayList> createState() => _CalendarDayListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CalendarDayListState extends ConsumerState<CalendarDayList> {
|
||||||
|
/// Task IDs currently animating out after completion.
|
||||||
|
final Set<int> _completingTaskIds = {};
|
||||||
|
|
||||||
|
void _onTaskCompleted(int taskId) {
|
||||||
|
setState(() {
|
||||||
|
_completingTaskIds.add(taskId);
|
||||||
|
});
|
||||||
|
ref.read(taskActionsProvider.notifier).completeTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final dayState = ref.watch(calendarDayProvider);
|
||||||
|
|
||||||
|
return dayState.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => Center(child: Text(error.toString())),
|
||||||
|
data: (state) {
|
||||||
|
// Clean up animation IDs for tasks that are no longer in the data.
|
||||||
|
_completingTaskIds.removeWhere((id) =>
|
||||||
|
!state.overdueTasks.any((t) => t.task.id == id) &&
|
||||||
|
!state.dayTasks.any((t) => t.task.id == id));
|
||||||
|
|
||||||
|
return _buildContent(context, state, l10n, theme);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(
|
||||||
|
BuildContext context,
|
||||||
|
CalendarDayState state,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
ThemeData theme,
|
||||||
|
) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final isToday = state.selectedDate == today;
|
||||||
|
|
||||||
|
// State (a): First-run empty — no tasks exist at all in the database.
|
||||||
|
if (state.isEmpty && state.totalTaskCount == 0) {
|
||||||
|
return _buildFirstRunEmpty(context, l10n, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (e): Celebration — today is selected and all tasks are done
|
||||||
|
// (totalTaskCount > 0 so at least some task exists somewhere, but today
|
||||||
|
// has none remaining after completion).
|
||||||
|
if (isToday && state.dayTasks.isEmpty && state.overdueTasks.isEmpty && state.totalTaskCount > 0) {
|
||||||
|
return _buildCelebration(l10n, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (d): Empty day — tasks exist elsewhere but not this day.
|
||||||
|
if (state.isEmpty) {
|
||||||
|
return _buildEmptyDay(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (f): Has tasks — render overdue section (today only) + day tasks.
|
||||||
|
return _buildTaskList(state, l10n, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First-run: no rooms/tasks created yet.
|
||||||
|
Widget _buildFirstRunEmpty(
|
||||||
|
BuildContext context,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
ThemeData theme,
|
||||||
|
) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.checklist_rounded,
|
||||||
|
size: 80,
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
l10n.dailyPlanNoTasks,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.homeEmptyMessage,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () => context.go('/rooms'),
|
||||||
|
child: Text(l10n.homeEmptyAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Celebration state: today is selected and all tasks are done.
|
||||||
|
Widget _buildCelebration(AppLocalizations l10n, ThemeData theme) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.celebration_outlined,
|
||||||
|
size: 80,
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
l10n.dailyPlanAllClearTitle,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.dailyPlanAllClearMessage,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty day: tasks exist elsewhere but nothing scheduled for this day.
|
||||||
|
Widget _buildEmptyDay(ThemeData theme) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.event_available,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Keine Aufgaben',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Task list with optional overdue section.
|
||||||
|
Widget _buildTaskList(
|
||||||
|
CalendarDayState state,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
ThemeData theme,
|
||||||
|
) {
|
||||||
|
final items = <Widget>[];
|
||||||
|
|
||||||
|
// Overdue section (today only, when overdue tasks exist).
|
||||||
|
if (state.overdueTasks.isNotEmpty) {
|
||||||
|
items.add(_buildSectionHeader(l10n.dailyPlanSectionOverdue, theme,
|
||||||
|
color: _overdueColor));
|
||||||
|
for (final tw in state.overdueTasks) {
|
||||||
|
items.add(_buildAnimatedTaskRow(tw, isOverdue: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day tasks section.
|
||||||
|
for (final tw in state.dayTasks) {
|
||||||
|
items.add(_buildAnimatedTaskRow(tw, isOverdue: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(children: items);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(
|
||||||
|
String title,
|
||||||
|
ThemeData theme, {
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnimatedTaskRow(TaskWithRoom tw, {required bool isOverdue}) {
|
||||||
|
final isCompleting = _completingTaskIds.contains(tw.task.id);
|
||||||
|
|
||||||
|
if (isCompleting) {
|
||||||
|
return _CompletingTaskRow(
|
||||||
|
key: ValueKey('completing-${tw.task.id}'),
|
||||||
|
taskWithRoom: tw,
|
||||||
|
isOverdue: isOverdue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CalendarTaskRow(
|
||||||
|
key: ValueKey('task-${tw.task.id}'),
|
||||||
|
taskWithRoom: tw,
|
||||||
|
isOverdue: isOverdue,
|
||||||
|
onCompleted: () => _onTaskCompleted(tw.task.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A task row that animates (slide + size) to zero height on completion.
|
||||||
|
class _CompletingTaskRow extends StatefulWidget {
|
||||||
|
const _CompletingTaskRow({
|
||||||
|
super.key,
|
||||||
|
required this.taskWithRoom,
|
||||||
|
required this.isOverdue,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TaskWithRoom taskWithRoom;
|
||||||
|
final bool isOverdue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CompletingTaskRow> createState() => _CompletingTaskRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompletingTaskRowState extends State<_CompletingTaskRow>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<double> _sizeAnimation;
|
||||||
|
late final Animation<Offset> _slideAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_sizeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
|
_slideAnimation = Tween<Offset>(
|
||||||
|
begin: Offset.zero,
|
||||||
|
end: const Offset(1.0, 0.0),
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizeTransition(
|
||||||
|
sizeFactor: _sizeAnimation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _slideAnimation,
|
||||||
|
child: CalendarTaskRow(
|
||||||
|
taskWithRoom: widget.taskWithRoom,
|
||||||
|
isOverdue: widget.isOverdue,
|
||||||
|
onCompleted: () {}, // Already completing — ignore repeat taps.
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,10 +57,13 @@ final calendarDayProvider =
|
|||||||
overdueTasks = const [];
|
overdueTasks = const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final totalTaskCount = await db.calendarDao.getTaskCount();
|
||||||
|
|
||||||
return CalendarDayState(
|
return CalendarDayState(
|
||||||
selectedDate: selectedDate,
|
selectedDate: selectedDate,
|
||||||
dayTasks: dayTasks,
|
dayTasks: dayTasks,
|
||||||
overdueTasks: overdueTasks,
|
overdueTasks: overdueTasks,
|
||||||
|
totalTaskCount: totalTaskCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
348
lib/features/home/presentation/calendar_strip.dart
Normal file
348
lib/features/home/presentation/calendar_strip.dart
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
|
|
||||||
|
/// Number of days in the past and future to show in the strip.
|
||||||
|
const _kPastDays = 90;
|
||||||
|
const _kFutureDays = 90;
|
||||||
|
|
||||||
|
/// Total number of day cards in the strip.
|
||||||
|
const _kTotalDays = _kPastDays + 1 + _kFutureDays;
|
||||||
|
|
||||||
|
/// Fixed card width and height for each day card.
|
||||||
|
const _kCardWidth = 56.0;
|
||||||
|
const _kCardHeight = 72.0;
|
||||||
|
|
||||||
|
/// Default horizontal margin between cards.
|
||||||
|
const _kCardMargin = 4.0;
|
||||||
|
|
||||||
|
/// Wider gap inserted at month boundaries (left side margin of the first-of-month card).
|
||||||
|
const _kMonthBoundaryGap = 16.0;
|
||||||
|
|
||||||
|
/// Controller that allows external code (e.g. the Today button) to trigger
|
||||||
|
/// a scroll-to-today animation on the strip.
|
||||||
|
class CalendarStripController {
|
||||||
|
VoidCallback? _scrollToToday;
|
||||||
|
|
||||||
|
/// Animate the strip to center today's card.
|
||||||
|
void scrollToToday() => _scrollToToday?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal scrollable strip of day cards spanning [_kPastDays] days in the
|
||||||
|
/// past and [_kFutureDays] days in the future.
|
||||||
|
///
|
||||||
|
/// Each card shows:
|
||||||
|
/// - German day abbreviation (Mo, Di, Mi, Do, Fr, Sa, So)
|
||||||
|
/// - Date number (day of month)
|
||||||
|
///
|
||||||
|
/// The selected card is highlighted and always centered.
|
||||||
|
/// Today's card uses bold text + an accent underline bar.
|
||||||
|
/// Month boundaries get a wider gap and a small month label.
|
||||||
|
class CalendarStrip extends ConsumerStatefulWidget {
|
||||||
|
const CalendarStrip({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.onTodayVisibilityChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Controller for programmatic scroll-to-today.
|
||||||
|
final CalendarStripController controller;
|
||||||
|
|
||||||
|
/// Called when today's card enters or leaves the viewport.
|
||||||
|
final ValueChanged<bool> onTodayVisibilityChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CalendarStrip> createState() => _CalendarStripState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CalendarStripState extends ConsumerState<CalendarStrip> {
|
||||||
|
late final ScrollController _scrollController;
|
||||||
|
late final DateTime _today;
|
||||||
|
late final List<DateTime> _dates;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
_today = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
|
// Build the date list: _kPastDays before today, today, _kFutureDays after.
|
||||||
|
_dates = List.generate(
|
||||||
|
_kTotalDays,
|
||||||
|
(i) => _today.subtract(Duration(days: _kPastDays - i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate initial scroll offset so today's card is centered.
|
||||||
|
_scrollController = ScrollController(
|
||||||
|
initialScrollOffset: _offsetForIndex(_kPastDays),
|
||||||
|
);
|
||||||
|
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
// Register the scroll-to-today callback on the controller.
|
||||||
|
widget.controller._scrollToToday = _animateToToday;
|
||||||
|
|
||||||
|
// After first frame, animate to center today with a short delay so the
|
||||||
|
// strip has laid out its children.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_animateToToday();
|
||||||
|
// Initial visibility check
|
||||||
|
_onScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the scroll offset that centers the card at [index].
|
||||||
|
double _offsetForIndex(int index) {
|
||||||
|
// Sum the widths of all items before [index], then subtract half the viewport
|
||||||
|
// width so the card is centered. We approximate viewport as screen width
|
||||||
|
// because we cannot access it here; we compensate in the post-frame callback.
|
||||||
|
double offset = 0;
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
offset += _itemWidth(i);
|
||||||
|
}
|
||||||
|
// Center by subtracting half the card container width (will be corrected post-frame).
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total width occupied by the item at [index], including margins
|
||||||
|
/// and any month-boundary gap on its left side.
|
||||||
|
double _itemWidth(int index) {
|
||||||
|
final date = _dates[index];
|
||||||
|
final leftMargin = _isFirstOfMonth(date) && index > 0
|
||||||
|
? _kMonthBoundaryGap
|
||||||
|
: _kCardMargin;
|
||||||
|
// Each item = leftMargin + card width + rightMargin
|
||||||
|
return leftMargin + _kCardWidth + _kCardMargin;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isFirstOfMonth(DateTime date) => date.day == 1;
|
||||||
|
|
||||||
|
void _animateToToday() {
|
||||||
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
|
final viewportWidth = _scrollController.position.viewportDimension;
|
||||||
|
double targetOffset = 0;
|
||||||
|
for (int i = 0; i < _kPastDays; i++) {
|
||||||
|
targetOffset += _itemWidth(i);
|
||||||
|
}
|
||||||
|
// Center today's card in the viewport.
|
||||||
|
targetOffset -= (viewportWidth - _kCardWidth) / 2;
|
||||||
|
targetOffset = targetOffset.clamp(
|
||||||
|
_scrollController.position.minScrollExtent,
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
_scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _animateToIndex(int index) {
|
||||||
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
|
final viewportWidth = _scrollController.position.viewportDimension;
|
||||||
|
double targetOffset = 0;
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
targetOffset += _itemWidth(i);
|
||||||
|
}
|
||||||
|
targetOffset -= (viewportWidth - _kCardWidth) / 2;
|
||||||
|
targetOffset = targetOffset.clamp(
|
||||||
|
_scrollController.position.minScrollExtent,
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
_scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
|
final viewportWidth = _scrollController.position.viewportDimension;
|
||||||
|
final scrollOffset = _scrollController.offset;
|
||||||
|
|
||||||
|
// Calculate the left edge of today's card.
|
||||||
|
double todayLeftEdge = 0;
|
||||||
|
for (int i = 0; i < _kPastDays; i++) {
|
||||||
|
todayLeftEdge += _itemWidth(i);
|
||||||
|
}
|
||||||
|
final todayRightEdge = todayLeftEdge + _kCardWidth;
|
||||||
|
|
||||||
|
// Today is visible if any part of the card is in the viewport.
|
||||||
|
final isVisible =
|
||||||
|
todayRightEdge > scrollOffset &&
|
||||||
|
todayLeftEdge < scrollOffset + viewportWidth;
|
||||||
|
|
||||||
|
widget.onTodayVisibilityChanged(isVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCardTapped(int index) {
|
||||||
|
final tappedDate = _dates[index];
|
||||||
|
ref.read(selectedDateProvider.notifier).selectDate(tappedDate);
|
||||||
|
_animateToIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final selectedDate = ref.watch(selectedDateProvider);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: _kCardHeight + 24, // extra height for month label
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: _kTotalDays,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final date = _dates[index];
|
||||||
|
final isToday = date == _today;
|
||||||
|
final isSelected = date == selectedDate;
|
||||||
|
final isFirstOfMonth = _isFirstOfMonth(date) && index > 0;
|
||||||
|
|
||||||
|
return _DayCardItem(
|
||||||
|
date: date,
|
||||||
|
isToday: isToday,
|
||||||
|
isSelected: isSelected,
|
||||||
|
isFirstOfMonth: isFirstOfMonth,
|
||||||
|
onTap: () => _onCardTapped(index),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single day card in the calendar strip, with optional month boundary label.
|
||||||
|
class _DayCardItem extends StatelessWidget {
|
||||||
|
const _DayCardItem({
|
||||||
|
required this.date,
|
||||||
|
required this.isToday,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.isFirstOfMonth,
|
||||||
|
required this.onTap,
|
||||||
|
required this.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime date;
|
||||||
|
final bool isToday;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool isFirstOfMonth;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final leftMargin = isFirstOfMonth ? _kMonthBoundaryGap : _kCardMargin;
|
||||||
|
|
||||||
|
// Card background color: selected gets full primaryContainer, others get
|
||||||
|
// a subtle tint of primaryContainer.
|
||||||
|
final bgColor = isSelected
|
||||||
|
? theme.colorScheme.primaryContainer
|
||||||
|
: theme.colorScheme.primaryContainer.withValues(alpha: 0.3);
|
||||||
|
|
||||||
|
// Border: selected card gets a primary color border.
|
||||||
|
final border = isSelected
|
||||||
|
? Border.all(color: theme.colorScheme.primary, width: 1.5)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Text weight: today uses bold.
|
||||||
|
final fontWeight = isToday ? FontWeight.bold : FontWeight.normal;
|
||||||
|
|
||||||
|
// Day abbreviation (German locale): Mo, Di, Mi, Do, Fr, Sa, So
|
||||||
|
final dayAbbr = DateFormat('E', 'de').format(date);
|
||||||
|
// Date number
|
||||||
|
final dayNum = date.day.toString();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Month label at boundary
|
||||||
|
if (isFirstOfMonth)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(left: leftMargin),
|
||||||
|
child: SizedBox(
|
||||||
|
width: _kCardWidth + _kCardMargin,
|
||||||
|
child: Text(
|
||||||
|
DateFormat('MMM', 'de').format(date),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 16), // Reserve space for month label row
|
||||||
|
|
||||||
|
// Day card
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: _kCardWidth,
|
||||||
|
height: _kCardHeight,
|
||||||
|
margin: EdgeInsets.only(left: leftMargin, right: _kCardMargin),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: border,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// German day abbreviation
|
||||||
|
Text(
|
||||||
|
dayAbbr,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Date number
|
||||||
|
Text(
|
||||||
|
dayNum,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Today accent underline bar
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (isToday)
|
||||||
|
Container(
|
||||||
|
width: 20,
|
||||||
|
height: 2,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/features/home/presentation/calendar_task_row.dart
Normal file
69
lib/features/home/presentation/calendar_task_row.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
|
||||||
|
/// Warm coral/terracotta color for overdue task name text.
|
||||||
|
const _overdueColor = Color(0xFFE07A5F);
|
||||||
|
|
||||||
|
/// A task row adapted for the calendar day list.
|
||||||
|
///
|
||||||
|
/// Shows task name, a tappable room tag (navigates to room task list),
|
||||||
|
/// and an interactive checkbox. Does NOT show a relative date — the
|
||||||
|
/// calendar strip already communicates which day is selected.
|
||||||
|
///
|
||||||
|
/// When [isOverdue] is true the task name uses coral text to visually
|
||||||
|
/// distinguish overdue carry-over from today's regular tasks.
|
||||||
|
class CalendarTaskRow extends StatelessWidget {
|
||||||
|
const CalendarTaskRow({
|
||||||
|
super.key,
|
||||||
|
required this.taskWithRoom,
|
||||||
|
required this.onCompleted,
|
||||||
|
this.isOverdue = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TaskWithRoom taskWithRoom;
|
||||||
|
|
||||||
|
/// Called when the user checks the checkbox.
|
||||||
|
final VoidCallback onCompleted;
|
||||||
|
|
||||||
|
/// When true, task name is rendered in coral color.
|
||||||
|
final bool isOverdue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final task = taskWithRoom.task;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: Checkbox(
|
||||||
|
value: false,
|
||||||
|
onChanged: (_) => onCompleted(),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
task.name,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: isOverdue ? _overdueColor : null,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: GestureDetector(
|
||||||
|
onTap: () => context.go('/rooms/${taskWithRoom.roomId}'),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
taskWithRoom.roomName,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_day_list.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_task_row.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_strip.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/progress_card.dart';
|
|
||||||
import 'package:household_keeper/features/tasks/presentation/task_providers.dart';
|
|
||||||
import 'package:household_keeper/l10n/app_localizations.dart';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Warm coral/terracotta color for overdue section header.
|
/// The app's primary screen: a horizontal calendar strip at the top with a
|
||||||
const _overdueColor = Color(0xFFE07A5F);
|
/// day task list below.
|
||||||
|
|
||||||
/// The app's primary screen: daily plan showing what's due today,
|
|
||||||
/// overdue tasks, and a preview of tomorrow.
|
|
||||||
///
|
///
|
||||||
/// Replaces the former placeholder with a full daily workflow:
|
/// Replaces the former stacked overdue/today/tomorrow daily plan layout.
|
||||||
/// see what's due, check it off, feel progress.
|
/// Users navigate by tapping day cards to see that day's tasks.
|
||||||
class HomeScreen extends ConsumerStatefulWidget {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@@ -25,365 +19,51 @@ class HomeScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||||
/// Task IDs currently animating out after completion.
|
late final CalendarStripController _stripController =
|
||||||
final Set<int> _completingTaskIds = {};
|
CalendarStripController();
|
||||||
|
|
||||||
void _onTaskCompleted(int taskId) {
|
/// Whether to show the floating "Heute" button.
|
||||||
setState(() {
|
/// True when the user has scrolled away from today's card.
|
||||||
_completingTaskIds.add(taskId);
|
bool _showTodayButton = false;
|
||||||
});
|
|
||||||
ref.read(taskActionsProvider.notifier).completeTask(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
|
||||||
final dailyPlan = ref.watch(dailyPlanProvider);
|
|
||||||
|
|
||||||
return dailyPlan.when(
|
return Stack(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (error, _) => Center(child: Text(error.toString())),
|
|
||||||
data: (state) {
|
|
||||||
// Clean up completing IDs that are no longer in the data
|
|
||||||
_completingTaskIds.removeWhere((id) =>
|
|
||||||
!state.overdueTasks.any((t) => t.task.id == id) &&
|
|
||||||
!state.todayTasks.any((t) => t.task.id == id));
|
|
||||||
|
|
||||||
return _buildDailyPlan(context, state, l10n, theme);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDailyPlan(
|
|
||||||
BuildContext context,
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
// Case a: No tasks at all (user hasn't created any rooms/tasks)
|
|
||||||
if (state.totalTodayCount == 0 &&
|
|
||||||
state.tomorrowTasks.isEmpty &&
|
|
||||||
state.completedTodayCount == 0) {
|
|
||||||
return _buildNoTasksState(l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case b: All clear -- there WERE tasks today but all are done
|
|
||||||
if (state.overdueTasks.isEmpty &&
|
|
||||||
state.todayTasks.isEmpty &&
|
|
||||||
state.completedTodayCount > 0 &&
|
|
||||||
state.tomorrowTasks.isEmpty) {
|
|
||||||
return _buildAllClearState(state, l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case c: Nothing today, but stuff tomorrow -- show celebration + tomorrow
|
|
||||||
if (state.overdueTasks.isEmpty &&
|
|
||||||
state.todayTasks.isEmpty &&
|
|
||||||
state.completedTodayCount == 0 &&
|
|
||||||
state.tomorrowTasks.isNotEmpty) {
|
|
||||||
return _buildAllClearWithTomorrow(state, l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case b extended: all clear with tomorrow tasks
|
|
||||||
if (state.overdueTasks.isEmpty &&
|
|
||||||
state.todayTasks.isEmpty &&
|
|
||||||
state.completedTodayCount > 0 &&
|
|
||||||
state.tomorrowTasks.isNotEmpty) {
|
|
||||||
return _buildAllClearWithTomorrow(state, l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case d: Normal state -- tasks exist
|
|
||||||
return _buildNormalState(state, l10n, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No tasks at all -- first-run empty state.
|
|
||||||
Widget _buildNoTasksState(AppLocalizations l10n, ThemeData theme) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.checklist_rounded,
|
|
||||||
size: 80,
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
l10n.dailyPlanNoTasks,
|
|
||||||
style: theme.textTheme.headlineSmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
l10n.homeEmptyMessage,
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
FilledButton.tonal(
|
|
||||||
onPressed: () => context.go('/rooms'),
|
|
||||||
child: Text(l10n.homeEmptyAction),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All tasks done, no tomorrow tasks -- celebration state.
|
|
||||||
Widget _buildAllClearState(
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ProgressCard(
|
|
||||||
completed: state.completedTodayCount,
|
|
||||||
total: state.totalTodayCount,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Icon(
|
|
||||||
Icons.celebration_outlined,
|
|
||||||
size: 80,
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
l10n.dailyPlanAllClearTitle,
|
|
||||||
style: theme.textTheme.headlineSmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
l10n.dailyPlanAllClearMessage,
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All clear for today but tomorrow tasks exist.
|
|
||||||
Widget _buildAllClearWithTomorrow(
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
return ListView(
|
|
||||||
children: [
|
children: [
|
||||||
ProgressCard(
|
Column(
|
||||||
completed: state.completedTodayCount,
|
children: [
|
||||||
total: state.totalTodayCount,
|
CalendarStrip(
|
||||||
|
controller: _stripController,
|
||||||
|
onTodayVisibilityChanged: (visible) {
|
||||||
|
setState(() => _showTodayButton = !visible);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Expanded(child: CalendarDayList()),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
if (_showTodayButton)
|
||||||
Center(
|
Positioned(
|
||||||
child: Column(
|
bottom: 16,
|
||||||
children: [
|
left: 0,
|
||||||
Icon(
|
right: 0,
|
||||||
Icons.celebration_outlined,
|
child: Center(
|
||||||
size: 80,
|
child: FloatingActionButton.extended(
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
onPressed: () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
ref
|
||||||
|
.read(selectedDateProvider.notifier)
|
||||||
|
.selectDate(today);
|
||||||
|
_stripController.scrollToToday();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.today),
|
||||||
|
label: Text(l10n.calendarTodayButton),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
Text(
|
|
||||||
l10n.dailyPlanAllClearTitle,
|
|
||||||
style: theme.textTheme.headlineSmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
l10n.dailyPlanAllClearMessage,
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildTomorrowSection(state, l10n, theme),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normal state with overdue/today/tomorrow sections.
|
|
||||||
Widget _buildNormalState(
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
return ListView(
|
|
||||||
children: [
|
|
||||||
ProgressCard(
|
|
||||||
completed: state.completedTodayCount,
|
|
||||||
total: state.totalTodayCount,
|
|
||||||
),
|
|
||||||
// Overdue section (conditional)
|
|
||||||
if (state.overdueTasks.isNotEmpty) ...[
|
|
||||||
_buildSectionHeader(
|
|
||||||
l10n.dailyPlanSectionOverdue,
|
|
||||||
theme,
|
|
||||||
color: _overdueColor,
|
|
||||||
),
|
|
||||||
...state.overdueTasks.map(
|
|
||||||
(tw) => _buildAnimatedTaskRow(tw, showCheckbox: true),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// Today section
|
|
||||||
_buildSectionHeader(
|
|
||||||
l10n.dailyPlanSectionToday,
|
|
||||||
theme,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
if (state.todayTasks.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Text(
|
|
||||||
l10n.dailyPlanAllClearMessage,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
...state.todayTasks.map(
|
|
||||||
(tw) => _buildAnimatedTaskRow(tw, showCheckbox: true),
|
|
||||||
),
|
|
||||||
// Tomorrow section (conditional, collapsed)
|
|
||||||
if (state.tomorrowTasks.isNotEmpty)
|
|
||||||
_buildTomorrowSection(state, l10n, theme),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSectionHeader(
|
|
||||||
String title,
|
|
||||||
ThemeData theme, {
|
|
||||||
required Color color,
|
|
||||||
}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAnimatedTaskRow(
|
|
||||||
TaskWithRoom tw, {
|
|
||||||
required bool showCheckbox,
|
|
||||||
}) {
|
|
||||||
final isCompleting = _completingTaskIds.contains(tw.task.id);
|
|
||||||
|
|
||||||
if (isCompleting) {
|
|
||||||
return _CompletingTaskRow(
|
|
||||||
key: ValueKey('completing-${tw.task.id}'),
|
|
||||||
taskWithRoom: tw,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DailyPlanTaskRow(
|
|
||||||
key: ValueKey('task-${tw.task.id}'),
|
|
||||||
taskWithRoom: tw,
|
|
||||||
showCheckbox: showCheckbox,
|
|
||||||
onCompleted: () => _onTaskCompleted(tw.task.id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTomorrowSection(
|
|
||||||
DailyPlanState state,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
return ExpansionTile(
|
|
||||||
initiallyExpanded: false,
|
|
||||||
title: Text(
|
|
||||||
l10n.dailyPlanUpcomingCount(state.tomorrowTasks.length),
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
children: state.tomorrowTasks
|
|
||||||
.map(
|
|
||||||
(tw) => DailyPlanTaskRow(
|
|
||||||
key: ValueKey('tomorrow-${tw.task.id}'),
|
|
||||||
taskWithRoom: tw,
|
|
||||||
showCheckbox: false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A task row that animates to zero height on completion.
|
|
||||||
class _CompletingTaskRow extends StatefulWidget {
|
|
||||||
const _CompletingTaskRow({
|
|
||||||
super.key,
|
|
||||||
required this.taskWithRoom,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TaskWithRoom taskWithRoom;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_CompletingTaskRow> createState() => _CompletingTaskRowState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CompletingTaskRowState extends State<_CompletingTaskRow>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final AnimationController _controller;
|
|
||||||
late final Animation<double> _sizeAnimation;
|
|
||||||
late final Animation<Offset> _slideAnimation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
_sizeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
|
||||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
|
||||||
);
|
|
||||||
_slideAnimation = Tween<Offset>(
|
|
||||||
begin: Offset.zero,
|
|
||||||
end: const Offset(1.0, 0.0),
|
|
||||||
).animate(
|
|
||||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
|
||||||
);
|
|
||||||
_controller.forward();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SizeTransition(
|
|
||||||
sizeFactor: _sizeAnimation,
|
|
||||||
child: SlideTransition(
|
|
||||||
position: _slideAnimation,
|
|
||||||
child: DailyPlanTaskRow(
|
|
||||||
taskWithRoom: widget.taskWithRoom,
|
|
||||||
showCheckbox: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import 'package:household_keeper/core/database/database.dart';
|
import 'package:household_keeper/core/database/database.dart';
|
||||||
import 'package:household_keeper/core/router/router.dart';
|
import 'package:household_keeper/core/router/router.dart';
|
||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
||||||
import 'package:household_keeper/features/tasks/domain/effort_level.dart';
|
import 'package:household_keeper/features/tasks/domain/effort_level.dart';
|
||||||
import 'package:household_keeper/features/tasks/domain/frequency.dart';
|
import 'package:household_keeper/features/tasks/domain/frequency.dart';
|
||||||
import 'package:household_keeper/l10n/app_localizations.dart';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
|
||||||
/// Helper to create a test [Task] with sensible defaults.
|
/// Helper to create a test [Task] with sensible defaults.
|
||||||
Task _makeTask({
|
Task _makeTask({
|
||||||
@@ -51,15 +52,16 @@ TaskWithRoom _makeTaskWithRoom({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the app with dailyPlanProvider overridden to the given state.
|
/// Build the app with calendarDayProvider overridden to the given state.
|
||||||
///
|
///
|
||||||
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
||||||
/// the riverpod_lint scoped_providers_should_specify_dependencies warning.
|
/// the riverpod_lint scoped_providers_should_specify_dependencies warning.
|
||||||
Widget _buildApp(DailyPlanState planState) {
|
Widget _buildApp(CalendarDayState dayState) {
|
||||||
final container = ProviderContainer(overrides: [
|
final container = ProviderContainer(overrides: [
|
||||||
dailyPlanProvider.overrideWith(
|
calendarDayProvider.overrideWith(
|
||||||
(ref) => Stream.value(planState),
|
(ref) => Stream.value(dayState),
|
||||||
),
|
),
|
||||||
|
selectedDateProvider.overrideWith(SelectedDateNotifier.new),
|
||||||
roomWithStatsListProvider.overrideWith(
|
roomWithStatsListProvider.overrideWith(
|
||||||
(ref) => Stream.value([]),
|
(ref) => Stream.value([]),
|
||||||
),
|
),
|
||||||
@@ -81,17 +83,21 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final yesterday = today.subtract(const Duration(days: 1));
|
||||||
|
|
||||||
group('HomeScreen empty states', () {
|
group('HomeScreen empty states', () {
|
||||||
testWidgets('shows no-tasks empty state when no tasks exist at all',
|
testWidgets('shows no-tasks empty state when no tasks exist at all',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(_buildApp(const DailyPlanState(
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
overdueTasks: [],
|
selectedDate: today,
|
||||||
todayTasks: [],
|
dayTasks: const [],
|
||||||
tomorrowTasks: [],
|
overdueTasks: const [],
|
||||||
completedTodayCount: 0,
|
totalTaskCount: 0,
|
||||||
totalTodayCount: 0,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show "Noch keine Aufgaben angelegt" (dailyPlanNoTasks)
|
// Should show "Noch keine Aufgaben angelegt" (dailyPlanNoTasks)
|
||||||
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
||||||
@@ -99,58 +105,53 @@ void main() {
|
|||||||
expect(find.text('Raum erstellen'), findsOneWidget);
|
expect(find.text('Raum erstellen'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows all-clear state when all tasks are done',
|
testWidgets('shows celebration state when tasks exist but today is clear',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(_buildApp(const DailyPlanState(
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
overdueTasks: [],
|
selectedDate: today,
|
||||||
todayTasks: [],
|
dayTasks: const [],
|
||||||
tomorrowTasks: [],
|
overdueTasks: const [],
|
||||||
completedTodayCount: 3,
|
totalTaskCount: 5, // tasks exist elsewhere
|
||||||
totalTodayCount: 3,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show celebration empty state
|
// Should show celebration state
|
||||||
expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget);
|
|
||||||
expect(find.byIcon(Icons.celebration_outlined), findsOneWidget);
|
expect(find.byIcon(Icons.celebration_outlined), findsOneWidget);
|
||||||
// Progress card should show 3/3
|
expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget);
|
||||||
expect(find.text('3 von 3 erledigt'), findsOneWidget);
|
});
|
||||||
|
|
||||||
|
testWidgets('shows empty-day state for non-today date with no tasks',
|
||||||
|
(tester) async {
|
||||||
|
final tomorrow = today.add(const Duration(days: 1));
|
||||||
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
|
selectedDate: tomorrow,
|
||||||
|
dayTasks: const [],
|
||||||
|
overdueTasks: const [],
|
||||||
|
totalTaskCount: 5, // tasks exist on other days
|
||||||
|
)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Should show "Keine Aufgaben" (not celebration — not today)
|
||||||
|
expect(find.text('Keine Aufgaben'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.event_available), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('HomeScreen normal state', () {
|
group('HomeScreen normal state', () {
|
||||||
testWidgets('shows progress card with correct counts', (tester) async {
|
testWidgets('shows overdue section when overdue tasks exist (today)',
|
||||||
final now = DateTime.now();
|
(tester) async {
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
|
selectedDate: today,
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
dayTasks: [
|
||||||
overdueTasks: [],
|
|
||||||
todayTasks: [
|
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 2,
|
||||||
taskName: 'Staubsaugen',
|
taskName: 'Staubsaugen',
|
||||||
roomName: 'Wohnzimmer',
|
roomName: 'Wohnzimmer',
|
||||||
nextDueDate: today,
|
nextDueDate: today,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
tomorrowTasks: [],
|
|
||||||
completedTodayCount: 2,
|
|
||||||
totalTodayCount: 3,
|
|
||||||
)));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Progress card should show 2/3
|
|
||||||
expect(find.text('2 von 3 erledigt'), findsOneWidget);
|
|
||||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows overdue section when overdue tasks exist',
|
|
||||||
(tester) async {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
|
||||||
final yesterday = today.subtract(const Duration(days: 1));
|
|
||||||
|
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
|
||||||
overdueTasks: [
|
overdueTasks: [
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -159,24 +160,13 @@ void main() {
|
|||||||
nextDueDate: yesterday,
|
nextDueDate: yesterday,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
todayTasks: [
|
totalTaskCount: 2,
|
||||||
_makeTaskWithRoom(
|
|
||||||
id: 2,
|
|
||||||
taskName: 'Staubsaugen',
|
|
||||||
roomName: 'Wohnzimmer',
|
|
||||||
nextDueDate: today,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
tomorrowTasks: [],
|
|
||||||
completedTodayCount: 0,
|
|
||||||
totalTodayCount: 2,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show overdue section header
|
// Should show overdue section header
|
||||||
expect(find.text('\u00dcberf\u00e4llig'), findsOneWidget);
|
expect(find.text('\u00dcberf\u00e4llig'), findsOneWidget);
|
||||||
// Should show today section header (may also appear as relative date)
|
|
||||||
expect(find.text('Heute'), findsAtLeast(1));
|
|
||||||
// Should show both tasks
|
// Should show both tasks
|
||||||
expect(find.text('Boden wischen'), findsOneWidget);
|
expect(find.text('Boden wischen'), findsOneWidget);
|
||||||
expect(find.text('Staubsaugen'), findsOneWidget);
|
expect(find.text('Staubsaugen'), findsOneWidget);
|
||||||
@@ -185,54 +175,37 @@ void main() {
|
|||||||
expect(find.text('Wohnzimmer'), findsOneWidget);
|
expect(find.text('Wohnzimmer'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows collapsed tomorrow section with count',
|
testWidgets('does not show overdue section for non-today date',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final now = DateTime.now();
|
// On a future date, overdueTasks will be empty (calendarDayProvider
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
// only populates overdueTasks when isToday).
|
||||||
final tomorrow = today.add(const Duration(days: 1));
|
final tomorrow = today.add(const Duration(days: 1));
|
||||||
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
selectedDate: tomorrow,
|
||||||
overdueTasks: [],
|
dayTasks: [
|
||||||
todayTasks: [
|
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 1,
|
||||||
taskName: 'Staubsaugen',
|
taskName: 'Staubsaugen',
|
||||||
roomName: 'Wohnzimmer',
|
roomName: 'Wohnzimmer',
|
||||||
nextDueDate: today,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
tomorrowTasks: [
|
|
||||||
_makeTaskWithRoom(
|
|
||||||
id: 2,
|
|
||||||
taskName: 'Fenster putzen',
|
|
||||||
roomName: 'Schlafzimmer',
|
|
||||||
nextDueDate: tomorrow,
|
|
||||||
),
|
|
||||||
_makeTaskWithRoom(
|
|
||||||
id: 3,
|
|
||||||
taskName: 'Bett beziehen',
|
|
||||||
roomName: 'Schlafzimmer',
|
|
||||||
nextDueDate: tomorrow,
|
nextDueDate: tomorrow,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
completedTodayCount: 0,
|
overdueTasks: const [], // No overdue for non-today
|
||||||
totalTodayCount: 1,
|
totalTaskCount: 1,
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Should show collapsed tomorrow section with count
|
// Should NOT show overdue section header
|
||||||
expect(find.text('Demn\u00e4chst (2)'), findsOneWidget);
|
expect(find.text('\u00dcberf\u00e4llig'), findsNothing);
|
||||||
// Tomorrow tasks should NOT be visible (collapsed by default)
|
// Should show day task
|
||||||
expect(find.text('Fenster putzen'), findsNothing);
|
expect(find.text('Staubsaugen'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('today tasks have checkboxes', (tester) async {
|
testWidgets('tasks have checkboxes', (tester) async {
|
||||||
final now = DateTime.now();
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
selectedDate: today,
|
||||||
|
dayTasks: [
|
||||||
await tester.pumpWidget(_buildApp(DailyPlanState(
|
|
||||||
overdueTasks: [],
|
|
||||||
todayTasks: [
|
|
||||||
_makeTaskWithRoom(
|
_makeTaskWithRoom(
|
||||||
id: 1,
|
id: 1,
|
||||||
taskName: 'Staubsaugen',
|
taskName: 'Staubsaugen',
|
||||||
@@ -240,14 +213,29 @@ void main() {
|
|||||||
nextDueDate: today,
|
nextDueDate: today,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
tomorrowTasks: [],
|
overdueTasks: const [],
|
||||||
completedTodayCount: 0,
|
totalTaskCount: 1,
|
||||||
totalTodayCount: 1,
|
|
||||||
)));
|
)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Today task should have a checkbox
|
// Task should have a checkbox
|
||||||
expect(find.byType(Checkbox), findsOneWidget);
|
expect(find.byType(Checkbox), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('calendar strip is shown', (tester) async {
|
||||||
|
await tester.pumpWidget(_buildApp(CalendarDayState(
|
||||||
|
selectedDate: today,
|
||||||
|
dayTasks: const [],
|
||||||
|
overdueTasks: const [],
|
||||||
|
totalTaskCount: 0,
|
||||||
|
)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// The strip is a horizontal ListView — verify it exists by finding
|
||||||
|
// ListView widgets (strip + potentially the task list).
|
||||||
|
expect(find.byType(ListView), findsWidgets);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:household_keeper/core/router/router.dart';
|
import 'package:household_keeper/core/router/router.dart';
|
||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
||||||
import 'package:household_keeper/features/home/presentation/daily_plan_providers.dart';
|
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
|
||||||
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
import 'package:household_keeper/features/rooms/presentation/room_providers.dart';
|
||||||
import 'package:household_keeper/l10n/app_localizations.dart';
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -15,6 +15,9 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
/// Helper to build the app with providers overridden for testing.
|
/// Helper to build the app with providers overridden for testing.
|
||||||
///
|
///
|
||||||
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid
|
||||||
@@ -26,15 +29,16 @@ void main() {
|
|||||||
roomWithStatsListProvider.overrideWith(
|
roomWithStatsListProvider.overrideWith(
|
||||||
(ref) => Stream.value([]),
|
(ref) => Stream.value([]),
|
||||||
),
|
),
|
||||||
// Override daily plan to return empty state so HomeScreen
|
// Override selected date to avoid any DB access.
|
||||||
// renders without a database.
|
selectedDateProvider.overrideWith(SelectedDateNotifier.new),
|
||||||
dailyPlanProvider.overrideWith(
|
// Override calendar day provider to return empty first-run state so
|
||||||
(ref) => Stream.value(const DailyPlanState(
|
// HomeScreen renders without a database.
|
||||||
overdueTasks: [],
|
calendarDayProvider.overrideWith(
|
||||||
todayTasks: [],
|
(ref) => Stream.value(CalendarDayState(
|
||||||
tomorrowTasks: [],
|
selectedDate: today,
|
||||||
completedTodayCount: 0,
|
dayTasks: const [],
|
||||||
totalTodayCount: 0,
|
overdueTasks: const [],
|
||||||
|
totalTaskCount: 0,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -53,7 +57,8 @@ void main() {
|
|||||||
testWidgets('renders 3 navigation destinations with correct German labels',
|
testWidgets('renders 3 navigation destinations with correct German labels',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(buildApp());
|
await tester.pumpWidget(buildApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Verify 3 NavigationDestination widgets are rendered
|
// Verify 3 NavigationDestination widgets are rendered
|
||||||
expect(find.byType(NavigationDestination), findsNWidgets(3));
|
expect(find.byType(NavigationDestination), findsNWidgets(3));
|
||||||
@@ -67,22 +72,24 @@ void main() {
|
|||||||
testWidgets('tapping a destination changes the selected tab',
|
testWidgets('tapping a destination changes the selected tab',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(buildApp());
|
await tester.pumpWidget(buildApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Initially on Home tab (index 0) -- verify home empty state is shown
|
// Initially on Home tab (index 0) -- verify home first-run empty state
|
||||||
// (dailyPlanNoTasks text from the daily plan empty state)
|
|
||||||
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget);
|
||||||
|
|
||||||
// Tap the Rooms tab (second destination)
|
// Tap the Rooms tab (second destination)
|
||||||
await tester.tap(find.text('R\u00e4ume'));
|
await tester.tap(find.text('R\u00e4ume'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Verify we see Rooms content now (empty state)
|
// Verify we see Rooms content now (empty state)
|
||||||
expect(find.text('Hier ist noch alles leer!'), findsOneWidget);
|
expect(find.text('Hier ist noch alles leer!'), findsOneWidget);
|
||||||
|
|
||||||
// Tap the Settings tab (third destination)
|
// Tap the Settings tab (third destination)
|
||||||
await tester.tap(find.text('Einstellungen'));
|
await tester.tap(find.text('Einstellungen'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Verify we see Settings content now
|
// Verify we see Settings content now
|
||||||
expect(find.text('Darstellung'), findsOneWidget);
|
expect(find.text('Darstellung'), findsOneWidget);
|
||||||
|
|||||||
Reference in New Issue
Block a user