diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 2f15d14..1588034 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -72,6 +72,17 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_people_info" />
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_widget_scan.xml b/android/app/src/main/res/drawable/ic_widget_scan.xml
new file mode 100644
index 0000000..e80c7cf
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_scan.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_tile_bg.xml b/android/app/src/main/res/drawable/widget_tile_bg.xml
new file mode 100644
index 0000000..b7cb091
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_tile_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_quick_capture.xml b/android/app/src/main/res/layout/widget_quick_capture.xml
new file mode 100644
index 0000000..957c659
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_quick_capture.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 507bc36..b7eba15 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -28,6 +28,11 @@
People
No circle members yet
%1$d today · %2$d this week
+ Quick Capture
+ Capture a task in one tap — type it, scan it, or say it.
+ Type
+ Scan
+ Speak
Widget options
Show everyone\'s tasks
Include tasks assigned to other members of your circle. Their avatar appears next to their tasks.
diff --git a/android/app/src/main/res/xml/widget_quick_capture_info.xml b/android/app/src/main/res/xml/widget_quick_capture_info.xml
new file mode 100644
index 0000000..9238cbb
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_quick_capture_info.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/ios/App/DonetickWidget/DonetickWidget.swift b/ios/App/DonetickWidget/DonetickWidget.swift
index aefa958..f32ef73 100644
--- a/ios/App/DonetickWidget/DonetickWidget.swift
+++ b/ios/App/DonetickWidget/DonetickWidget.swift
@@ -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) -> 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()
}
}
diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js
index fd8d195..7ab69b3 100644
--- a/src/CapacitorListener.js
+++ b/src/CapacitorListener.js
@@ -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/')) {
diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
index bc9b546..05a1150 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -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()
}
diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx
index 75dabc7..2030efe 100644
--- a/src/views/components/AddTaskModal.jsx
+++ b/src/views/components/AddTaskModal.jsx
@@ -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 = {