feat(android): add home screen widget for today's tasks (#8737)

* feat(android): add home screen widget for today's tasks (#3818)

Revives PR #7124 (POC by @ilvez) on current master with the review
punch-list addressed:

- today's tasks pushed as a widget_data KeyValStore snapshot (memoized
  selector, debounced, hydration-guarded, re-pushed on sync-window end
  and on pause); Angular is the single writer of the blob
- done-checkbox taps go through a SharedPreferences queue only; pending
  taps are overlaid natively at render time (no native blob write, no
  race with the serializer, no double setDone)
- row title tap opens the app via fill-in extras branching (single
  PendingIntent template); exported receiver no longer lists the custom
  actions in its intent-filter
- typed v:1 contract (android-widget.model.ts <-> WidgetData.kt) locked
  by golden-shape tests on both ends
- drain dedupes and skips missing/already-done tasks; aggregated
  translated snack
- KeyValStore: drop per-call db.close() (SQLiteOpenHelper caches the
  connection; access stays @Synchronized via the App singleton)

Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>

* feat(android): polish widget UI and support toggling done state

Visual polish to match the app:
- rounded surface card in the app's exact light/dark tokens
  (#f8f8f7 / #131314) with automatic day/night switching
- branded header (SP logo + 'Today') with separator, matching
  brand purple (#8b4a9d light / #a05db1 dark)
- app-style circle-check on the row end (Material check_circle in
  brand color when done), dimmed title for done tasks
- project dot tinted with the project color and hidden for
  project-less tasks; whole row and empty state open the app

Done-state toggle (was done-only):
- queue is now a last-wins map {taskId: targetIsDone}; tapping a
  done task queues setUnDone, and a done->undone round trip before
  the app runs collapses to a no-op
- checkbox target computed from the DISPLAYED state (incl. pending
  overlay) so repeated taps toggle back and forth while app is dead
- drain applies setDone/setUnDone, still skipping missing tasks and
  tasks already in the target state

---------

Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>
This commit is contained in:
Johannes Millan 2026-07-03 18:32:34 +02:00 committed by GitHub
parent 1fde5cf664
commit bf710637d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1189 additions and 5 deletions

View file

@ -122,6 +122,9 @@ dependencies {
}
testImplementation "junit:junit:4.13.2"
// Real org.json for JVM unit tests (android.jar only ships stubs); used by
// WidgetDataTest to lock the widget_data blob contract.
testImplementation "org.json:json:20240303"
androidTestImplementation "androidx.test:rules:1.6.1"
androidTestImplementation "com.android.support.test:runner:1.0.2"

View file

@ -174,5 +174,26 @@
</intent-filter>
</receiver>
<!-- Home screen widget. exported="true" is required for APPWIDGET_UPDATE from
the launcher; the custom click/done actions are deliberately NOT listed here —
they are delivered via explicit-component PendingIntents, and listing them
would let any app on the device trigger them. -->
<receiver
android:name=".widget.TaskListWidgetProvider"
android:exported="true"
android:label="@string/widget_title">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info" />
</receiver>
<service
android:name=".widget.TaskListWidgetService"
android:exported="false"
android:permission="android.permission.BIND_REMOTEVIEWS" />
</application>
</manifest>

View file

@ -32,6 +32,7 @@ import com.superproductivity.superproductivity.webview.WebViewCompatibilityCheck
import com.superproductivity.superproductivity.webview.WebViewRecovery
import com.superproductivity.superproductivity.widget.ShareIntentQueue
import com.superproductivity.superproductivity.widget.StartupOverlayManager
import com.superproductivity.superproductivity.widget.TaskListWidgetProvider
import com.superproductivity.plugins.webdavhttp.WebDavHttpPlugin
import org.json.JSONObject
@ -63,6 +64,7 @@ class CapacitorMainActivity : BridgeActivity() {
private var isTimerCompleteReceiverRegistered = false
private var isForegroundServiceFailureReceiverRegistered = false
private var isWidgetDoneDrainReceiverRegistered = false
private val storageHelper =
SimpleStorageHelper(this) // for scoped storage permission management on Android 10+
@ -77,6 +79,17 @@ class CapacitorMainActivity : BridgeActivity() {
}
}
private val widgetDoneDrainReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == TaskListWidgetProvider.ACTION_WIDGET_DONE_DRAIN) {
// Contentless drain signal: Angular pulls the queued IDs itself via
// getWidgetDoneQueue(), so there is a single delivery path and no
// task data crosses the string-interpolated JS bridge.
callJSInterfaceFunctionIfExists("next", "onWidgetDoneDrainRequest$")
}
}
}
private val foregroundServiceFailureReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ForegroundServiceFailure.ACTION) {
@ -224,6 +237,11 @@ class CapacitorMainActivity : BridgeActivity() {
IntentFilter(ForegroundServiceFailure.ACTION)
)
isForegroundServiceFailureReceiverRegistered = true
LocalBroadcastManager.getInstance(this).registerReceiver(
widgetDoneDrainReceiver,
IntentFilter(TaskListWidgetProvider.ACTION_WIDGET_DONE_DRAIN)
)
isWidgetDoneDrainReceiverRegistered = true
// Show startup overlay for quick task entry while Angular loads.
// Only on fresh cold start — not on config-change recreation.
@ -615,6 +633,10 @@ class CapacitorMainActivity : BridgeActivity() {
)
isForegroundServiceFailureReceiverRegistered = false
}
if (isWidgetDoneDrainReceiverRegistered) {
LocalBroadcastManager.getInstance(this).unregisterReceiver(widgetDoneDrainReceiver)
isWidgetDoneDrainReceiverRegistered = false
}
super.onDestroy()
}

View file

