Files
HouseHoldKeaper/.planning/research/PITFALLS.md

22 KiB

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


Pitfalls research for: Local-first Flutter household chore management app (HouseHoldKeaper) Researched: 2026-03-15