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-migrationsto snapshot the current schema. Drift generates a migration test file you can run to verify correctness. - Treat
schemaVersionas a checklist item: every PR that touches a table definition must bump the version and add astepByStepmigration block. - Use Drift's
stepByStepAPI rather than a rawif (from < N)block — it generates per-step migration scaffolding that reduces errors. - Test migrations with
dart run drift_dev schema verifyagainst the generated schema snapshots.
Warning signs:
- A table definition file changed in a commit but
schemaVersiondid not change. - Tests pass on CI (fresh DB) but crash reports appear from field users after an update.
SqliteException: no such column: Xerrors 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:
- 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.
- "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), notDateTime. 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
lastCompletedDateas 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, andRECEIVE_BOOT_COMPLETEDinAndroidManifest.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.exactAllowWhileIdlefor 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
ScheduledNotificationBootReceiverin 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_notificationsreturns 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.watchonly insidebuild().ref.readin callbacks,initState, event handlers.ref.listeninbuild()for side effects (showing SnackBars).ref.listenManualininitStateor outside build. - Enable
riverpod_lintrules — they catchref.watchoutsidebuildat analysis time, before runtime. - Prefer
@riverpodcode generation (riverpod_generator) over manually constructing providers — the annotation API reduces the surface area for these misuse patterns. - Do not call
ref.read(autoDisposeProvider)ininitStateto "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.
ConsumerStatefulWidgetscreens that callref.watchininitState.
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
autoDisposeonly 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 — schema versioning and
stepByStepAPI - Riverpod official docs — Refs —
ref.watchvsref.readvsref.listensemantics - Riverpod official docs — Auto Dispose —
autoDisposelifecycle - flutter_local_notifications pub.dev — Android permission requirements and migration notes
- Android Developer docs — Notification permission —
POST_NOTIFICATIONSruntime permission - Android Developer docs — Schedule exact alarms (API 14) —
SCHEDULE_EXACT_ALARMdenied by default on API 33+ - Andrea Bizzotto — Riverpod data caching and provider lifecycle —
keepAlive,autoDisposepatterns - Flutter Material 3 migration guide — widget replacements and theming changes
- Common SQLite mistakes — Sparkleo/Medium — N+1 queries, transactions, normalization
- ha-chore-helper GitHub — "every" vs "after" scheduling modes in chore apps, midnight edge cases
- Drift Local Database Part 1 — Medium —
schemaVersionand migration workflow for new developers
Pitfalls research for: Local-first Flutter household chore management app (HouseHoldKeaper) Researched: 2026-03-15