docs: complete project research

This commit is contained in:
2026-03-15 18:37:00 +01:00
parent 1a4223aff5
commit aa4c2ab817
5 changed files with 1287 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
# Pitfalls Research
**Domain:** Local-first Flutter household chore management app (Android, Riverpod + Drift)
**Researched:** 2026-03-15
**Confidence:** HIGH (Riverpod/Drift specifics), MEDIUM (scheduling edge cases), HIGH (Android notification permissions)
---
## Critical Pitfalls
### Pitfall 1: Drift Schema Changes Without Incrementing `schemaVersion`
**What goes wrong:**
A developer modifies a table definition (adds a column, renames a field, adds a new table) but forgets to increment `schemaVersion` and write a corresponding `onUpgrade` migration. On fresh installs, `onCreate` runs cleanly and everything works. On existing installs, the app opens against the old schema — queries silently return wrong data or crash with column-not-found errors. This is especially dangerous because it only surfaces after shipping an update to a real device.
**Why it happens:**
Drift's code generation regenerates Dart classes correctly based on the new table definition, so the app compiles and runs fine in development (developer always uses a fresh database). The migration gap is invisible until the app is installed over an older version.
**How to avoid:**
- Before making any schema change, run `dart run drift_dev make-migrations` to snapshot the current schema. Drift generates a migration test file you can run to verify correctness.
- Treat `schemaVersion` as a checklist item: every PR that touches a table definition must bump the version and add a `stepByStep` migration block.
- Use Drift's `stepByStep` API rather than a raw `if (from < N)` block — it generates per-step migration scaffolding that reduces errors.
- Test migrations with `dart run drift_dev schema verify` against the generated schema snapshots.
**Warning signs:**
- A table definition file changed in a commit but `schemaVersion` did not change.
- Tests pass on CI (fresh DB) but crash reports appear from field users after an update.
- `SqliteException: no such column: X` errors in crash logs.
**Phase to address:** Foundation phase (database setup). Establish the `make-migrations` workflow before writing the first table.
---
### Pitfall 2: "Next Due Date" Calculated from Wall-Clock `DateTime.now()` Without Timezone Anchoring
**What goes wrong:**
Recurring tasks use an interval (e.g., "every 7 days"). When a task is marked complete, the next due date is computed as `completionTime + Duration(days: interval)`. Because `DateTime.now()` returns UTC or local time depending on context, and because the notion of "today" is timezone-sensitive, two bugs emerge:
1. **Completion near midnight:** A task completed at 11:58 PM lands on a different calendar day than one completed at 12:02 AM. The next due date shifts by one day unpredictably.
2. **"Every N days" vs. "after completion":** Without a clear policy, the schedule drifts — a weekly vacuum scheduled for Monday slowly becomes a Wednesday vacuum because the user always completes it a day late.
**Why it happens:**
Developers store `DateTime` values directly from `DateTime.now()` without thinking about whether "due today" means "due before end of local calendar day" or "due within 24 hours." SQLite stores timestamps as integers (Unix epoch) — no timezone metadata is preserved.
**How to avoid:**
- Store all due dates as `Date` (calendar day only — year/month/day), not `DateTime`. For a chore app, a task is due "on a day," not "at a specific second."
- Use `DateTime.now().toLocal()` explicitly and strip time components when computing next due dates: `DateTime(now.year, now.month, now.day + interval)`.
- Define a policy at project start: "every N days from last completion date" (rolling) vs. "every N days from original start date" (fixed anchor). HouseHoldKeaper's core value — "trust the app to schedule the next occurrence" — implies rolling is correct, but document this explicitly.
- Store `lastCompletedDate` as a calendar date, not a timestamp, so the next due date calculation is always date arithmetic, never time arithmetic.
**Warning signs:**
- Due dates drifting week-by-week when a user is consistently slightly early or late on completions.
- "Overdue" tasks appearing that were completed the previous evening.
- Unit tests for due date logic only testing noon completions, never midnight edge cases.
**Phase to address:** Core task/scheduling phase. Write unit tests for due date calculation before wiring it to the UI.
---
### Pitfall 3: Android Notification Permissions Not Requested at Runtime (API 33+)
**What goes wrong:**
The app targets Android 13+ (API 33). `POST_NOTIFICATIONS` is a runtime permission — notifications are **off by default** for new installs. The app ships, the daily summary notification never fires, and users see nothing without knowing why. Additionally, `SCHEDULE_EXACT_ALARM` is denied by default on API 33+, so scheduled notifications (even if the permission is declared in the manifest) silently fail to fire.
**Why it happens:**
Developers coming from pre-API 33 experience assume notification permission is implicitly granted. The manifest declaration is necessary but not sufficient — a runtime `requestPermission()` call is required before `POST_NOTIFICATIONS` works on API 33+.
**How to avoid:**
- Declare `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, and `RECEIVE_BOOT_COMPLETED` in `AndroidManifest.xml`.
- Use `flutter_local_notifications`'s `.requestNotificationsPermission()` method on Android at an appropriate moment (first launch or when the user enables notifications in settings).
- Always call `canScheduleExactAlarms()` before scheduling; if denied, guide the user to Settings → Special app access → Alarms.
- Use `AndroidScheduleMode.exactAllowWhileIdle` for all scheduled notifications to ensure delivery during Doze mode.
- Call `tz.initializeTimeZones()` at app startup — missing this is a silent bug that causes scheduled notification times to be wrong.
- Register `ScheduledNotificationBootReceiver` in the manifest to reschedule notifications after device reboot.
**Warning signs:**
- Notification permission prompt never appears during testing on a real API 33 device.
- Scheduled test notifications arrive correctly on emulator (which has fewer battery restrictions) but not on a physical device.
- `flutter_local_notifications` returns success from the schedule call but no notification fires.
**Phase to address:** Notifications phase. Test on a physical API 33+ Android device, not just the emulator, before marking notifications done.
---
### Pitfall 4: Riverpod `ref.watch` / `ref.listen` Used Outside `build`
**What goes wrong:**
Calling `ref.watch(someProvider)` inside `initState`, a button callback, or an async function causes either a runtime exception ("Cannot call watch outside a build-scope") or silent stale reads (the widget never rebuilds when the watched provider changes). Similarly, calling `ref.listen` inside `initState` instead of using `ref.listenManual` results in the listener being lost after the first rebuild.
**Why it happens:**
The distinction between `ref.watch` (for reactive rebuilds inside `build`) and `ref.read` (for one-time reads in callbacks) is non-obvious to Riverpod newcomers. The PROJECT.md acknowledges the developer is new to Drift — this likely extends to Riverpod patterns as well.
**How to avoid:**
- Rule of thumb: `ref.watch` **only** inside `build()`. `ref.read` in callbacks, `initState`, event handlers. `ref.listen` in `build()` for side effects (showing SnackBars). `ref.listenManual` in `initState` or outside build.
- Enable `riverpod_lint` rules — they catch `ref.watch` outside `build` at analysis time, before runtime.
- Prefer `@riverpod` code generation (riverpod_generator) over manually constructing providers — the annotation API reduces the surface area for these misuse patterns.
- Do not call `ref.read(autoDisposeProvider)` in `initState` to "warm up" a provider — the auto-dispose mechanism may discard the state before any widget watches it.
**Warning signs:**
- "Cannot use ref functions after the widget was disposed" exceptions in logs.
- UI not updating when underlying provider state changes, even though data changes are confirmed in the database.
- `ConsumerStatefulWidget` screens that call `ref.watch` in `initState`.
**Phase to address:** Foundation phase (project structure and state management setup). Establish linting rules before writing feature code.
---
### Pitfall 5: `autoDispose` Providers Losing State on Screen Navigation
**What goes wrong:**
Riverpod's code-generation (`@riverpod`) enables `autoDispose` by default. When a user navigates away from the task list and returns, the provider state is discarded and rebuilt from scratch — triggering a full database reload, causing visible loading flicker, and losing any in-flight UI state (e.g., a sort selection, scroll position). For a household app used daily, this is a recurring annoyance.
**Why it happens:**
`autoDispose` disposes provider state when no listeners remain (i.e., when the widget leaves the tree). Developers using code generation often don't realize `autoDispose` is the default and that persistent UI state requires opt-in `keepAlive`.
**How to avoid:**
- Use `@Riverpod(keepAlive: true)` for long-lived app-wide state: room list, task list, daily plan view. These should persist for the entire app session.
- Use default `autoDispose` only for transient UI state that should be discarded (e.g., form state for a "new task" dialog).
- For async providers that load database content, use `ref.keepAlive()` after the first successful load to prevent re-querying on every navigation.
- Drift's `.watch()` streams already provide reactive updates — no need to aggressively dispose and recreate the Riverpod layer; let Drift push updates.
**Warning signs:**
- Noticeable loading flash every time the user returns to the main screen.
- Database queries firing more often than expected (visible in logs).
- Form state (partially filled "edit task" screen) resetting if the user briefly leaves.
**Phase to address:** Core UI phase. Define provider lifetime policy at architecture setup before building feature providers.
---
## Technical Debt Patterns
Shortcuts that seem reasonable but create long-term problems.
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Storing due dates as `DateTime` (with time) instead of `Date` (date only) | Less refactoring of existing patterns | Midnight edge cases, overdue logic bugs, timezone drift | Never — use date-only from day one |
| Skipping Drift `make-migrations` workflow for early schemas | Faster iteration in early development | First schema change on a shipped app causes data loss | Only acceptable before first public release; must adopt before any beta |
| Hardcoding color values instead of using `ColorScheme.fromSeed()` | Faster during prototyping | Broken dark mode, inconsistent tonal palette, future redesign pain | Prototyping only; replace before first feature-complete build |
| Using `ref.read` everywhere instead of `ref.watch` | No accidental rebuilds | UI never updates reactively; state bugs that look like data bugs | Never in production widgets |
| Scheduling a notification for every future recurrence at task creation | Simpler code path | Hits Samsung's 500-alarm limit; stale notifications after task edits; manifest bloat | Never — schedule only the next occurrence |
| Skipping `riverpod_lint` to avoid setup overhead | Faster start | Silent provider lifecycle bugs accumulate; harder to catch without the linter | Never |
---
## Integration Gotchas
Common mistakes when integrating the core libraries.
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| `flutter_local_notifications` + Android 13 | Declaring permission in manifest only | Also call `requestNotificationsPermission()` at runtime; check `canScheduleExactAlarms()` |
| `flutter_local_notifications` + scheduling | Using `DateTime.now()` without timezone init | Call `tz.initializeTimeZones()` at startup; use `TZDateTime` for all scheduled times |
| `flutter_local_notifications` + reboot | Not registering `ScheduledNotificationBootReceiver` | Register receiver in `AndroidManifest.xml`; reschedule on boot |
| Drift + `build_runner` | Running `build_runner build` once then forgetting | Use `build_runner watch` during development; always re-run after table changes |
| Drift + migrations | Adding columns without `addColumn()` migration | Always pair `schemaVersion` bump with explicit migration step; use `stepByStep` API |
| Riverpod + Drift | Watching a Drift `.watch()` stream inside a Riverpod `StreamProvider` | This is correct — but ensure the `StreamProvider` has `keepAlive: true` to avoid stream teardown on navigation |
| Riverpod + `@riverpod` codegen | Assuming `@riverpod` is equivalent to `@Riverpod(keepAlive: false)` | It is — explicitly add `keepAlive: true` for providers that must survive navigation |
---
## Performance Traps
Patterns that work at small scale but cause UX issues as data grows.
| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| Loading all tasks into memory, then filtering in Dart | Fine with 20 tasks, slow with 200 | Push filter/sort logic into Drift SQL queries with `.where()` and `orderBy` | Around 100-200 tasks when filtering involves date math |
| Rebuilding the entire task list widget on any state change | Imperceptible with 10 items, janky with 50+ | Use `select` on providers to narrow rebuilds; use `ListView.builder` (never `Column` with mapped list) | 30-50 tasks in a scrollable list |
| Storing room photos as raw bytes in SQLite | Works for 1-2 photos, degrades quickly | Store photos as file paths in the document directory; keep only the path in the database | After 3-4 high-res room photos |
| Scheduling a daily notification with an exact `TZDateTime` far into the future | Works initially; notification becomes stale after task edits or completions | Schedule only the next occurrence; reschedule in the task completion handler | After the first task edit that changes the due date |
---
## UX Pitfalls
Common UX mistakes specific to recurring task / chore management apps.
| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| No distinction between "due today" and "overdue" in the daily plan | User cannot triage quickly — everything looks equally urgent | Separate sections: Overdue (red tint), Due Today (normal), Upcoming (muted). PROJECT.md already specifies this — do not flatten it during implementation. |
| Marking a task complete immediately removes it from today's view | User cannot see what they've done today — feels like the work disappeared | Show completed tasks in a "Done today" collapsed section or with a strikethrough for the session |
| Cleanliness indicator updating in real time during rapid completions | Flickering percentage feels wrong | Debounce indicator updates or recalculate only on screen focus, not per-completion |
| Scheduling next due date from the moment the completion button is tapped | A task completed late at 11 PM schedules the next occurrence for 11 PM in N days — misaligned with calendar days | Compute next due date from the calendar date of completion, not the timestamp |
| Notification fires while the user has the app open | Duplicate information; notification feels spammy | Cancel or suppress the daily summary notification if the app is in the foreground at scheduled time |
| German-only strings hardcoded as literals scattered throughout widget code | Impossible to add English localization later without full-codebase surgery | Use Flutter's `l10n` infrastructure (`AppLocalizations`) from the start, even with only one locale — adding a second locale later is then trivial |
---
## "Looks Done But Isn't" Checklist
Things that appear complete but are missing critical pieces.
- [ ] **Notifications:** Tested on a real Android 13+ device (not emulator) with a fresh app install — permission prompt appears, notification fires at scheduled time.
- [ ] **Notifications after reboot:** Device rebooted after scheduling a notification — notification still fires at the correct time.
- [ ] **Drift migrations:** App installed over an older APK (not fresh install) — data survives, no crash, no missing columns.
- [ ] **Due date calculation:** Unit tests cover completion at 11:58 PM, 12:02 AM, and on the last day of a short month (e.g., Feb 28/29).
- [ ] **Overdue logic:** Tasks not completed for multiple intervals show as overdue for the correct number of days, not just "1 day overdue."
- [ ] **Cleanliness indicator:** Rooms with no tasks do not divide by zero.
- [ ] **Task deletion:** Deleting a task cancels its scheduled notification — no orphaned alarms in the AlarmManager queue.
- [ ] **Localization:** All user-visible strings come from `AppLocalizations`, not hardcoded German literals.
- [ ] **Dark mode:** Every screen renders correctly in both light and dark theme — test on device, not just the Flutter preview.
- [ ] **Task with no recurrence interval:** One-time tasks (if supported) do not trigger infinite "overdue" state after completion.
---
## Recovery Strategies
When pitfalls occur despite prevention, how to recover.
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| Shipped without migration; users lost data | HIGH | Ship a hotfix that detects the broken schema version, performs a safe re-creation with sane defaults, and notifies the user their task history was reset |
| Due dates drifted due to timestamp vs. date bug | MEDIUM | Write a one-time migration that normalizes all stored due dates to date-only (midnight local time); bump schemaVersion |
| Notification permission never requested; users have no notifications | LOW | Add permission request on next app launch; show an in-app banner explaining why |
| Hardcoded German strings throughout widgets | HIGH | Requires systematic extraction to ARB files; no shortcut — this is a full codebase refactor |
| `autoDispose` caused state loss and data reload on every navigation | MEDIUM | Add `keepAlive: true` to affected providers; test all navigation flows after the change |
| Notification icon stripped by R8 | LOW | Add `keep.xml` proguard rule; rebuild and redeploy |
---
## Pitfall-to-Phase Mapping
How roadmap phases should address these pitfalls.
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| Drift schema without migration workflow | Foundation (DB setup) | Run `drift_dev schema verify` passes; migration test file exists |
| Due date timestamp vs. date bug | Core scheduling logic | Unit tests for midnight, month-end, and interval edge cases all pass |
| Android notification runtime permissions | Notifications phase | Fresh install on API 33+ physical device shows permission prompt; notification fires |
| Notification lost after reboot | Notifications phase | Device rebooted; notification rescheduled and fires at correct time |
| `ref.watch` outside `build` | Foundation (project setup) | `riverpod_lint` enabled; no lint warnings in initial project scaffold |
| `autoDispose` state loss on navigation | Core UI phase | Navigate away and back 3 times; no loading flash, no database re-query |
| Hardcoded German strings | Foundation (project setup) | `l10n.yaml` and `AppLocalizations` set up before first UI widget is written |
| Notification icon stripped by R8 | Notifications phase | Release build tested (not just debug); notification icon renders correctly |
| Overdue logic / cleanliness indicator divide-by-zero | Daily plan view phase | Empty room (0 tasks) renders without crash; indicator shows neutral state |
| Room photo stored as blob | Room management phase | Photos stored as file paths; database size stays small after adding 5 rooms with photos |
---
## Sources
- [Drift official migration docs](https://drift.simonbinder.eu/migrations/) — schema versioning and `stepByStep` API
- [Riverpod official docs — Refs](https://riverpod.dev/docs/concepts2/refs) — `ref.watch` vs `ref.read` vs `ref.listen` semantics
- [Riverpod official docs — Auto Dispose](https://riverpod.dev/docs/concepts2/auto_dispose) — `autoDispose` lifecycle
- [flutter_local_notifications pub.dev](https://pub.dev/packages/flutter_local_notifications) — Android permission requirements and migration notes
- [Android Developer docs — Notification permission](https://developer.android.com/develop/ui/views/notifications/notification-permission) — `POST_NOTIFICATIONS` runtime permission
- [Android Developer docs — Schedule exact alarms (API 14)](https://developer.android.com/about/versions/14/changes/schedule-exact-alarms) — `SCHEDULE_EXACT_ALARM` denied by default on API 33+
- [Andrea Bizzotto — Riverpod data caching and provider lifecycle](https://codewithandrea.com/articles/flutter-riverpod-data-caching-providers-lifecycle/) — `keepAlive`, `autoDispose` patterns
- [Flutter Material 3 migration guide](https://docs.flutter.dev/release/breaking-changes/material-3-migration) — widget replacements and theming changes
- [Common SQLite mistakes — Sparkleo/Medium](https://medium.com/@sparkleo/common-sqlite-mistakes-flutter-devs-make-and-how-to-avoid-them-1102ab0117d5) — N+1 queries, transactions, normalization
- [ha-chore-helper GitHub](https://github.com/bmcclure/ha-chore-helper) — "every" vs "after" scheduling modes in chore apps, midnight edge cases
- [Drift Local Database Part 1 — Medium](https://r1n1os.medium.com/drift-local-database-for-flutter-part-1-intro-setup-and-migration-09a64d44f6df) — `schemaVersion` and migration workflow for new developers
---
*Pitfalls research for: Local-first Flutter household chore management app (HouseHoldKeaper)*
*Researched: 2026-03-15*