feat(android): add quick add widget

This commit is contained in:
Johannes Millan 2025-12-31 12:45:17 +01:00
parent 25edb4ff1e
commit 4a89c05d32
14 changed files with 461 additions and 0 deletions

View file

@ -107,5 +107,25 @@
android:name=".receiver.ReminderActionReceiver"
android:enabled="true"
android:exported="false" />
<!-- Quick Add Widget -->
<receiver
android:name=".widget.QuickAddWidgetProvider"
android:exported="true"
android:label="@string/widget_quick_add_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_quick_add_info" />
</receiver>
<!-- Quick Add Dialog Activity -->
<activity
android:name=".widget.QuickAddActivity"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light.Dialog"
android:excludeFromRecents="true" />
</application>
</manifest>

View file

@ -13,6 +13,7 @@ import com.superproductivity.superproductivity.app.LaunchDecider
import com.superproductivity.superproductivity.service.FocusModeForegroundService
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
import com.superproductivity.superproductivity.service.TrackingForegroundService
import com.superproductivity.superproductivity.widget.WidgetTaskQueue
class JavaScriptInterface(
@ -192,6 +193,16 @@ class JavaScriptInterface(
ReminderNotificationHelper.cancelReminder(activity, notificationId)
}
/**
* Get queued tasks from the widget and clear the queue.
* Returns JSON string of tasks or null if empty.
*/
@Suppress("unused")
@JavascriptInterface
fun getWidgetTaskQueue(): String? {
return WidgetTaskQueue.getAndClearQueue(activity)
}
fun callJavaScriptFunction(script: String) {
webView.post { webView.evaluateJavascript(script) { } }
}

View file

@ -0,0 +1,64 @@
package com.superproductivity.superproductivity.widget
import android.app.Activity
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import com.superproductivity.superproductivity.R
/**
* A minimal floating dialog activity for quick task entry from the widget.
* Uses a dialog theme to appear as a floating window.
*/
class QuickAddActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_quick_add)
// Make it dialog-like with proper sizing
window.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
setFinishOnTouchOutside(true)
val editText = findViewById<EditText>(R.id.quick_add_input)
val addButton = findViewById<Button>(R.id.quick_add_submit)
addButton.setOnClickListener {
submitTask(editText)
}
// Handle keyboard "done" action
editText.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_DONE ||
(event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)
) {
submitTask(editText)
true
} else {
false
}
}
// Show keyboard automatically
editText.requestFocus()
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
}
private fun submitTask(editText: EditText) {
val title = editText.text.toString().trim()
if (title.isNotEmpty()) {
WidgetTaskQueue.addTask(this, title)
Toast.makeText(this, R.string.widget_task_added, Toast.LENGTH_SHORT).show()
finish()
} else {
Toast.makeText(this, R.string.widget_enter_title, Toast.LENGTH_SHORT).show()
}
}
}

View file

@ -0,0 +1,75 @@
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.widget.RemoteViews
import com.superproductivity.superproductivity.R
/**
* Widget provider for the quick-add task widget.
* Displays a button that opens the QuickAddActivity dialog.
*/
class QuickAddWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (widgetId in appWidgetIds) {
updateWidget(context, appWidgetManager, widgetId)
}
}
override fun onEnabled(context: Context) {
// Called when the first widget is created
}
override fun onDisabled(context: Context) {
// Called when the last widget is removed
}
companion object {
/**
* Update all instances of this widget.
*/
fun updateAllWidgets(context: Context) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetIds = appWidgetManager.getAppWidgetIds(
ComponentName(context, QuickAddWidgetProvider::class.java)
)
for (widgetId in widgetIds) {
updateWidget(context, appWidgetManager, widgetId)
}
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
widgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.widget_quick_add)
// Create intent to open QuickAddActivity
val intent = Intent(context, QuickAddActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
widgetId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Set click handler on the entire widget
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
appWidgetManager.updateAppWidget(widgetId, views)
}
}
}

View file