@ -8,6 +8,11 @@ import android.database.sqlite.SQLiteOpenHelper
import android.util.Log
import com.superproductivity.superproductivity.App
// NOTE: no per-call db.close() anywhere in this class. SQLiteOpenHelper caches one
// connection for the process lifetime (the standard pattern); closing it per call
// defeated that cache and would churn once the home screen widget started reading
// `widget_data` alongside the JS-bridge writes. All access stays serialized via
// @Synchronized on the App-level singleton.
class KeyValStore(private val context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
@ -54,7 +59,6 @@ class KeyValStore(private val context: Context) :
values.put(KEY_CREATED_AT, System.currentTimeMillis())
row = db.replace(DATABASE_TABLE, null, values)
Log.v(TAG, "save db value size: " + value?.length)
db.close()
}
return row
}
@ -94,8 +98,6 @@ class KeyValStore(private val context: Context) :
// callers can surface "no backup" instead of an opaque invocation error.
Log.e(TAG, "get failed for key $newKey", e)
defaultValue
} finally {
database.close()
}
}
@ -105,7 +107,6 @@ class KeyValStore(private val context: Context) :
if (db != null) {
db.delete(DATABASE_TABLE, null, null)
Log.v(TAG, "cleared db ")
db.close()
}
}

View file

@ -26,6 +26,8 @@ import com.superproductivity.superproductivity.widget.ReminderDoneQueue
import com.superproductivity.superproductivity.widget.ReminderSnoozeQueue
import com.superproductivity.superproductivity.widget.ReminderTapQueue
import com.superproductivity.superproductivity.widget.ShareIntentQueue
import com.superproductivity.superproductivity.widget.TaskListWidgetProvider
import com.superproductivity.superproductivity.widget.WidgetDoneQueue
import com.superproductivity.superproductivity.widget.WidgetTaskQueue
import org.json.JSONObject
@ -372,6 +374,26 @@ class JavaScriptInterface(
return WidgetTaskQueue.getAndClearQueue(activity)
}
/**
* Get pending done-state changes from the home screen widget and clear the
* queue. Returns a JSON object string `{taskId: targetIsDone}` or null if empty.
*/
@Suppress("unused")
@JavascriptInterface
fun getWidgetDoneQueue(): String? {
return WidgetDoneQueue.getAndClear(activity)
}
/**
* Re-render the home screen widget from the current `widget_data` KeyValStore
* snapshot. Called by Angular after each snapshot push.
*/
@Suppress("unused")
@JavascriptInterface
fun updateWidget() {
TaskListWidgetProvider.notifyDataChanged(activity)
}
/**
* Pull-based retrieval of pending share data persisted in SharedPreferences.
* Clears both SharedPreferences and in-memory pendingShareIntent to prevent duplicates.

View file

@ -0,0 +1,131 @@
package com.superproductivity.superproductivity.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.RemoteViews
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.superproductivity.superproductivity.CapacitorMainActivity
import com.superproductivity.superproductivity.R
/**
* Home screen widget listing today's tasks from the `widget_data` KeyValStore
* snapshot pushed by Angular. Checkbox taps enqueue the task ID in
* [WidgetDoneQueue]; Angular drains the queue (instantly via the local drain
* broadcast when alive, otherwise on next resume/cold start).
*/
class TaskListWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateWidget(context, appWidgetManager, appWidgetId)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action != ACTION_CLICK) {
return
}
// A collection view has a single PendingIntent template, so both row
// outcomes arrive here; the fill-in extras decide which one was tapped.
val taskId = intent.getStringExtra(EXTRA_TASK_ID)
when {
taskId != null -> {
// Target state computed at render time from the DISPLAYED state
// (incl. pending overlay), so repeated taps toggle back and forth.
val setDone = intent.getBooleanExtra(EXTRA_SET_DONE, true)
Log.d(TAG, "Toggle done from widget: taskId=$taskId setDone=$setDone")
WidgetDoneQueue.setTarget(context, taskId, setDone)
// Re-render so the pending-done overlay shows the checked box
notifyDataChanged(context)
// Contentless "drain now" signal for a live app; Angular always
// pulls the IDs from the queue itself (single delivery path).
LocalBroadcastManager.getInstance(context)
.sendBroadcast(Intent(ACTION_WIDGET_DONE_DRAIN))
}
intent.getBooleanExtra(EXTRA_OPEN_APP, false) -> {
try {
context.startActivity(
Intent(context, CapacitorMainActivity::class.java).apply {
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
)
} catch (e: Exception) {
// Background-activity-launch restrictions may block this on some
// API levels/OEMs; the header tap (direct activity PendingIntent)
// remains as the reliable way in.
Log.w(TAG, "Failed to open app from widget row tap", e)
}
}
}
}
companion object {
private const val TAG = "TaskListWidget"
const val ACTION_CLICK = "com.superproductivity.superproductivity.WIDGET_CLICK"
const val ACTION_WIDGET_DONE_DRAIN =
"com.superproductivity.superproductivity.WIDGET_DONE_DRAIN"
const val EXTRA_TASK_ID = "WIDGET_TASK_ID"
const val EXTRA_SET_DONE = "WIDGET_SET_DONE"
const val EXTRA_OPEN_APP = "WIDGET_OPEN_APP"
fun notifyDataChanged(context: Context) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetIds = appWidgetManager.getAppWidgetIds(
ComponentName(context, TaskListWidgetProvider::class.java)
)
appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, R.id.widget_task_list)
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.widget_task_list)
val serviceIntent = Intent(context, TaskListWidgetService::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
views.setRemoteAdapter(R.id.widget_task_list, serviceIntent)
views.setEmptyView(R.id.widget_task_list, R.id.widget_empty)
// Single template for all row clicks (explicit component — needs no
// manifest intent-filter entry). MUTABLE is required for fill-ins.
val clickIntent = Intent(context, TaskListWidgetProvider::class.java).apply {
action = ACTION_CLICK
}
val clickPendingIntent = PendingIntent.getBroadcast(
context, 0, clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
views.setPendingIntentTemplate(R.id.widget_task_list, clickPendingIntent)
// Header tap → open app
val openAppIntent = Intent(context, CapacitorMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val openAppPendingIntent = PendingIntent.getActivity(
context, 0, openAppIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_header, openAppPendingIntent)
views.setOnClickPendingIntent(R.id.widget_empty, openAppPendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
}

View file

@ -0,0 +1,102 @@
package com.superproductivity.superproductivity.widget
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.util.Log
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import com.superproductivity.superproductivity.App
import com.superproductivity.superproductivity.R
class TaskListWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
return TaskListRemoteViewsFactory(applicationContext)
}
}
private class TaskListRemoteViewsFactory(
private val context: Context
) : RemoteViewsService.RemoteViewsFactory {
private var tasks: List<WidgetTask> = emptyList()
override fun onCreate() {}
override fun onDataSetChanged() {
tasks = try {
val json = (context.applicationContext as App).keyValStore
.get(WidgetData.KEYVAL_KEY, "{}")
WidgetData.parse(json, WidgetDoneQueue.peek(context)).take(MAX_TASKS)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse widget data", e)
emptyList()
}
}
override fun onDestroy() {
tasks = emptyList()
}
override fun getCount(): Int = tasks.size
override fun getViewAt(position: Int): RemoteViews {
val rv = RemoteViews(context.packageName, R.layout.widget_task_row)
if (position >= tasks.size) {
return rv
}
val task = tasks[position]
rv.setTextViewText(R.id.widget_task_title, task.title)
rv.setTextColor(
R.id.widget_task_title,
context.getColor(if (task.isDone) R.color.widget_ink_muted else R.color.widget_ink)
)
rv.setImageViewResource(
R.id.widget_done_checkbox,
if (task.isDone) R.drawable.ic_widget_check_done else R.drawable.ic_widget_check_outline
)
// Project dot: tint with the project color, hide entirely for
// project-less tasks instead of showing a meaningless default color
val color = task.projectColor?.let {
try {
Color.parseColor(it)
} catch (e: Exception) {
null
}
}
if (color != null) {
rv.setViewVisibility(R.id.widget_project_dot, android.view.View.VISIBLE)
rv.setInt(R.id.widget_project_dot, "setColorFilter", color)
} else {
rv.setViewVisibility(R.id.widget_project_dot, android.view.View.GONE)
}
// Checkbox toggles to the opposite of the DISPLAYED state; anywhere else
// on the row opens the app.
rv.setOnClickFillInIntent(
R.id.widget_done_checkbox,
Intent()
.putExtra(TaskListWidgetProvider.EXTRA_TASK_ID, task.id)
.putExtra(TaskListWidgetProvider.EXTRA_SET_DONE, !task.isDone)
)
rv.setOnClickFillInIntent(
R.id.widget_task_row,
Intent().putExtra(TaskListWidgetProvider.EXTRA_OPEN_APP, true)
)
return rv
}
override fun getLoadingView(): RemoteViews? = null
override fun getViewTypeCount(): Int = 1
override fun getItemId(position: Int): Long = position.toLong()
override fun hasStableIds(): Boolean = false
companion object {
private const val TAG = "TaskListWidget"
private const val MAX_TASKS = 20
}
}

View file

@ -0,0 +1,58 @@
package com.superproductivity.superproductivity.widget
import org.json.JSONObject
data class WidgetTask(
val id: String,
val title: String,
val isDone: Boolean,
val projectColor: String?
)
/**
* Native end of the `widget_data` KeyValStore contract. The writer is Angular's
* WidgetDataService; the blob shape is defined by AndroidWidgetData in
* src/app/features/android/android-widget.model.ts keep both ends in sync and
* bump `v` on breaking changes.
*/
object WidgetData {
const val KEYVAL_KEY = "widget_data"
private const val SUPPORTED_VERSION = 1
/**
* @param pendingDoneTargets per-task done-state targets queued via
* [WidgetDoneQueue] but not yet applied by Angular overlaid so a checkbox
* tap is reflected immediately even while the app process is dead.
*/
fun parse(
json: String,
pendingDoneTargets: Map<String, Boolean> = emptyMap()
): List<WidgetTask> {
val root = JSONObject(json)
if (root.optInt("v", -1) != SUPPORTED_VERSION) {
return emptyList()
}
val tasksArray = root.optJSONArray("tasks") ?: return emptyList()
val projectColors = root.optJSONObject("projectColors")
val result = mutableListOf<WidgetTask>()
for (i in 0 until tasksArray.length()) {
val task = tasksArray.getJSONObject(i)
val id = task.getString("id")
// isNull guard: optString maps JSON null to the literal string "null"
val projectId =
if (task.isNull("projectId")) null else task.optString("projectId", null)
val color = projectId?.let { pId ->
projectColors?.takeIf { !it.isNull(pId) }?.optString(pId, null)
}
result.add(
WidgetTask(
id = id,
title = task.getString("title"),
isDone = pendingDoneTargets[id] ?: task.optBoolean("isDone", false),
projectColor = color
)
)
}
return result
}
}

View file

@ -0,0 +1,63 @@
package com.superproductivity.superproductivity.widget
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONObject
/**
* SharedPreferences-backed queue of pending done-state changes from widget
* checkbox taps, stored as a JSON object map `{taskId: targetIsDone}` last tap
* per task wins, so tapping a task done and back to undone before the app runs
* collapses into a single (or no-op) change.
*
* Angular is the only consumer (JavaScriptInterface.getWidgetDoneQueue) and the
* only writer of the `widget_data` snapshot; the widget itself only ever peek()s
* so pending taps render correctly without native code mutating the blob.
*/
object WidgetDoneQueue {
private const val PREFS_NAME = "SuperProductivityWidgetDone"
private const val KEY_DONE_TASKS = "WIDGET_DONE_TASK_IDS"
private fun getPrefs(context: Context): SharedPreferences {
return context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
@Synchronized
fun setTarget(context: Context, taskId: String, isDone: Boolean) {
val prefs = getPrefs(context)
val map = prefs.getString(KEY_DONE_TASKS, null)?.let {
try {
JSONObject(it)
} catch (e: Exception) {
JSONObject()
}
} ?: JSONObject()
map.put(taskId, isDone)
// commit (not apply): the enqueue runs in a short-lived broadcast, the process
// may be killed right after — the tap must survive that.
prefs.edit().putString(KEY_DONE_TASKS, map.toString()).commit()
}
/** Non-clearing read used to overlay pending done state at widget render time. */
@Synchronized
fun peek(context: Context): Map<String, Boolean> {
val data = getPrefs(context).getString(KEY_DONE_TASKS, null) ?: return emptyMap()
return try {
val map = JSONObject(data)
map.keys().asSequence().associateWith { map.getBoolean(it) }
} catch (e: Exception) {
emptyMap()
}
}
/** @return JSON object string `{taskId: targetIsDone}`, or null if empty. */
@Synchronized
fun getAndClear(context: Context): String? {
val prefs = getPrefs(context)
val data = prefs.getString(KEY_DONE_TASKS, null)
if (data != null) {
prefs.edit().remove(KEY_DONE_TASKS).commit()
}
return data
}
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material "check_circle" in the app's brand color -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_brand">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
</vector>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material "radio_button_unchecked" — the app's open-task circle -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_ink_muted">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_bg" />
<corners android:radius="16dp" />
</shape>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- White circle tinted at runtime via setColorFilter with the project color -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFF" />
</shape>

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/widget_bg"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:paddingTop="12dp"
android:paddingBottom="6dp">
<LinearLayout
android:id="@+id/widget_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingBottom="10dp">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_stat_sp"
android:tint="@color/widget_brand"
android:contentDescription="" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="@string/widget_header_title"
android:textColor="@color/widget_ink"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<!-- FrameLayout, not View: plain View is not on the RemoteViews whitelist -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/widget_separator" />
<ListView
android:id="@+id/widget_task_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:divider="@null"
android:dividerHeight="0dp"
android:scrollbars="none" />
<TextView
android:id="@+id/widget_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/widget_empty"
android:textColor="@color/widget_ink_muted"
android:textSize="14sp"
android:gravity="center"
android:visibility="gone" />
</LinearLayout>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_task_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="6dp"
android:paddingEnd="0dp"
android:minHeight="34dp">
<ImageView
android:id="@+id/widget_project_dot"
android:layout_width="8dp"
android:layout_height="8dp"
android:layout_marginEnd="10dp"
android:src="@drawable/widget_project_dot"
android:contentDescription="" />
<TextView
android:id="@+id/widget_task_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/widget_ink"
android:textSize="15sp"
android:maxLines="2"
android:ellipsize="end" />
<ImageView
android:id="@+id/widget_done_checkbox"
android:layout_width="38dp"
android:layout_height="34dp"
android:paddingStart="9dp"
android:paddingEnd="9dp"
android:paddingTop="7dp"
android:paddingBottom="7dp"
android:layout_marginStart="4dp"
android:src="@drawable/ic_widget_check_outline"
android:scaleType="fitCenter"
android:contentDescription="@string/widget_task_done" />
</LinearLayout>

View file

@ -3,4 +3,11 @@
<color name="statusBar">#131314</color>
<color name="navigationBar">#131314</color>
<color name="windowBackground">#131314</color>
<!-- Home screen widget (dark) — see values/colors.xml -->
<color name="widget_bg">#F5131314</color>
<color name="widget_ink">#E6E6E6</color>
<color name="widget_ink_muted">#A6EBEBEB</color>
<color name="widget_separator">#1AFFFFFF</color>
<color name="widget_brand">#A05DB1</color>
</resources>

View file

@ -8,4 +8,13 @@
<color name="windowBackground">#f8f8f7</color>
<color name="webview_block_secondary">#424242</color>
<!-- Home screen widget — mirrors the web app's theme tokens
(light surface #f8f8f7 / dark surface #131314, brand purple).
Dark variants live in values-night/colors.xml. -->
<color name="widget_bg">#F5F8F8F7</color>
<color name="widget_ink">#DE000000</color>
<color name="widget_ink_muted">#8A000000</color>
<color name="widget_separator">#1F000000</color>
<color name="widget_brand">#8B4A9D</color>
</resources>

View file

@ -28,6 +28,13 @@
<!-- Share intent -->
<string name="share_received">Content received, creating task\u2026</string>
<!-- Home screen widget -->
<string name="widget_title">Today\'s Tasks</string>
<string name="widget_header_title">Today</string>
<string name="widget_description">Shows today\'s tasks with quick done action</string>
<string name="widget_empty">No tasks for today</string>
<string name="widget_task_done">Toggle task done</string>
<!-- Startup overlay -->
<string name="startup_overlay_add_task">Add task</string>
<string name="startup_overlay_hint">Add a task while loading\u2026</string>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_task_list"
android:previewLayout="@layout/widget_task_list"
android:minWidth="250dp"
android:minHeight="180dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:description="@string/widget_description" />

View file

@ -0,0 +1,75 @@
package com.superproductivity.superproductivity.widget
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Locks the native end of the `widget_data` v:1 contract. The writer-side shape is
* locked by android-widget.selectors.spec.ts if one changes, the other must too.
*/
class WidgetDataTest {
private val blob =
"""
{
"v": 1,
"tasks": [
{"id": "t1", "title": "Task one", "isDone": false, "projectId": "p1"},
{"id": "t2", "title": "Task two", "isDone": true},
{"id": "t3", "title": "Task three", "isDone": false, "projectId": null}
],
"projectColors": {"p1": "#ff0000"}
}
""".trimIndent()
@Test
fun parsesTasksWithProjectColors() {
val tasks = WidgetData.parse(blob)
assertEquals(3, tasks.size)
assertEquals(WidgetTask("t1", "Task one", false, "#ff0000"), tasks[0])
assertEquals(WidgetTask("t2", "Task two", true, null), tasks[1])
}
@Test
fun jsonNullProjectIdDoesNotBecomeStringNull() {
// org.json's optString maps JSON null to the literal string "null"
val tasks = WidgetData.parse(blob)
assertNull(tasks[2].projectColor)
}
@Test
fun overlaysPendingDoneTargets() {
val tasks = WidgetData.parse(blob, pendingDoneTargets = mapOf("t1" to true))
assertTrue(tasks[0].isDone)
assertTrue(tasks[1].isDone)
assertEquals(false, tasks[2].isDone)
}
@Test
fun overlaysPendingUndoneTargets() {
// t2 is done in the blob but has a pending "mark undone" tap
val tasks = WidgetData.parse(blob, pendingDoneTargets = mapOf("t2" to false))
assertEquals(false, tasks[1].isDone)
}
@Test
fun unknownVersionParsesToEmpty() {
assertTrue(WidgetData.parse("""{"v": 2, "tasks": [{"id": "x"}]}""").isEmpty())
assertTrue(WidgetData.parse("""{"tasks": []}""").isEmpty())
}
@Test
fun emptyBlobParsesToEmpty() {
assertTrue(WidgetData.parse("{}").isEmpty())
assertTrue(WidgetData.parse("""{"v": 1}""").isEmpty())
}
@Test
fun missingColorFallsBackToNull() {
val json =
"""{"v":1,"tasks":[{"id":"a","title":"A","isDone":false,"projectId":"px"}],"projectColors":{}}"""
assertNull(WidgetData.parse(json)[0].projectColor)
}
}

View file

@ -0,0 +1,80 @@
# Android home screen widget (revival of PR #7124)
Implements #3818. Based on ilvez's POC (PR #7124, closed) plus the 2026-06-08 review
punch-list. Re-implemented on current master (the PR's April base has drifted heavily).
## Architecture (unchanged from POC — confirmed sound)
- **Snapshot bridge:** Angular pushes a compact JSON snapshot of today's tasks into the
existing native `KeyValStore` (SQLite) under key `widget_data`. The native
`RemoteViewsFactory` reads it. No new storage mechanism.
- **Done actions:** `SharedPreferences`-backed `WidgetDoneQueue` (mirrors
`WidgetTaskQueue` / `ReminderDoneQueue` patterns), drained by Angular.
## Improvements over the POC (mapped to the punch-list)
1. **Title tap opens app (bug fix).** A collection has one `setPendingIntentTemplate`;
the POC's second fill-in intent silently fell into the done-template. Fix: one
broadcast template, fill-ins carry either `EXTRA_TASK_ID` (→ done) or
`EXTRA_OPEN_APP` (→ `startActivity`), branch in `onReceive`.
2. **Manifest hardening.** Custom actions removed from the exported receiver's
intent-filter (explicit-component PendingIntents don't need filter entries; listing
them let any app mark tasks done / launch us). Only `APPWIDGET_UPDATE` remains.
3. **Queue-only done path.** Widget tap → enqueue + widget refresh + contentless
"drain now" LocalBroadcast. Angular always reads via `getWidgetDoneQueue()`
(get-and-clear). Single delivery path; no double `setDone()`; retires the
`'$sanitizedId'` string-interpolated JS call.
4. **Single-writer blob (replaces the proposed ts-guard).** Native never writes
`widget_data`. For instant checkbox feedback while the app is dead, the factory
overlays `isDone=true` for IDs currently in `WidgetDoneQueue` at render time
(`peek()`, non-clearing). The Angular-vs-native write race disappears structurally;
`markDoneInWidgetData` is deleted.
5. **Memoized snapshot selector.** `selectAndroidWidgetData` projects
todayIds + task entities + project colors into the exact blob shape. Project color
changes now propagate (POC pulled colors in but excluded them from the distinct key).
Dedupe happens in `WidgetDataService` via a last-pushed-JSON cache, so all trigger
paths share it.
6. **Post-sync freshness push (new — gap found in review).** The hydration-guard filter
drops all emissions during remote-op apply and nothing re-emits after, so the widget
would miss synced changes until the next local edit. Added push on the
`isSyncInProgress$` falling edge. (Push-on-pause kept as belt-and-braces.)
7. **Typed contract.** `AndroidWidgetData` interface (TS) + `WidgetData.kt` parser as
the two named ends of the `v:1` contract, locked by a golden-shape unit test on the
serializer and a JVM parse test on the Kotlin side. (typia writer-side assert was
considered and skipped: the object is constructed from typed state, so an assert is
tautological, and a throw would leave the widget stale — worse than pushing.)
8. **Drain hygiene.** Dedupe IDs, skip missing/already-done tasks (no duplicate ops, no
op-log noise from stale queue entries), gate on initial data load. One aggregated
translated snack (`T`/en.json) instead of one hardcoded snack per task.
9. **KeyValStore per-call `db.close()` removal** (correct SQLiteOpenHelper pattern,
avoids churn from widget reads) — redone against the current chunked-read code.
All access is `@Synchronized` on the `App`-level singleton, so widget-vs-bridge
concurrency stays serialized. Backup-ring round trip must be manually verified on
device (no Robolectric in the project).
10. Kotlin JSON parsing extracted to `WidgetData.kt` (single parse site, JVM-testable
via `org.json:json` test dep). `optString("projectId", null)` JSON-NULL→"null"
footgun avoided via `isNull()` checks; Angular omits `projectId` when absent.
## Known limitations (deliberate, documented)
- Widget reflects the app's last known state; cross-client freshness while the app is
dead is phase 2 (WorkManager + SuperSync).
- Day rollover while the app process is dead shows yesterday's list until next open
(native can't recompute "today"; selector handles rollover whenever JS is alive).
- Hardcoded dark styling; Jetpack Glance / Material You is a follow-up view-layer swap.
- Widget chrome strings are native `strings.xml` (English) — accepted for v1.
- No task creation / undo from widget.
## Files
Native: `widget/{TaskListWidgetProvider,TaskListWidgetService,WidgetData,WidgetDoneQueue}.kt`,
`CapacitorMainActivity.kt` (drain receiver), `webview/JavaScriptInterface.kt`
(`getWidgetDoneQueue`, `updateWidget`), `app/KeyValStore.kt`, manifest, layouts,
`xml/appwidget_info.xml`, `values/strings.xml`, `build.gradle` (test dep),
`test/.../widget/WidgetDataTest.kt`.
Angular: `features/android/android-widget.model.ts`,
`features/android/store/android-widget.selectors.ts` (+spec),
`features/android/widget-data.service.ts` (+spec),
`features/android/store/android-widget.effects.ts` (+spec),
`android-interface.ts`, `root-store/feature-stores.module.ts`, `en.json` (+`npm run int`).

View file

@ -100,6 +100,13 @@ export interface AndroidInterface {
// Widget task queue - get queued tasks from home screen widget
getWidgetTaskQueue?(): string | null;
// Widget done queue - get pending done-state changes from the home screen
// widget as a JSON object string `{taskId: targetIsDone}` and clear the queue
getWidgetDoneQueue?(): string | null;
// Re-render the home screen widget from the current widget_data snapshot
updateWidget?(): void;
// Startup overlay
getStartupOverlayPartialText?(): string | null;
hideStartupOverlay?(): void;
@ -136,6 +143,11 @@ export interface AndroidInterface {
onReminderSnooze$: ReplaySubject<{ taskId: string; newRemindAt: number }>; // emits snooze events
getReminderSnoozeQueue?(): string | null;
// Contentless "drain now" signal after a widget done-checkbox tap while the app
// is alive; the queued IDs are always pulled via getWidgetDoneQueue() (cold
// start/resume drains are triggered by onResume$ instead)
onWidgetDoneDrainRequest$: Subject<void>;
// Background sync credential bridge (for WorkManager-based reminder cancellation)
setSuperSyncCredentials?(baseUrl: string, accessToken: string): void;
clearSuperSyncCredentials?(): void;
@ -170,6 +182,7 @@ if (IS_ANDROID_WEB_VIEW) {
androidInterface.onReminderTap$ = new ReplaySubject(5);
androidInterface.onReminderDone$ = new ReplaySubject(20);
androidInterface.onReminderSnooze$ = new ReplaySubject(20);
androidInterface.onWidgetDoneDrainRequest$ = new Subject();
androidInterface.onShareWithAttachment$ = new ReplaySubject(1);
androidInterface.isKeyboardShown$ = new BehaviorSubject(false);

View file

@ -0,0 +1,26 @@
/**
* Contract for the `widget_data` KeyValStore blob consumed by the native Android
* home screen widget. The native reader is
* `android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt`
* keep both ends in sync. Bump `v` on breaking changes; the native side renders
* unknown versions as an empty widget instead of mis-parsing them.
*
* Angular is the ONLY writer of this blob. Pending widget done-taps are overlaid
* natively at render time from WidgetDoneQueue, never written into the blob.
*/
export const ANDROID_WIDGET_DATA_KEY = 'widget_data';
export interface AndroidWidgetTask {
id: string;
title: string;
isDone: boolean;
// omitted (not null) when the task has no project — org.json's optString maps
// JSON null to the literal string "null"
projectId?: string;
}
export interface AndroidWidgetData {
v: 1;
tasks: AndroidWidgetTask[];
projectColors: { [projectId: string]: string };
}

View file

@ -0,0 +1,66 @@
import { getTaskDoneChangesToApply } from './android-widget.effects';
import { Task } from '../../tasks/task.model';
import { Dictionary } from '@ngrx/entity';
/**
* The effects themselves are gated by IS_ANDROID_WEB_VIEW (false in tests), so
* we test the drain decision logic directly (repo convention, see
* android-sync-bridge.effects.spec.ts).
*/
describe('AndroidWidgetEffects - getTaskDoneChangesToApply', () => {
const entities = (...tasks: { id: string; isDone?: boolean }[]): Dictionary<Task> =>
Object.fromEntries(
tasks.map((t) => [t.id, { id: t.id, isDone: !!t.isDone } as Task]),
);
it('should mark undone tasks done', () => {
expect(
getTaskDoneChangesToApply(
'{"a":true,"b":true}',
entities({ id: 'a' }, { id: 'b' }),
),
).toEqual([
{ id: 'a', isDone: true },
{ id: 'b', isDone: true },
]);
});
it('should mark done tasks undone', () => {
expect(
getTaskDoneChangesToApply('{"a":false}', entities({ id: 'a', isDone: true })),
).toEqual([{ id: 'a', isDone: false }]);
});
it('should skip tasks deleted since the tap', () => {
expect(
getTaskDoneChangesToApply('{"gone":true,"a":true}', entities({ id: 'a' })),
).toEqual([{ id: 'a', isDone: true }]);
});
it('should skip tasks already in the target state (no redundant update ops)', () => {
expect(
getTaskDoneChangesToApply(
'{"a":true,"b":true}',
entities({ id: 'a', isDone: true }, { id: 'b' }),
),
).toEqual([{ id: 'b', isDone: true }]);
});
it('should treat a done→undone round trip as a no-op', () => {
// last-wins map: tapping done then undone before the app runs → target false
expect(getTaskDoneChangesToApply('{"a":false}', entities({ id: 'a' }))).toEqual([]);
});
it('should return empty for invalid JSON', () => {
expect(getTaskDoneChangesToApply('not json', entities({ id: 'a' }))).toEqual([]);
});
it('should return empty for non-object JSON', () => {
expect(getTaskDoneChangesToApply('["a"]', entities({ id: 'a' }))).toEqual([]);
expect(getTaskDoneChangesToApply('null', entities({ id: 'a' }))).toEqual([]);
});
it('should skip non-boolean target values', () => {
expect(getTaskDoneChangesToApply('{"a":"true"}', entities({ id: 'a' }))).toEqual([]);
});
});

View file

@ -0,0 +1,151 @@
import { inject, Injectable } from '@angular/core';
import { createEffect } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { merge } from 'rxjs';
import {
concatMap,
debounceTime,
filter,
first,
pairwise,
switchMap,
tap,
} from 'rxjs/operators';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { androidInterface } from '../android-interface';
import { WidgetDataService } from '../widget-data.service';
import { TaskService } from '../../tasks/task.service';
import { SnackService } from '../../../core/snack/snack.service';
import { DroidLog } from '../../../core/log';
import { T } from '../../../t.const';
import { HydrationStateService } from '../../../op-log/apply/hydration-state.service';
import { DataInitStateService } from '../../../core/data-init/data-init-state.service';
import { selectAndroidWidgetData } from './android-widget.selectors';
import { selectTaskEntities } from '../../tasks/store/task.selectors';
import { Dictionary } from '@ngrx/entity';
import { Task } from '../../tasks/task.model';
/**
* Decides which queued widget checkbox taps become setDone()/setUnDone() calls.
* The queue is a last-wins map `{taskId: targetIsDone}`; missing tasks (deleted
* since the tap) and tasks already in the target state are skipped so stale
* queue entries never produce redundant update ops. Exported for direct
* testing the effect itself is gated by IS_ANDROID_WEB_VIEW.
*/
export const getTaskDoneChangesToApply = (
queueJson: string,
taskEntities: Dictionary<Task>,
): { id: string; isDone: boolean }[] => {
let targets: unknown;
try {
targets = JSON.parse(queueJson);
} catch (e) {
DroidLog.err('Failed to parse widget done queue', e);
return [];
}
if (typeof targets !== 'object' || targets === null || Array.isArray(targets)) {
return [];
}
return Object.entries(targets as Record<string, unknown>)
.filter(([id, isDone]) => {
const task = taskEntities[id];
return typeof isDone === 'boolean' && !!task && task.isDone !== isDone;
})
.map(([id, isDone]) => ({ id, isDone: isDone as boolean }));
};
@Injectable()
export class AndroidWidgetEffects {
private _store = inject(Store);
private _widgetDataService = inject(WidgetDataService);
private _taskService = inject(TaskService);
private _snackService = inject(SnackService);
private _hydrationState = inject(HydrationStateService);
private _dataInitState = inject(DataInitStateService);
// The selector emission is only a change trigger; pushCurrent() re-reads the
// store at push time (fresher than debounce-stale data) and dedupes itself.
pushOnStateChange$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
this._store.select(selectAndroidWidgetData).pipe(
filter(() => !this._hydrationState.isApplyingRemoteOps()),
debounceTime(500),
tap(() => this._widgetDataService.pushCurrent()),
),
{ dispatch: false },
);
// The guard above drops ALL emissions while remote ops are applied and nothing
// re-emits afterwards, so without this the widget would miss synced changes
// until the next local edit. Push once whenever the sync window closes.
pushOnSyncWindowEnd$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
this._hydrationState.isInSyncWindow$.pipe(
pairwise(),
filter(([wasInWindow, isInWindow]) => wasInWindow && !isInWindow),
tap(() => this._widgetDataService.pushCurrent()),
),
{ dispatch: false },
);
// Last chance to hand the freshest state to the widget before the WebView may
// be frozen or killed in the background — deliberately not debounced.
pushOnPause$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
androidInterface.onPause$.pipe(tap(() => this._widgetDataService.pushCurrent())),
{ dispatch: false },
);
// Single delivery path for widget done-taps: cold start and background→
// foreground are covered by onResume$ (ReplaySubject(1)), taps while the app
// is alive by the contentless native drain signal. The queue read is
// get-and-clear, so overlapping triggers drain at most once.
drainWidgetDoneQueue$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
merge(
androidInterface.onResume$,
androidInterface.onWidgetDoneDrainRequest$,
).pipe(
concatMap(() =>
this._dataInitState.isAllDataLoadedInitially$.pipe(
first(),
switchMap(() => this._store.select(selectTaskEntities).pipe(first())),
),
),
tap((taskEntities) => this._drainDoneQueue(taskEntities)),
),
{ dispatch: false },
);
private _drainDoneQueue(taskEntities: Dictionary<Task>): void {
const queueJson = androidInterface.getWidgetDoneQueue?.();
if (!queueJson) {
return;
}
const changes = getTaskDoneChangesToApply(queueJson, taskEntities);
for (const change of changes) {
if (change.isDone) {
this._taskService.setDone(change.id);
} else {
this._taskService.setUnDone(change.id);
}
}
DroidLog.log('Drained widget done queue', { changeCount: changes.length });
if (changes.length > 0) {
this._snackService.open({
type: 'SUCCESS',
msg: T.F.ANDROID.WIDGET_TASKS_UPDATED,
translateParams: { count: changes.length },
});
}
}
}

View file

@ -0,0 +1,91 @@
import { selectAndroidWidgetData } from './android-widget.selectors';
import { Task } from '../../tasks/task.model';
import { Project } from '../../project/project.model';
describe('selectAndroidWidgetData', () => {
const task = (id: string, partial: Partial<Task> = {}): Task =>
({
id,
title: `Task ${id}`,
isDone: false,
projectId: undefined,
...partial,
}) as Task;
const project = (id: string, primary?: string): Project =>
({
id,
title: `Project ${id}`,
theme: primary ? { primary } : {},
}) as Project;
const projectState = (projects: Project[]): any => ({
ids: projects.map((p) => p.id),
entities: Object.fromEntries(projects.map((p) => [p.id, p])),
});
it('should project today tasks in order with project colors', () => {
const result = selectAndroidWidgetData.projector(
['t1', 't2'],
{
t1: task('t1', { title: 'Task one', projectId: 'p1' }),
t2: task('t2', { title: 'Task two', isDone: true }),
},
projectState([project('p1', '#ff0000')]),
);
expect(result).toEqual({
v: 1,
tasks: [
{ id: 't1', title: 'Task one', isDone: false, projectId: 'p1' },
{ id: 't2', title: 'Task two', isDone: true },
],
projectColors: { p1: '#ff0000' },
});
});
it('should skip today ids without a task entity', () => {
const result = selectAndroidWidgetData.projector(
['missing', 't1'],
{ t1: task('t1') },
projectState([]),
);
expect(result.tasks.length).toBe(1);
expect(result.tasks[0].id).toBe('t1');
});
it('should omit projectId key entirely for project-less tasks (JSON null breaks the Kotlin parser contract)', () => {
const result = selectAndroidWidgetData.projector(
['t1'],
{ t1: task('t1', { projectId: undefined }) },
projectState([]),
);
expect('projectId' in result.tasks[0]).toBe(false);
});
it('should not include colors for projects without a theme primary', () => {
const result = selectAndroidWidgetData.projector(
['t1'],
{ t1: task('t1', { projectId: 'p1' }) },
projectState([project('p1')]),
);
expect(result.projectColors).toEqual({});
expect(result.tasks[0].projectId).toBe('p1');
});
it('should serialize to the exact v:1 blob shape consumed by WidgetData.kt (see WidgetDataTest.kt)', () => {
const result = selectAndroidWidgetData.projector(
['t1', 't2'],
{
t1: task('t1', { title: 'Task one', projectId: 'p1' }),
t2: task('t2', { title: 'Task two', isDone: true }),
},
projectState([project('p1', '#ff0000')]),
);
expect(JSON.stringify(result)).toBe(
'{"v":1,"tasks":[' +
'{"id":"t1","title":"Task one","isDone":false,"projectId":"p1"},' +
'{"id":"t2","title":"Task two","isDone":true}],' +
'"projectColors":{"p1":"#ff0000"}}',
);
});
});

View file

@ -0,0 +1,42 @@
import { createSelector } from '@ngrx/store';
import { selectTodayTaskIds } from '../../work-context/store/work-context.selectors';
import { selectTaskEntities } from '../../tasks/store/task.selectors';
import { selectProjectFeatureState } from '../../project/store/project.selectors';
import { AndroidWidgetData, AndroidWidgetTask } from '../android-widget.model';
/**
* Projects today's tasks into the exact `widget_data` blob shape, so downstream
* consumers get referential stability from the selector memoization and cheap
* change detection via JSON comparison in WidgetDataService.
*/
export const selectAndroidWidgetData = createSelector(
selectTodayTaskIds,
selectTaskEntities,
selectProjectFeatureState,
(todayTaskIds, taskEntities, projectState): AndroidWidgetData => {
const tasks: AndroidWidgetTask[] = [];
const projectColors: { [projectId: string]: string } = {};
for (const taskId of todayTaskIds) {
const task = taskEntities[taskId];
if (!task) {
continue;
}
const widgetTask: AndroidWidgetTask = {
id: task.id,
title: task.title,
isDone: task.isDone,
};
if (task.projectId) {
widgetTask.projectId = task.projectId;
const color = projectState.entities[task.projectId]?.theme?.primary;
if (color) {
projectColors[task.projectId] = color;
}
}
tasks.push(widgetTask);
}
return { v: 1, tasks, projectColors };
},
);

View file

@ -0,0 +1,34 @@
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { androidInterface } from './android-interface';
import { ANDROID_WIDGET_DATA_KEY } from './android-widget.model';
import { selectAndroidWidgetData } from './store/android-widget.selectors';
import { DroidLog } from '../../core/log';
/**
* Pushes the current today-task snapshot to the native KeyValStore for the home
* screen widget. Dedupes against the last successfully pushed blob so every
* trigger path (state change, pause, post-sync) can call it unconditionally.
*/
@Injectable({ providedIn: 'root' })
export class WidgetDataService {
private _store = inject(Store);
private _lastPushedJson: string | null = null;
async pushCurrent(): Promise<void> {
const data = await firstValueFrom(this._store.select(selectAndroidWidgetData));
const json = JSON.stringify(data);
if (json === this._lastPushedJson) {
return;
}
try {
await androidInterface.saveToDbWrapped(ANDROID_WIDGET_DATA_KEY, json);
androidInterface.updateWidget?.();
// only remember successful pushes, so a failed one is retried next trigger
this._lastPushedJson = json;
} catch (e) {
DroidLog.err('Failed to push widget data', e);
}
}
}

View file

@ -69,6 +69,7 @@ import { AndroidEffects } from '../features/android/store/android.effects';
import { AndroidFocusModeEffects } from '../features/android/store/android-focus-mode.effects';
import { AndroidForegroundTrackingEffects } from '../features/android/store/android-foreground-tracking.effects';
import { AndroidSyncBridgeEffects } from '../features/android/store/android-sync-bridge.effects';
import { AndroidWidgetEffects } from '../features/android/store/android-widget.effects';
import { MobileNotificationEffects } from '../features/mobile/store/mobile-notification.effects';
import { IS_IOS_NATIVE, IS_NATIVE_PLATFORM } from '../util/is-native-platform';
import { IosBackgroundTrackingEffects } from '../features/ios/store/ios-background-tracking.effects';
@ -184,6 +185,7 @@ import {
AndroidFocusModeEffects,
AndroidForegroundTrackingEffects,
AndroidSyncBridgeEffects,
AndroidWidgetEffects,
]
: []),
]),

View file

@ -106,6 +106,7 @@ const T = {
FOREGROUND_SERVICE_START_FAILED_TRACKING:
'F.ANDROID.FOREGROUND_SERVICE_START_FAILED_TRACKING',
OPEN_NOTIFICATION_SETTINGS: 'F.ANDROID.OPEN_NOTIFICATION_SETTINGS',
WIDGET_TASKS_UPDATED: 'F.ANDROID.WIDGET_TASKS_UPDATED',
},
ATTACHMENT: {
LOCAL_FILE_UNAVAILABLE: 'F.ATTACHMENT.LOCAL_FILE_UNAVAILABLE',

View file

@ -102,7 +102,8 @@
"ANDROID": {
"FOREGROUND_SERVICE_START_FAILED_FOCUS": "Android blocked the persistent focus mode notification. The timer continues in the app, but background alerts may be unavailable. Check notification permission and battery/background restrictions.",
"FOREGROUND_SERVICE_START_FAILED_TRACKING": "Android blocked the persistent time tracking notification. Time is still tracked in the app, but background recovery may be unavailable. Check notification permission and battery/background restrictions.",
"OPEN_NOTIFICATION_SETTINGS": "Open settings"
"OPEN_NOTIFICATION_SETTINGS": "Open settings",
"WIDGET_TASKS_UPDATED": "{{count}} task(s) updated from widget"
},
"ATTACHMENT": {
"LOCAL_FILE_UNAVAILABLE": "This attachment is only available on the device where it was added.",