package com.tt.honeyDue.widget import android.content.Context import android.content.Intent import kotlinx.coroutines.launch import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.provideContent import androidx.glance.background import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxSize import androidx.glance.layout.height import androidx.glance.layout.padding /** * Small (2x2) widget. * * Mirrors iOS `SmallWidgetView` / `FreeWidgetView` in * `iosApp/HoneyDue/HoneyDue.swift`: * - Free tier → big count + "tasks waiting" label. * - Premium → task count header + single next-task row with * an inline complete button wired to [CompleteTaskAction]. * * Glance restriction: no radial gradients or custom shapes, so the * "organic" glow behind the number is dropped. Cream background and * primary/accent colors match iOS. */ class HoneyDueSmallWidget : GlanceAppWidget() { override val sizeMode: SizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { val repo = WidgetDataRepository.get(context) // Resolve which residence this widget instance is scoped to // (gitea#6). `loadTasksForWidget` falls back to "All residences" // when no scope is saved, matching pre-#6 behaviour for tiles // that haven't been configured yet. val appWidgetId = androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id) val tasks = repo.loadTasksForWidget(appWidgetId) val tier = repo.loadTierState() val isPremium = tier.equals("premium", ignoreCase = true) provideContent { SmallWidgetContent(tasks, isPremium) } } @Composable private fun SmallWidgetContent( tasks: List, isPremium: Boolean ) { val openApp = actionRunCallback() Box( modifier = GlanceModifier .fillMaxSize() .background(WidgetColors.BACKGROUND_PRIMARY) .padding(12.dp) .clickable(openApp), contentAlignment = Alignment.Center ) { if (!isPremium) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalAlignment = Alignment.CenterVertically, modifier = GlanceModifier.fillMaxSize() ) { TaskCountBlock(count = tasks.size, long = true) } } else { Column(modifier = GlanceModifier.fillMaxSize()) { TaskCountBlock(count = tasks.size, long = false) Spacer(modifier = GlanceModifier.height(8.dp)) val nextTask = tasks.firstOrNull() if (nextTask != null) { TaskRow( task = nextTask, compact = true, showResidence = false, onTaskClick = openApp, trailing = { CompleteButton(taskId = nextTask.id) } ) } else { EmptyState(compact = true, onTap = openApp) } } } } } } /** * Launch the main activity when the widget is tapped. * * Shared across all three widget sizes. Task-completion actions live * in Stream M's [CompleteTaskAction]; this receiver handles plain * "open app" taps. */ class OpenAppAction : ActionCallback { override suspend fun onAction( context: Context, glanceId: GlanceId, parameters: ActionParameters ) { val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) intent?.let { it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP context.startActivity(it) } } } /** AppWidget receiver for the small widget. */ class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget() override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) // Clean per-instance residence scope when the user removes a tile // so dangling `widget_residence_id_` keys don't accumulate in // the DataStore (gitea#6). WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds) } } /** * Shared helpers for honeyDue Glance widget receivers. Kept in a * top-level utility so every receiver size (Small / Medium / Large) * uses identical cleanup logic. */ internal object WidgetReceiverHelpers { @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) fun purgeResidenceScopes(context: Context, appWidgetIds: IntArray) { if (appWidgetIds.isEmpty()) return val repo = WidgetDataRepository.get(context) // Fire-and-forget on a background dispatcher — `onDeleted` runs // on the broadcast thread which doesn't permit suspend calls. // GlobalScope is correct here: the IO is short-lived (one // DataStore edit per removed appWidgetId) and there's no // coroutine-scope tied to a long-lived receiver to attach to. kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { for (id in appWidgetIds) { repo.clearResidenceIdFor(id) } } } }