
An app widget can be seen as a mini-app that users “glance at and tap on.”
However, widgets have different process lifecycles and much stricter background restrictions, so making network calls as usual is difficult. 😥
This post is a record of the trial-and-error I went through while building Mulkkam, a water intake tracking widget.
The widget is implemented with RemoteViews + AppWidgetProvider, and uses a BroadcastReceiver for events and WorkManager for actual tasks.
Along the way, I wanted to decouple the UI from WorkManager, so I introduced an abstraction layer (IntakeChecker) to keep things cleaner.
Let’s dive into the core code.
<receiver
android:name=".ui.widget.IntakeWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/intake_widget_info" />
</receiver>
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/layout_intake_widget"
android:minWidth="276dp"
android:minHeight="50dp"
android:previewImage="@drawable/img_intake_widget"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="7200000"
android:widgetCategory="home_screen" />
class IntakeWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
appWidgetIds.forEach { id -> updateIntakeWidgetInfo(context, id) }
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
when (IntakeWidgetAction.from(intent.action)) {
ACTION_DRINK -> performDrink(intent, context)
ACTION_REFRESH -> refreshWidget(context)
null -> return
}
}
}
private fun showIntakeWidgetInfo(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
achievementRate: Float,
targetAmount: Int,
totalAmount: Int,
primaryCupAmount: Int,
) {
val views = RemoteViews(context.packageName, R.layout.layout_intake_widget)
val donut = GradientDonutChartView.createBitmap(
context, width = 74.dpToPx(context), height = 74.dpToPx(context),
stroke = 6f, progress = achievementRate
)
views.setImageViewBitmap(R.id.iv_donut_chart, donut)
views.setTextViewText(
R.id.tv_title_date,
context.getString(R.string.intake_widget_home_target, LocalDate.now().monthValue, LocalDate.now().dayOfMonth),
)
views.setTextViewText(
R.id.tv_summary,
context.getString(R.string.home_daily_intake_summary, totalAmount, targetAmount),
)
// Widget click → Open app
views.setOnClickPendingIntent(R.id.layout_intake_widget, MainActivity.newPendingIntent(context))
// “Drink a cup” button → Trigger intake action
views.setOnClickPendingIntent(R.id.ll_drink, newDrinkPendingIntent(context, appWidgetId, primaryCupAmount))
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private fun newDrinkPendingIntent(context: Context, appWidgetId: Int, amount: Int): PendingIntent {
val intent = Intent(context, IntakeWidget::class.java).apply {
action = ACTION_DRINK.name
putExtra(KEY_EXTRA_AMOUNT, amount)
putExtra(KEY_EXTRA_WIDGET_ID, appWidgetId)
}
val requestCode = REQUEST_CODE_DRINK + appWidgetId
return PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
Since widgets are BroadcastReceiver-based, receiving a click event is easy.
But you must not run network/DB tasks directly here. (Tight background restrictions, must return within seconds, ANR risk…)
Solution: Delegate all real work to WorkManager.
class IntakeCheckerImpl(private val workManager: WorkManager) : IntakeChecker {
override fun drink(amount: Int): UUID {
val request = OneTimeWorkRequestBuilder<DrinkByAmountWorker>()
.setInputData(workDataOf(IntakeChecker.KEY_INTAKE_CHECKER_AMOUNT to amount))
.build()
workManager.enqueue(request)
return request.id
}
override fun checkWidgetInfo(): UUID {
val request = OneTimeWorkRequestBuilder<IntakeWidgetWorker>().build()
workManager.enqueue(request)
return request.id
}
}
private fun updateIntakeWidgetInfo(context: Context, appWidgetId: Int) {
val requestId = intakeChecker.checkWidgetInfo()
val workManager = WorkManager.getInstance(context.applicationContext)
val live = workManager.getWorkInfoByIdLiveData(requestId)
val observer = object : Observer<WorkInfo?> {
override fun onChanged(value: WorkInfo?) {
if (value?.state?.isFinished != true) return
val rate = value.outputData.getFloat(KEY_INTAKE_CHECKER_ACHIEVEMENT_RATE, 0f)
val target = value.outputData.getInt(KEY_INTAKE_CHECKER_TARGET_AMOUNT, 0)
val total = value.outputData.getInt(KEY_INTAKE_CHECKER_TOTAL_AMOUNT, 0)
val amount = value.outputData.getInt(KEY_INTAKE_CHECKER_CUP_AMOUNT, 0)
val appWidgetManager = AppWidgetManager.getInstance(context.applicationContext)
showIntakeWidgetInfo(context.applicationContext, appWidgetManager, appWidgetId, rate, target, total, amount)
live.removeObserver(this) // ★ Prevent leaks
}
}
live.observeForever(observer)
}
drink observing works the same way.
Current:
Future options:
For now, I chose option 1 (simpler, stable), with room for refactoring later.
With widgets, the rule of thumb is:
Keep the BroadcastReceiver thin, push real logic to WorkManager.
By exposing only an abstraction layer (IntakeChecker) to UI and consolidating constants, you reduce mismatches and keep contracts consistent.
Still room for architectural polish, but this structure is already robust. 🥲
Hope this saves someone else a few headaches when building their first widget. 🎉