Add quick capture widget

This commit is contained in:
Mo Tarbin
2026-07-28 02:02:44 -04:00
parent ce49e4afc6
commit bb87dc2e14
12 changed files with 350 additions and 8 deletions

View File

@@ -72,6 +72,17 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_people_info" />
</receiver>
<receiver
android:name=".widget.QuickCaptureWidgetProvider"
android:exported="false"
android:label="@string/widget_quick_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_capture_info" />
</receiver>
<service
android:name=".widget.WidgetListService"
android:exported="false"

View File

@@ -0,0 +1,47 @@
package com.donetick.app.widget;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.widget.RemoteViews;
import com.donetick.app.R;
/**
* "Quick Capture" home-screen widget: three shortcuts straight into the
* add-task flow (type, scan, speak). Purely a launcher — it shows no task
* data, so it never needs a refresh cycle.
*/
public class QuickCaptureWidgetProvider extends AppWidgetProvider {
// Handled in src/CapacitorListener.js → /chores?add_task=1[&mode=…]
private static final String URI_TYPE = "donetick://chores/add";
private static final String URI_SCAN = "donetick://chores/add?mode=scan";
private static final String URI_VOICE = "donetick://chores/add?mode=voice";
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
for (int id : appWidgetIds) {
manager.updateAppWidget(id, build(context));
}
}
private static RemoteViews build(Context context) {
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_quick_capture);
views.setOnClickPendingIntent(R.id.quick_type, deepLink(context, 10, URI_TYPE));
views.setOnClickPendingIntent(R.id.quick_scan, deepLink(context, 11, URI_SCAN));
views.setOnClickPendingIntent(R.id.quick_voice, deepLink(context, 12, URI_VOICE));
return views;
}
private static PendingIntent deepLink(Context context, int requestCode, String uri) {
Intent intent = new Intent(context, com.donetick.app.MainActivity.class);
intent.setAction(Intent.ACTION_VIEW);
// Distinct data per tile so the three PendingIntents aren't collapsed.
intent.setData(Uri.parse(uri));
return PendingIntent.getActivity(context, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<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_accent">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14z" />
<path
android:fillColor="@android:color/white"
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92H17z" />
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<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_accent">
<!-- Framing corners plus a scan line: reads as "scan" at widget size. -->
<path
android:fillColor="@android:color/white"
android:pathData="M4,4h5v2H6v3H4V4zM15,4h5v5h-2V6h-3V4zM4,15h2v3h3v2H4V15zM18,15h2v5h-5v-2h3V15z" />
<path
android:fillColor="@android:color/white"
android:pathData="M3,11h18v2H3V11z" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Tappable tile on the Quick Capture widget. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_accent_soft" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Quick Capture widget: three one-tap ways into the add-task flow. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/quick_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:baselineAligned="false"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/quick_type"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/widget_tile_bg"
android:contentDescription="@string/widget_quick_type"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/widget_quick_type"
android:src="@drawable/ic_widget_add" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:maxLines="1"
android:text="@string/widget_quick_type"
android:textColor="@color/widget_accent"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/quick_scan"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile_bg"
android:contentDescription="@string/widget_quick_scan"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/widget_quick_scan"
android:src="@drawable/ic_widget_scan" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:maxLines="1"
android:text="@string/widget_quick_scan"
android:textColor="@color/widget_accent"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/quick_voice"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile_bg"
android:contentDescription="@string/widget_quick_voice"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/widget_quick_voice"
android:src="@drawable/ic_widget_mic" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:maxLines="1"
android:text="@string/widget_quick_voice"
android:textColor="@color/widget_accent"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -28,6 +28,11 @@
<string name="widget_people_title">People</string>
<string name="widget_empty_people">No circle members yet</string>
<string name="widget_person_counts">%1$d today · %2$d this week</string>
<string name="widget_quick_label">Quick Capture</string>
<string name="widget_quick_description">Capture a task in one tap — type it, scan it, or say it.</string>
<string name="widget_quick_type">Type</string>
<string name="widget_quick_scan">Scan</string>
<string name="widget_quick_voice">Speak</string>
<string name="widget_config_title">Widget options</string>
<string name="widget_config_include_others">Show everyone\'s tasks</string>
<string name="widget_config_include_others_hint">Include tasks assigned to other members of your circle. Their avatar appears next to their tasks.</string>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_quick_description"
android:initialLayout="@layout/widget_quick_capture"
android:minWidth="180dp"
android:minHeight="70dp"
android:minResizeWidth="180dp"
android:minResizeHeight="60dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />

View File

@@ -995,6 +995,105 @@ struct PeopleWidget: Widget {
}
}
// MARK: - Quick Capture widget
/// One of the three ways into the add-task flow. Purely a launcher the
/// destinations are handled in src/CapacitorListener.js.
private struct QuickCaptureAction: Identifiable {
let id: String
let title: String
let systemImage: String
let url: URL?
static let all: [QuickCaptureAction] = [
QuickCaptureAction(
id: "type",
title: "Type",
systemImage: "plus",
url: URL(string: "donetick://chores/add")
),
QuickCaptureAction(
id: "scan",
title: "Scan",
systemImage: "doc.viewfinder",
url: URL(string: "donetick://chores/add?mode=scan")
),
QuickCaptureAction(
id: "voice",
title: "Speak",
systemImage: "mic.fill",
url: URL(string: "donetick://chores/add?mode=voice")
),
]
}
private struct QuickCaptureTile: View {
let action: QuickCaptureAction
var body: some View {
let tile = VStack(spacing: 5) {
Image(systemName: action.systemImage)
.font(.system(size: 22, weight: .medium))
.foregroundColor(Palette.accent)
Text(action.title)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Palette.accent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.accentSoft)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
if let url = action.url {
Link(destination: url) { tile }
} else {
tile
}
}
}
struct QuickCaptureEntry: TimelineEntry {
let date: Date
}
/// Static content one entry, never reloaded.
struct QuickCaptureProvider: TimelineProvider {
func placeholder(in context: Context) -> QuickCaptureEntry {
QuickCaptureEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (QuickCaptureEntry) -> Void) {
completion(QuickCaptureEntry(date: Date()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<QuickCaptureEntry>) -> Void) {
completion(Timeline(entries: [QuickCaptureEntry(date: Date())], policy: .never))
}
}
struct QuickCaptureWidgetView: View {
var body: some View {
HStack(spacing: 8) {
ForEach(QuickCaptureAction.all) { action in
QuickCaptureTile(action: action)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct QuickCaptureWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "DonetickQuickCaptureWidget", provider: QuickCaptureProvider()) { _ in
QuickCaptureWidgetView().widgetShell()
}
.configurationDisplayName("Quick Capture")
.description("Capture a task in one tap — type it, scan it, or say it.")
// Medium only: systemSmall gives the whole widget a single tap target,
// which can't carry three separate destinations.
.supportedFamilies([.systemMedium])
}
}
// MARK: - Bundle
@main
@@ -1003,5 +1102,6 @@ struct DonetickWidgetBundle: WidgetBundle {
TodayWidget()
WeekWidget()
PeopleWidget()
QuickCaptureWidget()
}
}

View File

@@ -63,9 +63,20 @@ const handleNFCChoreDeepLink = (url, isColdStart) => {
const handleUrlOpen = (url, isColdStart = false) => {
console.log('[NFC] handleUrlOpen:', url)
if (url.startsWith('donetick://chores/add')) {
// Widget "+" button: land on the chore list with the quick-add modal open
// (MyChores watches for the add_task param and consumes it).
routerNavigate('/chores?add_task=1')
// Widget "+" / quick-capture buttons: land on the chore list with the
// quick-add modal open (MyChores watches for the add_task param and
// consumes it). ?mode=scan|voice opens straight into that capture panel.
let mode = null
try {
mode = new URL(url).searchParams.get('mode')
} catch {
// malformed URL — fall back to plain text capture
}
routerNavigate(
mode === 'scan' || mode === 'voice'
? `/chores?add_task=1&mode=${mode}`
: '/chores?add_task=1',
)
} else if (url.startsWith('donetick://chores/')) {
handleNFCChoreDeepLink(url, isColdStart)
} else if (url.startsWith('donetick://auth/')) {

View File

@@ -97,6 +97,8 @@ const MyChores = () => {
const [filteredChores, setFilteredChores] = useState([])
const [choreSections, setChoreSections] = useState([])
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
// 'voice' | 'scan' | null — set by the quick-capture widget deep links
const [addTaskInitialMode, setAddTaskInitialMode] = useState(null)
const [taskInputFocus, setTaskInputFocus] = useState(0)
const searchInputRef = useRef(null)
const [searchInputFocus, setSearchInputFocus] = useState(0)
@@ -439,14 +441,18 @@ const MyChores = () => {
setSelectedProjectWithCache,
])
// Widget "+" deep link (donetick://chores/add → /chores?add_task=1):
// open the quick-add modal once and strip the param so back/refresh
// doesn't re-trigger it.
// Widget deep links (donetick://chores/add[?mode=scan|voice] →
// /chores?add_task=1[&mode=…]): open the quick-add modal once, in the
// requested capture mode, and strip the params so back/refresh doesn't
// re-trigger it.
useEffect(() => {
if (searchParams.get('add_task') === '1') {
const mode = searchParams.get('mode')
setAddTaskInitialMode(mode === 'voice' || mode === 'scan' ? mode : null)
setAddTaskModalOpen(true)
const params = new URLSearchParams(searchParams)
params.delete('add_task')
params.delete('mode')
setSearchParams(params, { replace: true })
}
}, [searchParams, setSearchParams])
@@ -1455,8 +1461,10 @@ const MyChores = () => {
autoFocus={taskInputFocus}
onChoreUpdate={updateChores}
isModalOpen={addTaskModalOpen}
initialMode={addTaskInitialMode}
onClose={forceRefresh => {
setAddTaskModalOpen(false)
setAddTaskInitialMode(null)
if (forceRefresh) {
refetchChores()
}

View File

@@ -147,17 +147,19 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
}
if (appliedInitialModeRef.current) return
// Availability resolves async — wait for the answer before deciding; if it
// never turns true the modal simply stays in plain text mode.
if (initialMode === 'voice') {
// isSupported() resolves async — wait for the answer before deciding.
if (!voiceAvailable) return
appliedInitialModeRef.current = true
setShowVoice(true)
} else if (initialMode === 'scan') {
if (!llmAvailable) return
appliedInitialModeRef.current = true
setScanAutoCapture(true)
setShowScan(true)
}
}, [isModalOpen, initialMode, voiceAvailable])
}, [isModalOpen, initialMode, voiceAvailable, llmAvailable])
// Priority colors
const priorityColors = {