P3 Stream L: widget refresh scheduler (WorkManager, iOS cadence)
WidgetRefreshSchedule: 30-min day / 120-min overnight (6am–11pm split). WidgetRefreshWorker: CoroutineWorker fetches via APILayer -> repo -> widget.update. WidgetUpdateManager: chained one-time enqueue pattern (WorkManager PeriodicWork can't vary cadence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,113 +1,83 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Manager for updating all widgets with new data
|
||||
* Scheduler for the widget-refresh background work. Thin wrapper over
|
||||
* [WorkManager] that enqueues a [WidgetRefreshWorker] with the cadence
|
||||
* defined by [WidgetRefreshSchedule].
|
||||
*
|
||||
* We use a chained one-time-work pattern rather than `PeriodicWorkRequest`
|
||||
* because:
|
||||
* - `PeriodicWorkRequest` has a 15-minute floor which is fine, but more
|
||||
* importantly can't *vary* its cadence between runs.
|
||||
* - The iOS-parity spec needs 30-min during the day and 120-min overnight
|
||||
* — so each run computes the next interval based on the local clock
|
||||
* and enqueues the next one-time request.
|
||||
*
|
||||
* On [schedulePeriodic], the worker is enqueued with an initial delay of
|
||||
* `intervalMinutes(now)`. On successful completion [WidgetRefreshWorker]
|
||||
* calls [schedulePeriodic] again to chain the next wake.
|
||||
*/
|
||||
object WidgetUpdateManager {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
/** Unique name for the periodic (chained) refresh queue. */
|
||||
const val UNIQUE_WORK_NAME: String = "widget_refresh_periodic"
|
||||
|
||||
/** Unique name for user- / app-triggered forced refreshes. */
|
||||
const val FORCE_REFRESH_WORK_NAME: String = "widget_refresh_force"
|
||||
|
||||
/**
|
||||
* Update all honeyDue widgets with new data
|
||||
* Schedule the next periodic refresh. Delay = [WidgetRefreshSchedule.intervalMinutes]
|
||||
* evaluated against the current local-zone clock. Existing work under
|
||||
* [UNIQUE_WORK_NAME] is replaced — the new interval always wins.
|
||||
*/
|
||||
fun updateAllWidgets(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val repository = WidgetDataRepository.getInstance(context)
|
||||
val summary = repository.widgetSummary.first()
|
||||
val isProUser = repository.isProUser.first()
|
||||
fun schedulePeriodic(context: Context) {
|
||||
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
val delayMinutes = WidgetRefreshSchedule.intervalMinutes(now)
|
||||
|
||||
updateWidgetsWithData(context, summary, isProUser)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
val request = OneTimeWorkRequestBuilder<WidgetRefreshWorker>()
|
||||
.setInitialDelay(delayMinutes, TimeUnit.MINUTES)
|
||||
.addTag(TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context.applicationContext)
|
||||
.enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update widgets with the provided summary data
|
||||
* Force an immediate refresh. Runs as an expedited worker so the OS
|
||||
* treats it as a foreground-ish job (best-effort — may be denied
|
||||
* quota, in which case it falls back to a regular one-time enqueue).
|
||||
*/
|
||||
suspend fun updateWidgetsWithData(
|
||||
context: Context,
|
||||
summary: WidgetSummary,
|
||||
isProUser: Boolean
|
||||
) {
|
||||
val glanceManager = GlanceAppWidgetManager(context)
|
||||
fun forceRefresh(context: Context) {
|
||||
val request = OneTimeWorkRequestBuilder<WidgetRefreshWorker>()
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.addTag(TAG)
|
||||
.build()
|
||||
|
||||
// Update small widgets
|
||||
val smallWidgetIds = glanceManager.getGlanceIds(HoneyDueSmallWidget::class.java)
|
||||
smallWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
}
|
||||
}
|
||||
HoneyDueSmallWidget().update(context, id)
|
||||
}
|
||||
|
||||
// Update medium widgets
|
||||
val mediumWidgetIds = glanceManager.getGlanceIds(HoneyDueMediumWidget::class.java)
|
||||
mediumWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
|
||||
}
|
||||
}
|
||||
HoneyDueMediumWidget().update(context, id)
|
||||
}
|
||||
|
||||
// Update large widgets
|
||||
val largeWidgetIds = glanceManager.getGlanceIds(HoneyDueLargeWidget::class.java)
|
||||
largeWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
this[intPreferencesKey("total_tasks_count")] = summary.totalTasksCount
|
||||
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
|
||||
this[stringPreferencesKey("is_pro_user")] = if (isProUser) "true" else "false"
|
||||
this[longPreferencesKey("last_updated")] = summary.lastUpdated
|
||||
}
|
||||
}
|
||||
HoneyDueLargeWidget().update(context, id)
|
||||
}
|
||||
WorkManager.getInstance(context.applicationContext)
|
||||
.enqueueUniqueWork(FORCE_REFRESH_WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all widget data (called on logout)
|
||||
* Cancel any pending/chained periodic refresh. Does not affect
|
||||
* in-flight forced refreshes — call [cancel] from a logout flow to
|
||||
* stop the scheduler wholesale, or clear both queues explicitly.
|
||||
*/
|
||||
fun clearAllWidgets(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val emptyData = WidgetSummary()
|
||||
updateWidgetsWithData(context, emptyData, false)
|
||||
|
||||
// Also clear the repository
|
||||
val repository = WidgetDataRepository.getInstance(context)
|
||||
repository.clearData()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
fun cancel(context: Context) {
|
||||
val wm = WorkManager.getInstance(context.applicationContext)
|
||||
wm.cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
wm.cancelUniqueWork(FORCE_REFRESH_WORK_NAME)
|
||||
}
|
||||
|
||||
private const val TAG = "widget_refresh"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user