From bf710637d26c4a1538fda014c56a8446e005752c Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 3 Jul 2026 18:32:34 +0200 Subject: [PATCH] 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> --- android/app/build.gradle | 3 + android/app/src/main/AndroidManifest.xml | 21 +++ .../CapacitorMainActivity.kt | 22 +++ .../superproductivity/app/KeyValStore.kt | 9 +- .../webview/JavaScriptInterface.kt | 22 +++ .../widget/TaskListWidgetProvider.kt | 131 +++++++++++++++ .../widget/TaskListWidgetService.kt | 102 ++++++++++++ .../superproductivity/widget/WidgetData.kt | 58 +++++++ .../widget/WidgetDoneQueue.kt | 63 ++++++++ .../res/drawable/ic_widget_check_done.xml | 12 ++ .../res/drawable/ic_widget_check_outline.xml | 12 ++ .../app/src/main/res/drawable/widget_bg.xml | 6 + .../main/res/drawable/widget_project_dot.xml | 6 + .../src/main/res/layout/widget_task_list.xml | 65 ++++++++ .../src/main/res/layout/widget_task_row.xml | 43 +++++ .../app/src/main/res/values-night/colors.xml | 7 + android/app/src/main/res/values/colors.xml | 9 ++ android/app/src/main/res/values/strings.xml | 7 + .../app/src/main/res/xml/appwidget_info.xml | 12 ++ .../widget/WidgetDataTest.kt | 75 +++++++++ .../2026-07-03-android-home-screen-widget.md | 80 ++++++++++ src/app/features/android/android-interface.ts | 13 ++ .../features/android/android-widget.model.ts | 26 +++ .../store/android-widget.effects.spec.ts | 66 ++++++++ .../android/store/android-widget.effects.ts | 151 ++++++++++++++++++ .../store/android-widget.selectors.spec.ts | 91 +++++++++++ .../android/store/android-widget.selectors.ts | 42 +++++ .../features/android/widget-data.service.ts | 34 ++++ src/app/root-store/feature-stores.module.ts | 2 + src/app/t.const.ts | 1 + src/assets/i18n/en.json | 3 +- 31 files changed, 1189 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt create mode 100644 android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetService.kt create mode 100644 android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt create mode 100644 android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetDoneQueue.kt create mode 100644 android/app/src/main/res/drawable/ic_widget_check_done.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_check_outline.xml create mode 100644 android/app/src/main/res/drawable/widget_bg.xml create mode 100644 android/app/src/main/res/drawable/widget_project_dot.xml create mode 100644 android/app/src/main/res/layout/widget_task_list.xml create mode 100644 android/app/src/main/res/layout/widget_task_row.xml create mode 100644 android/app/src/main/res/xml/appwidget_info.xml create mode 100644 android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt create mode 100644 docs/plans/2026-07-03-android-home-screen-widget.md create mode 100644 src/app/features/android/android-widget.model.ts create mode 100644 src/app/features/android/store/android-widget.effects.spec.ts create mode 100644 src/app/features/android/store/android-widget.effects.ts create mode 100644 src/app/features/android/store/android-widget.selectors.spec.ts create mode 100644 src/app/features/android/store/android-widget.selectors.ts create mode 100644 src/app/features/android/widget-data.service.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index a0b2df4258..e5126e2f94 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 15f66f5fc2..acd5a34b54 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -174,5 +174,26 @@ + + + + + + + + + + diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt index 63b416966c..96efce9322 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt @@ -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() } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/app/KeyValStore.kt b/android/app/src/main/java/com/superproductivity/superproductivity/app/KeyValStore.kt index e4337f9f5b..e7da7e5088 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/app/KeyValStore.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/app/KeyValStore.kt @@ -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() } } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt index 7c69552db9..f05d152d49 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt @@ -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. diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt new file mode 100644 index 0000000000..620c8cae81 --- /dev/null +++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetService.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetService.kt new file mode 100644 index 0000000000..0a153135af --- /dev/null +++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetService.kt @@ -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 = 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 + } +} diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt new file mode 100644 index 0000000000..1d3abc6f4b --- /dev/null +++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt @@ -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 = emptyMap() + ): List { + 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() + 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 + } +} diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetDoneQueue.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetDoneQueue.kt new file mode 100644 index 0000000000..48843a3e50 --- /dev/null +++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetDoneQueue.kt @@ -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 { + 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 + } +} diff --git a/android/app/src/main/res/drawable/ic_widget_check_done.xml b/android/app/src/main/res/drawable/ic_widget_check_done.xml new file mode 100644 index 0000000000..c589bf60c3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_check_done.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_widget_check_outline.xml b/android/app/src/main/res/drawable/ic_widget_check_outline.xml new file mode 100644 index 0000000000..3967ac30cb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_check_outline.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_bg.xml b/android/app/src/main/res/drawable/widget_bg.xml new file mode 100644 index 0000000000..eb6ff4c59e --- /dev/null +++ b/android/app/src/main/res/drawable/widget_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_project_dot.xml b/android/app/src/main/res/drawable/widget_project_dot.xml new file mode 100644 index 0000000000..8dc456c3bb --- /dev/null +++ b/android/app/src/main/res/drawable/widget_project_dot.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/layout/widget_task_list.xml b/android/app/src/main/res/layout/widget_task_list.xml new file mode 100644 index 0000000000..ac5ae8a2e3 --- /dev/null +++ b/android/app/src/main/res/layout/widget_task_list.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_task_row.xml b/android/app/src/main/res/layout/widget_task_row.xml new file mode 100644 index 0000000000..e49bce625c --- /dev/null +++ b/android/app/src/main/res/layout/widget_task_row.xml @@ -0,0 +1,43 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index db7621cf0a..1919e0913f 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -3,4 +3,11 @@ #131314 #131314 #131314 + + + #F5131314 + #E6E6E6 + #A6EBEBEB + #1AFFFFFF + #A05DB1 diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 691dac2c42..bb7b5f136d 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -8,4 +8,13 @@ #f8f8f7 #424242 + + #F5F8F8F7 + #DE000000 + #8A000000 + #1F000000 + #8B4A9D + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 73c7fc919e..43aa21be36 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -28,6 +28,13 @@ Content received, creating task\u2026 + + Today\'s Tasks + Today + Shows today\'s tasks with quick done action + No tasks for today + Toggle task done + Add task Add a task while loading\u2026 diff --git a/android/app/src/main/res/xml/appwidget_info.xml b/android/app/src/main/res/xml/appwidget_info.xml new file mode 100644 index 0000000000..555425d6e1 --- /dev/null +++ b/android/app/src/main/res/xml/appwidget_info.xml @@ -0,0 +1,12 @@ + + diff --git a/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt b/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt new file mode 100644 index 0000000000..111edf80ae --- /dev/null +++ b/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt @@ -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) + } +} diff --git a/docs/plans/2026-07-03-android-home-screen-widget.md b/docs/plans/2026-07-03-android-home-screen-widget.md new file mode 100644 index 0000000000..c025c59885 --- /dev/null +++ b/docs/plans/2026-07-03-android-home-screen-widget.md @@ -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`). diff --git a/src/app/features/android/android-interface.ts b/src/app/features/android/android-interface.ts index 8693684ec0..63793dbf4c 100644 --- a/src/app/features/android/android-interface.ts +++ b/src/app/features/android/android-interface.ts @@ -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; + // 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); diff --git a/src/app/features/android/android-widget.model.ts b/src/app/features/android/android-widget.model.ts new file mode 100644 index 0000000000..3cbe8484e9 --- /dev/null +++ b/src/app/features/android/android-widget.model.ts @@ -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 }; +} diff --git a/src/app/features/android/store/android-widget.effects.spec.ts b/src/app/features/android/store/android-widget.effects.spec.ts new file mode 100644 index 0000000000..f1e793ef99 --- /dev/null +++ b/src/app/features/android/store/android-widget.effects.spec.ts @@ -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 => + 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([]); + }); +}); diff --git a/src/app/features/android/store/android-widget.effects.ts b/src/app/features/android/store/android-widget.effects.ts new file mode 100644 index 0000000000..cfe94730e5 --- /dev/null +++ b/src/app/features/android/store/android-widget.effects.ts @@ -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, +): { 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) + .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): 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 }, + }); + } + } +} diff --git a/src/app/features/android/store/android-widget.selectors.spec.ts b/src/app/features/android/store/android-widget.selectors.spec.ts new file mode 100644 index 0000000000..8f64cfa4c8 --- /dev/null +++ b/src/app/features/android/store/android-widget.selectors.spec.ts @@ -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 => + ({ + 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"}}', + ); + }); +}); diff --git a/src/app/features/android/store/android-widget.selectors.ts b/src/app/features/android/store/android-widget.selectors.ts new file mode 100644 index 0000000000..e2ae443a8f --- /dev/null +++ b/src/app/features/android/store/android-widget.selectors.ts @@ -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 }; + }, +); diff --git a/src/app/features/android/widget-data.service.ts b/src/app/features/android/widget-data.service.ts new file mode 100644 index 0000000000..19305e491e --- /dev/null +++ b/src/app/features/android/widget-data.service.ts @@ -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 { + 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); + } + } +} diff --git a/src/app/root-store/feature-stores.module.ts b/src/app/root-store/feature-stores.module.ts index e64d2fa24d..63ffc3cc73 100644 --- a/src/app/root-store/feature-stores.module.ts +++ b/src/app/root-store/feature-stores.module.ts @@ -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, ] : []), ]), diff --git a/src/app/t.const.ts b/src/app/t.const.ts index f2aed74848..004d284671 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 69d3079ed2..23b6a0f12c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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.",