Add quick capture widget
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
android/app/src/main/res/drawable/ic_widget_mic.xml
Normal file
14
android/app/src/main/res/drawable/ic_widget_mic.xml
Normal 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>
|
||||
15
android/app/src/main/res/drawable/ic_widget_scan.xml
Normal file
15
android/app/src/main/res/drawable/ic_widget_scan.xml
Normal 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>
|
||||
6
android/app/src/main/res/drawable/widget_tile_bg.xml
Normal file
6
android/app/src/main/res/drawable/widget_tile_bg.xml
Normal 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>
|
||||
110
android/app/src/main/res/layout/widget_quick_capture.xml
Normal file
110
android/app/src/main/res/layout/widget_quick_capture.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
13
android/app/src/main/res/xml/widget_quick_capture_info.xml
Normal file
13
android/app/src/main/res/xml/widget_quick_capture_info.xml
Normal 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" />
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/')) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user