How I Built an Offline-First Android Expense Widget Using Kotlin and Flutter
Published on • By Soban Rafiq
TL;DR
A technical deep-dive into how Jumble's 4x1 Android home screen widget uses Kotlin SharedPreferences, WorkManager offline queues, and Flutter method channels to deliver sub-2-second expense logging without touching the main app.
Table of Contents
- Why We Built It
- Architecture Overview
- Step 1: The AppWidgetProvider
- Step 2: The Offline JSON Queue
- Step 3: The WorkManager Sync Job
- Step 4: Flutter ↔ Kotlin via MethodChannel
- Key Lessons
- Results
Why We Built It
Most Android budget apps require you to launch the full activity to add an expense. Our user research showed that this 7-step process was the primary reason people quit logging after 2 weeks.
The solution: an interactive widget on the home screen that accepts input without launching the app. We needed it to be:
- Instantaneous — sub-2-second interaction from tap to saved
- Offline-capable — work in a subway or a basement with zero signal
- Crash-safe — no data loss even if the sync job fails
Architecture Overview
┌─────────────────────┐
│ Android Home Screen│
│ Jumble 4x1 Widget │
└────────┬────────────┘
│ User taps Save
▼
┌─────────────────────┐
│ AppWidgetProvider │ (Kotlin)
│ + RemoteViews UI │
└────────┬────────────┘
│ Writes transaction
▼
┌─────────────────────┐
│ SharedPreferences │ (Local JSON queue)
│ "offline_queue" │
└────────┬────────────┘
│ WorkManager polls
▼
┌─────────────────────┐
│ SyncWorker.kt │ (WorkManager CoroutineWorker)
│ Drains queue → │
│ Firebase Firestore │
└─────────────────────┘
▲
│ Flutter reads/writes
┌─────────────────────┐
│ Flutter MethodChannel│
│ "jumble_widget" │
└─────────────────────┘
Step 1: The AppWidgetProvider
The widget layout is defined in XML using RemoteViews — the restricted subset of Views that Android allows in widgets.
// QuickAddWidget.kt
class QuickAddWidget : AppWidgetProvider() {
override fun onUpdate(
context: Context,
manager: AppWidgetManager,
ids: IntArray
) {
ids.forEach { updateWidget(context, manager, it) }
}
companion object {
fun updateWidget(ctx: Context, mgr: AppWidgetManager, id: Int) {
val views = RemoteViews(ctx.packageName, R.layout.widget_quick_add)
// Wire up Save button PendingIntent
val saveIntent = Intent(ctx, QuickAddWidget::class.java).apply {
action = ACTION_SAVE_EXPENSE
}
val savePending = PendingIntent.getBroadcast(
ctx, 0, saveIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.btn_save, savePending)
mgr.updateAppWidget(id, views)
}
const val ACTION_SAVE_EXPENSE = "com.soban.jumble.ACTION_SAVE_EXPENSE"
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == ACTION_SAVE_EXPENSE) {
val amount = intent.getDoubleExtra("amount", 0.0)
val category = intent.getStringExtra("category") ?: "General"
enqueueExpense(context, amount, category)
}
}
}
Step 2: The Offline JSON Queue
When the user taps Save, we do NOT make a network call. We immediately write to SharedPreferences as a JSON array:
fun enqueueExpense(ctx: Context, amount: Double, category: String) {
val prefs = ctx.getSharedPreferences("jumble_offline", Context.MODE_PRIVATE)
val existing = prefs.getString("offline_queue", "[]")
val queue = JSONArray(existing)
val entry = JSONObject().apply {
put("amount", amount)
put("category", category)
put("timestamp", System.currentTimeMillis())
put("id", UUID.randomUUID().toString())
}
queue.put(entry)
prefs.edit().putString("offline_queue", queue.toString()).apply()
// Schedule sync (runs when network is available)
OfflineSyncWorker.schedule(ctx)
}
This entire function takes < 5ms. From the user's perspective, the transaction is saved the instant they tap Save. True offline-first.
Step 3: The WorkManager Sync Job
// OfflineSyncWorker.kt
class OfflineSyncWorker(
ctx: Context,
params: WorkerParameters
) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val prefs = applicationContext.getSharedPreferences(
"jumble_offline", Context.MODE_PRIVATE
)
val queueJson = prefs.getString("offline_queue", "[]") ?: "[]"
val queue = JSONArray(queueJson)
if (queue.length() == 0) return Result.success()
val auth = FirebaseAuth.getInstance()
val uid = auth.currentUser?.uid ?: return Result.retry()
val db = FirebaseFirestore.getInstance()
val remaining = JSONArray()
for (i in 0 until queue.length()) {
val item = queue.getJSONObject(i)
try {
db.collection("users").document(uid)
.collection("expenses")
.add(item.toMap())
.await()
// Successfully synced — don't add back to remaining
} catch (e: Exception) {
remaining.put(item) // Keep for retry
}
}
// Write only failed items back
prefs.edit().putString("offline_queue", remaining.toString()).apply()
return if (remaining.length() == 0) Result.success() else Result.retry()
}
companion object {
fun schedule(ctx: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<OfflineSyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(ctx).enqueueUniqueWork(
"offline_sync",
ExistingWorkPolicy.REPLACE,
request
)
}
}
}
The ExistingWorkPolicy.REPLACE ensures we never pile up duplicate sync jobs. WorkManager handles wake-lock, battery optimization, and retry logic automatically.
Step 4: Flutter ↔ Kotlin via MethodChannel
Flutter doesn't control the widget directly (widgets live in native Kotlin). To keep categories in sync between the Flutter app and widget, we use a MethodChannel:
// In Flutter (lib/services/widget_service.dart)
class WidgetService {
static const _channel = MethodChannel('jumble_widget');
static Future<void> syncCategories(List<String> categories) async {
try {
await _channel.invokeMethod('syncCategories', {'categories': categories});
} on PlatformException catch (e) {
debugPrint('Widget sync error: ${e.message}');
}
}
static Future<List<Map<String, dynamic>>> drainOfflineQueue() async {
final result = await _channel.invokeMethod<List>('drainQueue');
return result?.cast<Map<String, dynamic>>() ?? [];
}
}
// In MainActivity.kt
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jumble_widget")
.setMethodCallHandler { call, result ->
when (call.method) {
"syncCategories" -> {
val cats = call.argument<List<String>>("categories") ?: emptyList()
saveCategoriesForWidget(cats)
result.success(null)
}
"drainQueue" -> {
val queue = drainOfflineQueue()
result.success(queue)
}
else -> result.notImplemented()
}
}
Key Lessons
-
Never make network calls inside
AppWidgetProvider.onReceive()— the broadcast receiver has a strict 10-second ANR limit and no coroutine context. Always enqueue to SharedPreferences and delegate to WorkManager. -
Use
FLAG_IMMUTABLEon allPendingIntentcalls — required on Android 12+ or the app will crash with a security exception. -
Idempotency matters — give every queued expense a UUID. If WorkManager retries a batch, you need to deduplicate on the Firestore side.
-
Self-healing JSON queue — on app startup, call
drainOfflineQueue()via MethodChannel to pull any items that the WorkManager may have missed (e.g., phone was off for 3 days).
Results
- Widget tap-to-saved: < 200ms (local SharedPreferences write)
- Background sync time (when reconnected): < 3 seconds for typical 20-item backlog
- Data loss rate: 0% in 3 months of production usage (n=1,200+ users)
The Jumble app is the only free Android budget tracker I'm aware of that offers a fully interactive expense widget with offline JSON queue sync. If you're building something similar, I hope this teardown saves you the 3 weeks I spent reverse-engineering WorkManager's backoff behavior.
Download Jumble free on Google Play or visit jumbleapp.online for the web version.
Start Budgeting for Free
Get the Jumble App to log expenses instantly via our home screen widget. No credit card required.