Engineering

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

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:

  1. Instantaneous — sub-2-second interaction from tap to saved
  2. Offline-capable — work in a subway or a basement with zero signal
  3. 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

  1. 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.

  2. Use FLAG_IMMUTABLE on all PendingIntent calls — required on Android 12+ or the app will crash with a security exception.

  3. Idempotency matters — give every queued expense a UUID. If WorkManager retries a batch, you need to deduplicate on the Firestore side.

  4. 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.