@ -0,0 +1,76 @@
package com.superproductivity.superproductivity.widget
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
/**
* Manages a queue of tasks created from the home screen widget.
* Tasks are stored in SharedPreferences and processed when the Angular app resumes.
*/
object WidgetTaskQueue {
private const val PREFS_NAME = "SuperProductivityWidget"
private const val KEY_TASK_QUEUE = "WIDGET_TASK_QUEUE"
private fun getPrefs(context: Context): SharedPreferences {
return context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
/**
* Add a task to the queue.
* @return The generated task ID
*/
fun addTask(context: Context, title: String): String {
val taskId = UUID.randomUUID().toString()
val task = JSONObject().apply {
put("id", taskId)
put("title", title.trim())
put("createdAt", System.currentTimeMillis())
}
val prefs = getPrefs(context)
val queueJson = prefs.getString(KEY_TASK_QUEUE, null)
val queue = if (queueJson != null) {
try {
JSONObject(queueJson)
} catch (e: Exception) {
JSONObject().put("tasks", JSONArray())
}
} else {
JSONObject().put("tasks", JSONArray())
}
val tasks = queue.optJSONArray("tasks") ?: JSONArray()
tasks.put(task)
queue.put("tasks", tasks)
prefs.edit().putString(KEY_TASK_QUEUE, queue.toString()).commit()
return taskId
}
/**
* Get all queued tasks and clear the queue atomically.
* @return JSON string of queued tasks, or null if empty
*/
fun getAndClearQueue(context: Context): String? {
val prefs = getPrefs(context)
val queueJson = prefs.getString(KEY_TASK_QUEUE, null)
if (queueJson != null) {
prefs.edit().remove(KEY_TASK_QUEUE).commit()
try {
val queue = JSONObject(queueJson)
val tasks = queue.optJSONArray("tasks")
if (tasks != null && tasks.length() > 0) {
return queueJson
}
} catch (e: Exception) {
// Invalid JSON, return null
}
}
return null
}
}

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Circle background -->
<item>
<shape android:shape="oval">
<solid android:color="#0B77D2" />
<stroke
android:width="2dp"
android:color="#FFFFFF" />
</shape>
</item>
<!-- Horizontal bar of plus -->
<item
android:top="11dp"
android:bottom="11dp"
android:left="5dp"
android:right="5dp">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="1dp" />
</shape>
</item>
<!-- Vertical bar of plus -->
<item
android:top="5dp"
android:bottom="5dp"
android:left="11dp"
android:right="11dp">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="1dp" />
</shape>
</item>
</layer-list>

View file

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

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- App icon as base -->
<item
android:drawable="@mipmap/ic_launcher"
android:gravity="center" />
<!-- Plus badge in bottom-right corner -->
<item
android:width="20dp"
android:height="20dp"
android:gravity="bottom|end"
android:bottom="2dp"
android:end="2dp">
<shape android:shape="oval">
<solid android:color="#0B77D2" />
<stroke
android:width="1dp"
android:color="#FFFFFF" />
</shape>
</item>
<!-- Plus sign -->
<item
android:width="20dp"
android:height="20dp"
android:gravity="bottom|end"
android:bottom="2dp"
android:end="2dp">
<layer-list>
<!-- Horizontal bar -->
<item
android:width="10dp"
android:height="2dp"
android:gravity="center">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="1dp" />
</shape>
</item>
<!-- Vertical bar -->
<item
android:width="2dp"
android:height="10dp"
android:gravity="center">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="1dp" />
</shape>
</item>
</layer-list>
</item>
</layer-list>

View file

@ -0,0 +1,32 @@
<?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="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_dialog_title"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<EditText
android:id="@+id/quick_add_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/widget_input_hint"
android:inputType="text"
android:imeOptions="actionDone"
android:maxLines="1"
android:layout_marginBottom="16dp" />
<Button
android:id="@+id/quick_add_submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_add_button" />
</LinearLayout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:padding="4dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@mipmap/ic_launcher"
android:scaleType="fitCenter"
android:contentDescription="@string/widget_add_task" />
<!-- Plus badge overlay -->
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="bottom|end"
android:layout_margin="2dp"
android:src="@drawable/ic_widget_plus_badge"
android:contentDescription="@null" />
</FrameLayout>

View file

@ -7,4 +7,15 @@
<string name="webview_block_provider">Provider package: %1$s</string>
<string name="webview_block_update">Update WebView</string>
<string name="webview_block_close">Close app</string>
<!-- Widget strings -->
<string name="widget_quick_add_label">Quick Add Task</string>
<string name="widget_description">Quickly add a task without opening the app</string>
<string name="widget_tap_to_add">Tap to add a task</string>
<string name="widget_add_task">Add task</string>
<string name="widget_dialog_title">Add Task</string>
<string name="widget_input_hint">What do you need to do?</string>
<string name="widget_add_button">Add</string>
<string name="widget_task_added">Task added</string>
<string name="widget_enter_title">Please enter a task title</string>
</resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:targetCellWidth="1"
android:targetCellHeight="1"
android:resizeMode="none"
android:widgetCategory="home_screen"
android:initialLayout="@layout/widget_quick_add"
android:previewImage="@mipmap/ic_launcher"
android:description="@string/widget_description"
android:updatePeriodMillis="0" />