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 = {