Building an offline-first task manager with Flutter and native Android widgets means dancing on the edge of two worlds. Recently, while shipping update 0.2.2+63 for ROCIs Tasks, I hit a classic multi-threading trap that’s easy to stumble into when dealing with background isolates, platform channels, and cloud sync.
Here is how I debugged it, solved it, and finally closed the loop on true two-way Google Tasks syncing.
The Mystery: Background Crashes and Broken Tests
While working on background task completion handlers, my app started throwing intermittent runtime errors on Android. The culprit? WidgetsBinding.instance.
If you've ever tried to trigger state updates, platform channel calls, or bindings initialization inside a detached background isolate, you know the pain. Flutter expects a UI binding to be initialized on the main thread. When a background handler fires asynchronously, WidgetsBinding.instance can be null or completely unstable depending on the lifecycle state and platform.
To make matters worse, my test suite was throwing a couple of red flags: a regex validation failure on my new version string format (0.2.2+63), and a Mocktail type-casting error because I hadn't stubbed getGoogleAccessToken() during startup synchronization tests. Standard indie dev Tuesday.
The Root Cause
-
The Isolate Trap:
WidgetsBinding.instance.platformDispatcherrelies heavily on an active UI view and bound Flutter engine lifecycle. In background execution contexts (like handling quick actions or background sync triggers), that binding simply isn't guaranteed to exist. - Mocking Blind Spots: As features grow, mocking external auth tokens in unit tests becomes brittle if your initialization sequence suddenly demands them before the mock is set up.
The Solution & Code Architecture
1. Ditching WidgetsBinding for Background Tasks
The fix for the background isolate issue was surprisingly clean. Instead of reaching for the UI-bound dispatcher, drop down to the global singleton PlatformDispatcher:
// Before (Unstable in background isolates)
// final dispatcher = WidgetsBinding.instance.platformDispatcher;
// After (Safe globally, even off-main-thread)
final dispatcher = PlatformDispatcher.instance;
This simple swap completely stabilized my background handlers without touching the main UI lifecycle.
2. Bulletproofing Google Tasks Back-Sync
With background stability restored, I tackled true two-way sync. Users expect changes made directly inside Google Tasks (completions, uncompletions, and deletions) to reflect instantly in ROCIs Tasks.
I expanded the GoogleTasksService with paginated task retrieval, ensuring we pull completed and hidden items, and implemented syncGoogleTasksToLocal() inside the TaskProvider:
Future<void> syncGoogleTasksToLocal(List<GoogleTask> remoteTasks) async {
for (var remote in remoteTasks) {
final local = _localTasks.firstWhereOrNull((t) => t.googleId == remote.id);
if (remote.isCompleted && local?.isCompleted == false) {
await _markLocalCompleted(local!.id);
} else if (!remote.isCompleted && local?.isCompleted == true) {
await _markLocalActive(local!.id);
}
}
// Handle deletions: tasks present locally with a Google ID but missing remotely
await _reconcileDeletedTasks(remoteTasks);
}
I hooked this reconciliation logic directly into app startup (syncWithCloud), manual settings triggers, and lifecycle tab-switches on both mobile and web (home_screen.dart / web_home_screen.dart).
Key Lessons for Other Devs
-
Beware of Isolate Assumptions: Never assume UI-bound singletons like
WidgetsBindingare available outside the main thread. UsePlatformDispatcher.instancefor thread-safe global access. -
Test Your Version Strings: If you adopt build number formats like
+63, update your validation regexes before CI/CD complains. - Stub Everything Early: Keep your Mocktail default stubs updated when expanding authentication flows to prevent mysterious startup crashes in tests.
Try ROCIs Tasks
ROCIs Tasks is built completely offline-first with native Kotlin home screen widgets and seamless cloud sync. If you want to check it out, grab it on Google Play or try the web version:
- Google Play: https://play.google.com/store/apps/details?id=com.rocisapps.tasks
- Web App: https://tasks.rocisapps.com
Happy coding!
Top comments (0)