mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
1fde5cf664
commit
bf710637d2
31 changed files with 1189 additions and 5 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
12
android/app/src/main/res/drawable/ic_widget_check_done.xml
Normal file
12
android/app/src/main/res/drawable/ic_widget_check_done.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
6
android/app/src/main/res/drawable/widget_bg.xml
Normal file
6
android/app/src/main/res/drawable/widget_bg.xml
Normal 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>
|
||||
6
android/app/src/main/res/drawable/widget_project_dot.xml
Normal file
6
android/app/src/main/res/drawable/widget_project_dot.xml
Normal 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>
|
||||
65
android/app/src/main/res/layout/widget_task_list.xml
Normal file
65
android/app/src/main/res/layout/widget_task_list.xml
Normal 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>
|
||||
43
android/app/src/main/res/layout/widget_task_row.xml
Normal file
43
android/app/src/main/res/layout/widget_task_row.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
12
android/app/src/main/res/xml/appwidget_info.xml
Normal file
12
android/app/src/main/res/xml/appwidget_info.xml
Normal 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" />
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
80
docs/plans/2026-07-03-android-home-screen-widget.md
Normal file
80
docs/plans/2026-07-03-android-home-screen-widget.md
Normal 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`).
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
26
src/app/features/android/android-widget.model.ts
Normal file
26
src/app/features/android/android-widget.model.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
151
src/app/features/android/store/android-widget.effects.ts
Normal file
151
src/app/features/android/store/android-widget.effects.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
42
src/app/features/android/store/android-widget.selectors.ts
Normal file
42
src/app/features/android/store/android-widget.selectors.ts
Normal 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 };
|
||||
},
|
||||
);
|
||||
34
src/app/features/android/widget-data.service.ts
Normal file
34
src/app/features/android/widget-data.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue