Merge pull request #127 from donetick/add-photo-ai
Add Support for OCR and attachment on tasks, redesign AddTaskModal
This commit is contained in:
@@ -13,13 +13,16 @@ dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-device')
|
||||
implementation project(':capacitor-local-llm')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-document-scanner')
|
||||
implementation project(':capgo-capacitor-nfc')
|
||||
implementation project(':capgo-capacitor-social-login')
|
||||
implementation project(':jcesarmobile-capacitor-ocr')
|
||||
implementation project(':revenuecat-purchases-capacitor')
|
||||
implementation project(':revenuecat-purchases-capacitor-ui')
|
||||
implementation project(':capacitor-plugin-safe-area')
|
||||
|
||||
@@ -7,7 +7,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||
classpath 'com.android.tools.build:gradle:8.10.1'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
@@ -14,6 +14,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-device'
|
||||
project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
|
||||
|
||||
include ':capacitor-local-llm'
|
||||
project(':capacitor-local-llm').projectDir = new File('../node_modules/@capacitor/local-llm/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
@@ -29,12 +32,18 @@ project(':capacitor-push-notifications').projectDir = new File('../node_modules/
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
include ':capgo-capacitor-document-scanner'
|
||||
project(':capgo-capacitor-document-scanner').projectDir = new File('../node_modules/@capgo/capacitor-document-scanner/android')
|
||||
|
||||
include ':capgo-capacitor-nfc'
|
||||
project(':capgo-capacitor-nfc').projectDir = new File('../node_modules/@capgo/capacitor-nfc/android')
|
||||
|
||||
include ':capgo-capacitor-social-login'
|
||||
project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/@capgo/capacitor-social-login/android')
|
||||
|
||||
include ':jcesarmobile-capacitor-ocr'
|
||||
project(':jcesarmobile-capacitor-ocr').projectDir = new File('../node_modules/@jcesarmobile/capacitor-ocr/android')
|
||||
|
||||
include ':revenuecat-purchases-capacitor'
|
||||
project(':revenuecat-purchases-capacitor').projectDir = new File('../node_modules/@revenuecat/purchases-capacitor/android')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
minSdkVersion = 28
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
firebaseMessagingVersion = '24.1.0'
|
||||
}
|
||||
1457
package-lock.json
generated
1457
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -37,39 +37,42 @@
|
||||
"bump:patch": "node bump-version.js patch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "^7.0.0",
|
||||
"@capacitor/android": "^7.0.0",
|
||||
"@capacitor/app": "^7.0.0",
|
||||
"@capacitor/browser": "^7.0.2",
|
||||
"@capacitor/core": "^7.0.0",
|
||||
"@capacitor/device": "^7.0.0",
|
||||
"@capacitor/ios": "^7.0.0",
|
||||
"@capacitor/local-notifications": "^7.0.0",
|
||||
"@capacitor/network": "^7.0.1",
|
||||
"@capacitor/preferences": "^7.0.0",
|
||||
"@capacitor/push-notifications": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@capgo/capacitor-nfc": "^7.2.0",
|
||||
"@capgo/capacitor-social-login": "^7.5.3",
|
||||
"@capacitor-community/sqlite": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/browser": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/device": "^8.0.0",
|
||||
"@capacitor/ios": "^8.0.0",
|
||||
"@capacitor/local-llm": "^1.0.0",
|
||||
"@capacitor/local-notifications": "^8.0.0",
|
||||
"@capacitor/network": "^8.0.0",
|
||||
"@capacitor/preferences": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-document-scanner": "^8.4.0",
|
||||
"@capgo/capacitor-nfc": "^8.0.0",
|
||||
"@capgo/capacitor-social-login": "^8.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@jcesarmobile/capacitor-ocr": "^0.3.0",
|
||||
"@meauxt/react-swipeable-list": "^1.0.0",
|
||||
"@mui/icons-material": "^5.16.13",
|
||||
"@mui/joy": "^5.0.0-beta.20",
|
||||
"@mui/material": "^5.15.2",
|
||||
"@openreplay/tracker": "^14.0.4",
|
||||
"@revenuecat/purchases-capacitor": "^11.1.0",
|
||||
"@revenuecat/purchases-capacitor-ui": "^11.1.0",
|
||||
"@revenuecat/purchases-capacitor": "^12.0.0",
|
||||
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
|
||||
"@swc/core": "^1.12.5",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"aos": "^2.3.4",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"caniuse-lite": "^1.0.30001769",
|
||||
"capacitor-plugin-safe-area": "^4.0.0",
|
||||
"capacitor-plugin-safe-area": "^5.0.0",
|
||||
"chrono-node": "^2.7.7",
|
||||
"dotenv": "^16.4.5",
|
||||
"esm": "^3.2.25",
|
||||
@@ -95,11 +98,12 @@
|
||||
"reactjs-social-login": "^2.6.3",
|
||||
"recharts": "^2.15.0",
|
||||
"reusify": "^1.0.4",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@capacitor/cli": "^7.0.0",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.14.6",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
@@ -123,6 +127,10 @@
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.5.10",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^5.2.13"
|
||||
},
|
||||
"allowScripts": {
|
||||
"tesseract.js@7.0.0": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
||||
import useStickyState from '../hooks/useStickyState'
|
||||
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
import UserModal from '../views/Modals/Inputs/UserModal'
|
||||
import SubscriptionModal from './SubscriptionModal'
|
||||
|
||||
@@ -116,7 +116,7 @@ const UserProfileAvatar = () => {
|
||||
{isImpersonating ? (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
src={resolvePhotoURL(currentUser?.image)}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='md'
|
||||
sx={{
|
||||
@@ -127,7 +127,7 @@ const UserProfileAvatar = () => {
|
||||
}}
|
||||
/>
|
||||
<Avatar
|
||||
src={userProfile?.image || userProfile?.avatar}
|
||||
src={resolvePhotoURL(userProfile?.image || userProfile?.avatar)}
|
||||
alt={userProfile?.displayName || userProfile?.name}
|
||||
size='sm'
|
||||
sx={{
|
||||
@@ -162,7 +162,7 @@ const UserProfileAvatar = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='md'
|
||||
sx={{
|
||||
@@ -189,7 +189,7 @@ const UserProfileAvatar = () => {
|
||||
<Sheet sx={{ p: 2, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='lg'
|
||||
sx={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
||||
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
/**
|
||||
@@ -78,6 +78,7 @@ const FadeModal = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalClose />
|
||||
{title && (
|
||||
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{title}
|
||||
|
||||
50
src/hooks/useDocumentScanner.js
Normal file
50
src/hooks/useDocumentScanner.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
/**
|
||||
* Normalizes a raw image string from the native document scanner into a
|
||||
* format that can be used as an <img> src and passed to Tesseract.js.
|
||||
*
|
||||
* Android returns file:// or absolute paths → convert via Capacitor.convertFileSrc
|
||||
* iOS returns raw base64 (no data: prefix) → prepend the data URI scheme
|
||||
*/
|
||||
function normalizeScannedImage(raw) {
|
||||
if (!raw) return null
|
||||
if (raw.startsWith('data:')) return raw
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('content://')) return raw
|
||||
if (raw.startsWith('/') || raw.startsWith('file://')) return Capacitor.convertFileSrc(raw)
|
||||
// iOS base64 without prefix
|
||||
return `data:image/jpeg;base64,${raw}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for native document scanning via @capgo/capacitor-document-scanner.
|
||||
*
|
||||
* On native: opens the OS document scanner (edge detection, perspective correction).
|
||||
* On web: `scanDocument` returns null — callers should fall back to their own camera UI.
|
||||
*/
|
||||
export function useDocumentScanner() {
|
||||
const isNativeScanner = Capacitor.isNativePlatform()
|
||||
|
||||
const scanDocument = async ({ maxDocuments = 1, quality = 90, letUserAdjustCrop = true } = {}) => {
|
||||
if (!isNativeScanner) return { image: null, cancelled: false }
|
||||
|
||||
try {
|
||||
const { DocumentScanner } = await import('@capgo/capacitor-document-scanner')
|
||||
const { scannedImages } = await DocumentScanner.scanDocument({
|
||||
croppedImageQuality: quality,
|
||||
maxNumDocuments: maxDocuments,
|
||||
letUserAdjustCrop,
|
||||
})
|
||||
|
||||
if (!scannedImages?.length) return { image: null, cancelled: true }
|
||||
|
||||
const normalized = normalizeScannedImage(scannedImages[0])
|
||||
return { image: normalized, cancelled: false }
|
||||
} catch (e) {
|
||||
console.error('[DocumentScanner] scan failed:', e)
|
||||
return { image: null, cancelled: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
return { isNativeScanner, scanDocument }
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
|
||||
export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}) => {
|
||||
export const useFileUpload = ({ entityType = 'chore_attachment', entityId, draftId } = {}) => {
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
@@ -38,7 +38,8 @@ export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}
|
||||
const formData = new FormData()
|
||||
formData.append('file', compressedJpegFile)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId) formData.append('entityId', entityId)
|
||||
if (entityId) formData.append('entityId', String(entityId))
|
||||
if (draftId) formData.append('draftId', draftId)
|
||||
|
||||
const response = await apiClient.upload('/assets/chore', formData)
|
||||
|
||||
@@ -84,7 +85,7 @@ export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}
|
||||
return null
|
||||
}
|
||||
},
|
||||
[entityType, entityId, showError, userProfile],
|
||||
[entityType, entityId, draftId, showError, userProfile],
|
||||
)
|
||||
|
||||
return { uploadFile, isPlus: isPlusAccount(userProfile) }
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreateChore,
|
||||
DeleteChore,
|
||||
DeleteChoreHistory,
|
||||
GetChoreAttachments,
|
||||
GetChoreByID,
|
||||
GetChoreDetailById,
|
||||
GetChoreHistory,
|
||||
@@ -696,3 +697,19 @@ export const useRejectChore = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useChoreAttachments = (choreId, hasAttachments = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['choreAttachments', choreId],
|
||||
queryFn: async () => {
|
||||
const response = await GetChoreAttachments(choreId)
|
||||
if (response && response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
throw new Error('Failed to fetch attachments')
|
||||
},
|
||||
enabled: !!choreId && hasAttachments,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
gcTime: 15 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
72
src/service/AIPromptCache.js
Normal file
72
src/service/AIPromptCache.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const ENABLED_KEY = 'ai_prompt_cache_enabled'
|
||||
const ENTRY_PREFIX = 'ai_prompt_cache_'
|
||||
const INDEX_KEY = 'ai_prompt_cache_index'
|
||||
|
||||
function djb2(str) {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
|
||||
hash = hash >>> 0
|
||||
}
|
||||
return hash.toString(36)
|
||||
}
|
||||
|
||||
export function isCacheEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(ENABLED_KEY) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function setCacheEnabled(enabled) {
|
||||
try {
|
||||
localStorage.setItem(ENABLED_KEY, String(enabled))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function hashContent(content) {
|
||||
return djb2(typeof content === 'string' ? content : JSON.stringify(content))
|
||||
}
|
||||
|
||||
function getIndex() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(INDEX_KEY) || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getCached(hash) {
|
||||
if (!isCacheEnabled()) return null
|
||||
try {
|
||||
const raw = localStorage.getItem(ENTRY_PREFIX + hash)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setCached(hash, value) {
|
||||
if (!isCacheEnabled()) return
|
||||
try {
|
||||
localStorage.setItem(ENTRY_PREFIX + hash, JSON.stringify(value))
|
||||
const index = getIndex()
|
||||
if (!index.includes(hash)) {
|
||||
index.push(hash)
|
||||
localStorage.setItem(INDEX_KEY, JSON.stringify(index))
|
||||
}
|
||||
} catch { /* storage full, ignore */ }
|
||||
}
|
||||
|
||||
export function getCacheStats() {
|
||||
return { count: getIndex().length }
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
const index = getIndex()
|
||||
index.forEach(h => {
|
||||
try { localStorage.removeItem(ENTRY_PREFIX + h) } catch { /* ignore */ }
|
||||
})
|
||||
try { localStorage.removeItem(INDEX_KEY) } catch { /* ignore */ }
|
||||
}
|
||||
141
src/service/LocalAIService.js
Normal file
141
src/service/LocalAIService.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { getCached, hashContent, setCached } from './AIPromptCache'
|
||||
|
||||
// Native-only local AI service using @capacitor/local-llm.
|
||||
// On web, all methods return 'unavailable' / null — no WebLLM.
|
||||
class LocalAIService {
|
||||
constructor() {
|
||||
this._availability = null
|
||||
this._sessionId = 'donetick-summary'
|
||||
this._warmedUp = false
|
||||
}
|
||||
|
||||
get isNative() {
|
||||
return Capacitor.isNativePlatform()
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
if (!this.isNative) {
|
||||
this._availability = 'unavailable'
|
||||
return 'unavailable'
|
||||
}
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { status } = await LocalLLM.systemAvailability()
|
||||
this._availability = status
|
||||
return status
|
||||
} catch (e) {
|
||||
this._availability = 'unavailable'
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
if (this._availability !== null) return this._availability
|
||||
return this.checkAvailability()
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
return (await this.getStatus()) === 'available'
|
||||
}
|
||||
|
||||
resetAvailability() {
|
||||
this._availability = null
|
||||
}
|
||||
|
||||
async download(onStatusChange) {
|
||||
if (!this.isNative) return
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
if (onStatusChange) {
|
||||
LocalLLM.addListener('systemAvailabilityChange', ({ status }) => {
|
||||
this._availability = status
|
||||
onStatusChange(status)
|
||||
})
|
||||
}
|
||||
await LocalLLM.download()
|
||||
} catch {
|
||||
// download not available on iOS, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async warmup() {
|
||||
if (this._warmedUp || !this.isNative) return
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.warmup({ sessionId: this._sessionId })
|
||||
this._warmedUp = true
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
async _nativePrompt(text) {
|
||||
await this.warmup()
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { text: out } = await LocalLLM.prompt({ prompt: text, sessionId: this._sessionId })
|
||||
return out?.trim() || null
|
||||
} finally {
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||
this._warmedUp = false
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Plain chat — no tools. Returns answer string or null.
|
||||
async plainChat(messages) {
|
||||
const available = await this.isAvailable()
|
||||
if (!available) return null
|
||||
|
||||
const cacheHash = hashContent(['plain', ...messages])
|
||||
const cached = getCached(cacheHash)
|
||||
if (cached) return cached
|
||||
|
||||
if (!this.isNative) return null
|
||||
|
||||
try {
|
||||
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
|
||||
const userMsg = messages.find(m => m.role === 'user')?.content || ''
|
||||
const result = await this._nativePrompt(`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`)
|
||||
if (result) setCached(cacheHash, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.error('[LocalAI] plainChat() failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the summary string or null if LLM is unavailable
|
||||
async summarize(prompt) {
|
||||
const available = await this.isAvailable()
|
||||
if (!available) return null
|
||||
|
||||
const cacheHash = hashContent(prompt)
|
||||
const cached = getCached(cacheHash)
|
||||
if (cached) return cached
|
||||
|
||||
if (!this.isNative) return null
|
||||
|
||||
try {
|
||||
await this.warmup()
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { text } = await LocalLLM.prompt({ prompt, sessionId: this._sessionId })
|
||||
const result = text?.trim() || null
|
||||
if (result) setCached(cacheHash, result)
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||
this._warmedUp = false
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const localAIService = new LocalAIService()
|
||||
@@ -728,6 +728,30 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
|
||||
})
|
||||
}
|
||||
|
||||
const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId != null) formData.append('entityId', String(entityId))
|
||||
if (draftId != null) formData.append('draftId', draftId)
|
||||
return apiClient.upload('/assets/chore', formData)
|
||||
}
|
||||
|
||||
const GetChoreAttachments = choreId => {
|
||||
return Fetch(`/chores/${choreId}/attachments`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const DeleteChoreAttachment = (choreId, filePath) => {
|
||||
return Fetch(`/chores/${choreId}/attachments`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({ file_path: filePath }),
|
||||
})
|
||||
}
|
||||
|
||||
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
|
||||
return Fetch(`/backup/create`, {
|
||||
method: 'POST',
|
||||
@@ -933,6 +957,9 @@ const TrackFilterUsage = id => {
|
||||
|
||||
export {
|
||||
AcceptCircleMemberRequest,
|
||||
DeleteChoreAttachment,
|
||||
GetChoreAttachments,
|
||||
UploadChoreAttachment,
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
CancelSubscription,
|
||||
|
||||
@@ -10,9 +10,114 @@ const resolvePhotoURL = url => {
|
||||
if (url.startsWith('http') || url.startsWith('https')) {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('assets')) {
|
||||
return apiClient.getAssetURL(url)
|
||||
}
|
||||
return url
|
||||
return apiClient.getAssetURL(url)
|
||||
}
|
||||
export { isPlusAccount, resolvePhotoURL }
|
||||
|
||||
// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params.
|
||||
const isCloudSignedUrl = url => {
|
||||
if (!url) return false
|
||||
try {
|
||||
return (
|
||||
url.includes('X-Amz-Signature') ||
|
||||
url.includes('X-Amz-Expires') ||
|
||||
url.includes('X-Goog-Expires') ||
|
||||
url.includes('expires')
|
||||
)
|
||||
} catch(e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the storage key from a cloud signed URL so we can route it through
|
||||
// the backend proxy (which re-signs on every request and never expires).
|
||||
//
|
||||
// Handles:
|
||||
// Virtual-hosted S3: https://{bucket}.s3[.region].amazonaws.com/{key}?...
|
||||
// Path-style S3: https://s3[.region].amazonaws.com/{bucket}/{key}?...
|
||||
// Cloudflare R2: https://{bucket}.{accountid}.r2.cloudflarestorage.com/{key}?...
|
||||
// GCS: https://storage.googleapis.com/{bucket}/{key}?...
|
||||
// Azure Blob: https://{account}.blob.core.windows.net/{container}/{blob}?...
|
||||
//
|
||||
// The app stores files under an "assets/" prefix in the bucket but the backend
|
||||
// proxy already mounts at /assets/, so we strip that leading segment when present.
|
||||
const extractStorageKey = url => {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname
|
||||
const rawPath = u.pathname.replace(/^\//, '')
|
||||
|
||||
let key
|
||||
|
||||
if (host.endsWith('.r2.cloudflarestorage.com')) {
|
||||
// Virtual-hosted R2: bucket is in the host, key is the full path
|
||||
key = rawPath
|
||||
} else if (host.endsWith('.amazonaws.com')) {
|
||||
if (host.startsWith('s3') || host.includes('.s3.')) {
|
||||
// Path-style S3: first segment is the bucket — strip it
|
||||
key = rawPath.split('/').slice(1).join('/')
|
||||
} else {
|
||||
// Virtual-hosted S3: bucket is in the host, path is the key
|
||||
key = rawPath
|
||||
}
|
||||
} else if (host === 'storage.googleapis.com') {
|
||||
// First segment is the bucket
|
||||
key = rawPath.split('/').slice(1).join('/')
|
||||
} else if (host.endsWith('.blob.core.windows.net')) {
|
||||
// First segment is the container name
|
||||
key = rawPath.split('/').slice(1).join('/')
|
||||
} else {
|
||||
key = rawPath
|
||||
}
|
||||
|
||||
// The bucket stores files under an "assets/" prefix; the backend /assets/
|
||||
// endpoint already adds that prefix, so strip it to avoid duplication.
|
||||
if (key.startsWith('assets/')) {
|
||||
key = key.slice('assets/'.length)
|
||||
}
|
||||
|
||||
return key || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Scan an HTML string for <img> tags whose src is a cloud signed URL and
|
||||
// replace them with backend proxy URLs (which generate fresh signed URLs on
|
||||
// each request). Returns the patched HTML, or the original if nothing changed.
|
||||
const refreshSignedUrlsInHtml = html => {
|
||||
if (!html) return html
|
||||
if (
|
||||
!html.includes('dt-data-path') &&
|
||||
!html.includes('X-Amz-') &&
|
||||
!html.includes('X-Goog-') &&
|
||||
!html.includes('sig') &&
|
||||
!html.includes('.blob.core.windows.net')
|
||||
) {
|
||||
return html
|
||||
}
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const imgs = doc.querySelectorAll('img[src]')
|
||||
let changed = false
|
||||
|
||||
imgs.forEach(img => {
|
||||
const stablePath = img.getAttribute('dt-data-path')
|
||||
const src = img.getAttribute('src')
|
||||
let nextSrc = src
|
||||
|
||||
if (stablePath) {
|
||||
nextSrc = resolvePhotoURL(stablePath)
|
||||
} else if (isCloudSignedUrl(src)) {
|
||||
nextSrc = resolvePhotoURL(extractStorageKey(src))
|
||||
}
|
||||
|
||||
if (nextSrc && nextSrc !== src) {
|
||||
img.setAttribute('src', nextSrc)
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? doc.body.innerHTML : html
|
||||
}
|
||||
|
||||
export { isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Add, ArrowDropDown, HorizontalRule, Save } from '@mui/icons-material'
|
||||
import {
|
||||
Add,
|
||||
ArrowDropDown,
|
||||
AttachFile,
|
||||
Delete,
|
||||
HorizontalRule,
|
||||
Save,
|
||||
UploadFile,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -43,8 +51,13 @@ import {
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import {
|
||||
DeleteChoreAttachment,
|
||||
GetAllCircleMembers,
|
||||
GetThings,
|
||||
UploadChoreAttachment,
|
||||
} from '../../utils/Fetcher'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import Priorities from '../../utils/Priorities.jsx'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
@@ -53,6 +66,7 @@ import LoadingComponent from '../components/Loading.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import AttachmentViewerModal from '../Modals/Inputs/AttachmentViewerModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
@@ -117,7 +131,13 @@ const ChoreEdit = () => {
|
||||
const [createdBy, setCreatedBy] = useState(0)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [attemptToSave, setAttemptToSave] = useState(false)
|
||||
const [draftId] = useState(() => crypto.randomUUID())
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
|
||||
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
|
||||
const [attachmentViewerConfig, setAttachmentViewerConfig] = useState({
|
||||
isOpen: false,
|
||||
})
|
||||
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
|
||||
const [privacySaved, setPrivacySaved] = useState(false)
|
||||
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
|
||||
@@ -362,6 +382,7 @@ const ChoreEdit = () => {
|
||||
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
||||
priority: priority,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
draftId: newChoreId > 0 ? undefined : draftId,
|
||||
}
|
||||
let SaveFunction = createChoreMutation.mutateAsync
|
||||
if (newChoreId > 0) {
|
||||
@@ -576,6 +597,7 @@ const ChoreEdit = () => {
|
||||
|
||||
setCreatedBy(data.res.createdBy)
|
||||
setUpdatedBy(data.res.updatedBy)
|
||||
setAttachments(data.res.attachments || [])
|
||||
}
|
||||
}, [choreData, isChoreLoading, searchParams])
|
||||
|
||||
@@ -941,6 +963,175 @@ const ChoreEdit = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box mt={3}>
|
||||
<Typography level='h4'>Attachments</Typography>
|
||||
<Typography level='body-md'>Files attached to this task</Typography>
|
||||
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
|
||||
{attachments.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1.5 }}>
|
||||
{attachments.map((att, idx) => (
|
||||
<Box
|
||||
key={att.file_path || idx}
|
||||
onClick={() => {
|
||||
const url = resolvePhotoURL(att.sign || att.file_path)
|
||||
const ext = (att.file_name || '')
|
||||
.split('.')
|
||||
.pop()
|
||||
.toLowerCase()
|
||||
const isImage = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'svg',
|
||||
].includes(ext)
|
||||
if (isImage) {
|
||||
setAttachmentViewerConfig({
|
||||
isOpen: true,
|
||||
url,
|
||||
fileName: att.file_name,
|
||||
onClose: () =>
|
||||
setAttachmentViewerConfig({ isOpen: false }),
|
||||
})
|
||||
} else {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = att.file_name || 'attachment'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
borderRadius: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'neutral.softHoverBg' },
|
||||
}}
|
||||
>
|
||||
<AttachFile sx={{ fontSize: 18, color: 'neutral.500' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ flex: 1, wordBreak: 'break-all' }}
|
||||
>
|
||||
{att.file_name}
|
||||
</Typography>
|
||||
{att.size_bytes && (
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
{(att.size_bytes / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
{choreId && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
DeleteChoreAttachment(choreId, att.file_path)
|
||||
.then(() => {
|
||||
setAttachments(prev =>
|
||||
prev.filter(a => a.file_path !== att.file_path),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
showError({
|
||||
title: 'Delete Failed',
|
||||
message: 'Failed to delete attachment.',
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Delete sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
{!choreId && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setAttachments(prev =>
|
||||
prev.filter((_, i) => i !== idx),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Delete sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
component='label'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={
|
||||
isUploadingAttachment ? null : <UploadFile />
|
||||
}
|
||||
loading={isUploadingAttachment}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
Upload File
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
setIsUploadingAttachment(true)
|
||||
try {
|
||||
const response = choreId
|
||||
? await UploadChoreAttachment(file, 'chore_attachment', {
|
||||
entityId: choreId,
|
||||
})
|
||||
: await UploadChoreAttachment(
|
||||
file,
|
||||
'chore_attachment_draft',
|
||||
{ draftId },
|
||||
)
|
||||
if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
file_path: data.path,
|
||||
file_name: data.file_name,
|
||||
size_bytes: data.size_bytes,
|
||||
sign: data.sign,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploadingAttachment(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Section 2: Assignment & Responsibility */}
|
||||
@@ -1732,6 +1923,7 @@ const ChoreEdit = () => {
|
||||
)}
|
||||
</Button>
|
||||
</Sheet>
|
||||
<AttachmentViewerModal config={attachmentViewerConfig} />
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
{addLabelModalOpen && (
|
||||
<LabelModal
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Archive,
|
||||
AttachFile,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Checklist,
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
@@ -86,6 +88,7 @@ import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import TimePassedCard from './TimePassedCard.jsx'
|
||||
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||
import { refreshSignedUrlsInHtml } from '../../utils/Helpers.jsx'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
@@ -124,6 +127,7 @@ const ChoreView = () => {
|
||||
const [chorePriority, setChorePriority] = useState(null)
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
|
||||
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -132,6 +136,7 @@ const ChoreView = () => {
|
||||
const { data: choreData, isLoading: isChoreLoading } =
|
||||
useChoreDetails(choreId)
|
||||
const { data: choreHistoryData } = useChoreHistory(choreId)
|
||||
|
||||
const { data: pendingCmds } = usePendingCommands(choreId)
|
||||
|
||||
const choreHistory = choreHistoryData?.res || []
|
||||
@@ -651,16 +656,14 @@ const ChoreView = () => {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
mb: 1,
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{chore?.labelsV2?.map((label, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
ml: index === 0 ? 0 : 0.5,
|
||||
top: 2,
|
||||
zIndex: 1,
|
||||
backgroundColor: label?.color,
|
||||
color: getTextColorFromBackgroundColor(label?.color),
|
||||
}}
|
||||
@@ -668,6 +671,20 @@ const ChoreView = () => {
|
||||
{label?.name}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{chore?.attachments?.length > 0 && (
|
||||
<Chip
|
||||
startDecorator={<AttachFile />}
|
||||
size='md'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={() => setAttachmentBrowserOpen(true)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
{chore.attachments.length}{' '}
|
||||
{chore.attachments.length === 1 ? 'attachment' : 'attachments'}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -918,7 +935,7 @@ const ChoreView = () => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
@@ -986,7 +1003,7 @@ const ChoreView = () => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
@@ -1324,6 +1341,11 @@ const ChoreView = () => {
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
<ConfirmationModal config={timerActionConfig} />
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
<AttachmentBrowserModal
|
||||
choreId={choreId}
|
||||
isOpen={attachmentBrowserOpen}
|
||||
onClose={() => setAttachmentBrowserOpen(false)}
|
||||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
|
||||
138
src/views/Modals/Inputs/AttachmentBrowserModal.jsx
Normal file
138
src/views/Modals/Inputs/AttachmentBrowserModal.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { AttachFile, Close, Image } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, List, ListItem, ListItemButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
||||
import { resolvePhotoURL } from '../../../utils/Helpers'
|
||||
import AttachmentViewerModal from './AttachmentViewerModal'
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
|
||||
|
||||
const isImageFile = fileName => {
|
||||
if (!fileName) return false
|
||||
const ext = fileName.split('.').pop().toLowerCase()
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
const downloadFile = (url, fileName) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName || 'attachment'
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [viewerConfig, setViewerConfig] = useState({ isOpen: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !choreId) return
|
||||
setIsLoading(true)
|
||||
GetChoreAttachments(choreId)
|
||||
.then(async res => {
|
||||
if (!res.ok) throw new Error('Failed to fetch attachments')
|
||||
return res.json()
|
||||
})
|
||||
.then(data => setAttachments(Array.isArray(data) ? data : []))
|
||||
.catch(() => setAttachments([]))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [isOpen, choreId])
|
||||
|
||||
const handleClose = () => {
|
||||
setAttachments([])
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const handleAttachmentClick = attachment => {
|
||||
const url = resolvePhotoURL(attachment.sign)
|
||||
if (isImageFile(attachment.file_name)) {
|
||||
setViewerConfig({
|
||||
isOpen: true,
|
||||
url,
|
||||
fileName: attachment.file_name,
|
||||
onClose: () => setViewerConfig({ isOpen: false }),
|
||||
})
|
||||
} else {
|
||||
downloadFile(url, attachment.file_name)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveModal
|
||||
open={!!isOpen}
|
||||
onClose={handleClose}
|
||||
title='Attachments'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size='md' />
|
||||
</Box>
|
||||
) : attachments.length === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}
|
||||
>
|
||||
No attachments found.
|
||||
</Typography>
|
||||
) : (
|
||||
<List sx={{ '--ListItem-paddingX': '0px' }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<ListItem
|
||||
key={
|
||||
attachment.id ||
|
||||
attachment.file_path ||
|
||||
attachment.sign ||
|
||||
attachment.file_name ||
|
||||
index
|
||||
}
|
||||
sx={{ p: 0 }}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => handleAttachmentClick(attachment)}
|
||||
sx={{ borderRadius: 'sm', gap: 1.5, py: 1 }}
|
||||
>
|
||||
{isImageFile(attachment.file_name) ? (
|
||||
<Image fontSize='small' />
|
||||
) : (
|
||||
<AttachFile fontSize='small' />
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography level='body-sm' noWrap>
|
||||
{attachment.file_name || `File ${index + 1}`}
|
||||
</Typography>
|
||||
{attachment.size_bytes > 0 && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{(attachment.size_bytes / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
<AttachmentViewerModal config={viewerConfig} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentBrowserModal
|
||||
116
src/views/Modals/Inputs/AttachmentViewerModal.jsx
Normal file
116
src/views/Modals/Inputs/AttachmentViewerModal.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Close, Download } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
const openUrl = async url => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
await Browser.open({ url })
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
const downloadUrl = (url, fileName) => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
Browser.open({ url })
|
||||
} else {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName || 'attachment'
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
}
|
||||
|
||||
function AttachmentViewerModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [imgLoaded, setImgLoaded] = useState(false)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
|
||||
const { isOpen, url, fileName, onClose } = config || {}
|
||||
|
||||
const handleClose = () => {
|
||||
setImgLoaded(false)
|
||||
setImgError(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={!!isOpen}
|
||||
onClose={handleClose}
|
||||
title={fileName || 'Attachment'}
|
||||
maxHeight='92vh'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
startDecorator={<Download />}
|
||||
onClick={() => downloadUrl(url, fileName)}
|
||||
disabled={!url}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 200,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{!imgLoaded && !imgError && (
|
||||
<CircularProgress
|
||||
sx={{ position: 'absolute' }}
|
||||
size='md'
|
||||
/>
|
||||
)}
|
||||
{imgError ? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Failed to load image.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
component='img'
|
||||
src={url}
|
||||
alt={fileName}
|
||||
onClick={() => url && openUrl(url)}
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
onError={() => {
|
||||
setImgLoaded(true)
|
||||
setImgError(true)
|
||||
}}
|
||||
sx={{
|
||||
cursor: url ? 'zoom-in' : 'default',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '65vh',
|
||||
borderRadius: 'md',
|
||||
objectFit: 'contain',
|
||||
display: imgLoaded && !imgError ? 'block' : 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentViewerModal
|
||||
@@ -25,13 +25,12 @@ import SettingsLayout from './SettingsLayout'
|
||||
const ProfileSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const queryClient = useQueryClient()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
||||
const [timezone, setTimezone] = useState(
|
||||
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)
|
||||
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const fileInputRef = useRef()
|
||||
@@ -89,10 +88,9 @@ const ProfileSettings = () => {
|
||||
formData.append('file', compressedFile, 'profile.jpg')
|
||||
const response = await apiClient.upload('/users/profile_photo', formData)
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
const data = await response.json()
|
||||
const url = resolvePhotoURL(data.url || data.sign)
|
||||
await response.json()
|
||||
|
||||
setPhotoURL(url)
|
||||
refetchUserProfile() // Refresh user profile to get the new photoURL
|
||||
showSuccess({
|
||||
title: t('profile.photoUpdated'),
|
||||
message: t('profile.photoUpdatedMessage'),
|
||||
@@ -155,7 +153,7 @@ const ProfileSettings = () => {
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<Avatar src={photoURL} sx={{ width: 64, height: 64 }} />
|
||||
<Avatar src={resolvePhotoURL(userProfile?.image)} sx={{ width: 64, height: 64 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
|
||||
@@ -28,6 +28,8 @@ import DueDatePickerField from './DueDatePickerField'
|
||||
import LabelsPickerField from './LabelsPickerField'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
import NotificationPickerField from './NotificationPickerField'
|
||||
import ScanPanel from './ScanToTask/ScanPanel'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import PriorityPickerField from './PriorityPickerField'
|
||||
import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
@@ -103,6 +105,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject())
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [draftId, setDraftId] = useState(() => crypto.randomUUID())
|
||||
const [showScan, setShowScan] = useState(false)
|
||||
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
|
||||
const { isNativeScanner } = useDocumentScanner()
|
||||
|
||||
// Priority colors
|
||||
const priorityColors = {
|
||||
@@ -543,8 +549,26 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
createChore()
|
||||
}
|
||||
|
||||
const handleTaskExtracted = ({ taskName, description: extractedDesc, dueDate: extractedDue }) => {
|
||||
if (taskName) {
|
||||
processText(taskName)
|
||||
}
|
||||
if (extractedDesc) {
|
||||
setDescription(extractedDesc)
|
||||
setHasDescription(true)
|
||||
}
|
||||
if (extractedDue) {
|
||||
const m = moment(new Date(extractedDue))
|
||||
if (m.isValid()) {
|
||||
setDueDateOnly(m.format('YYYY-MM-DD'))
|
||||
setDueDate(m.endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseModal = forceRefetch => {
|
||||
onClose(forceRefetch)
|
||||
setShowScan(false)
|
||||
setTaskText('')
|
||||
setTaskTitle('')
|
||||
setDueDate(null)
|
||||
@@ -563,6 +587,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
setAttachments([])
|
||||
setDraftId(crypto.randomUUID())
|
||||
}
|
||||
|
||||
const createChore = () => {
|
||||
@@ -609,7 +635,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
notificationMetadata: {},
|
||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
attachments: attachments.length > 0 ? attachments : null,
|
||||
draftId: draftId,
|
||||
}
|
||||
|
||||
if (frequency) {
|
||||
@@ -664,6 +690,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveModal
|
||||
open={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
@@ -710,248 +737,263 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Task in a sentence:</Typography>
|
||||
<LearnMoreButton
|
||||
content={
|
||||
<>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This feature lets you create a task simply by typing a
|
||||
sentence. It attempt parses the sentence to identify the
|
||||
task's due date, priority, and frequency.
|
||||
</Typography>
|
||||
{!showScan && (
|
||||
<>
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Task in a sentence:</Typography>
|
||||
<LearnMoreButton
|
||||
content={
|
||||
<>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This feature lets you create a task simply by typing a
|
||||
sentence. It attempt parses the sentence to identify the
|
||||
task's due date, priority, and frequency.
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}>
|
||||
Examples:
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}>
|
||||
Examples:
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
component='ul'
|
||||
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
|
||||
>
|
||||
<li>
|
||||
<strong>Priority:</strong>For highest priority any of the
|
||||
following keyword <em>P1</em>, <em>Urgent</em>,{' '}
|
||||
<em>Important</em>, or <em>ASAP</em>. For lower priorities,
|
||||
use <em>P2</em>, <em>P3</em>, or <em>P4</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Due date:</strong> Specify dates with phrases like{' '}
|
||||
<em>tomorrow</em>, <em>next week</em>, <em>Monday</em>, or{' '}
|
||||
<em>August 1st at 12pm</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Frequency:</strong> Set recurring tasks with terms
|
||||
like <em>daily</em>, <em>weekly</em>, <em>monthly</em>,{' '}
|
||||
<em>yearly</em>, or patterns such as{' '}
|
||||
<em>every Tuesday and Thursday</em>.
|
||||
</li>
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
component='ul'
|
||||
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
|
||||
>
|
||||
<li>
|
||||
<strong>Priority:</strong>For highest priority any of the
|
||||
following keyword <em>P1</em>, <em>Urgent</em>,{' '}
|
||||
<em>Important</em>, or <em>ASAP</em>. For lower
|
||||
priorities, use <em>P2</em>, <em>P3</em>, or <em>P4</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Due date:</strong> Specify dates with phrases
|
||||
like <em>tomorrow</em>, <em>next week</em>,{' '}
|
||||
<em>Monday</em>, or <em>August 1st at 12pm</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Frequency:</strong> Set recurring tasks with
|
||||
terms like <em>daily</em>, <em>weekly</em>,{' '}
|
||||
<em>monthly</em>, <em>yearly</em>, or patterns such as{' '}
|
||||
<em>every Tuesday and Thursday</em>.
|
||||
</li>
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
value={taskText}
|
||||
placeholder='Type your task...'
|
||||
onChange={text => {
|
||||
setTaskText(text)
|
||||
}}
|
||||
customRenderer={renderedParts}
|
||||
onEnterPressed={handleEnterPressed}
|
||||
suggestions={{
|
||||
'#': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: userLabels ? userLabels : [],
|
||||
},
|
||||
'!': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: 'P1' },
|
||||
{ id: '2', name: 'P2' },
|
||||
{ id: '3', name: 'P3' },
|
||||
{ id: '4', name: 'P4' },
|
||||
],
|
||||
},
|
||||
'@': {
|
||||
value: 'userId',
|
||||
display: 'displayName',
|
||||
options: [
|
||||
{ userId: 'anyone', displayName: 'Anyone' },
|
||||
...(circleMembers?.res || []),
|
||||
],
|
||||
},
|
||||
'*': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: '1 point' },
|
||||
{ id: '5', name: '5 points' },
|
||||
{ id: '10', name: '10 points' },
|
||||
{ id: '25', name: '25 points' },
|
||||
{ id: '50', name: '50 points' },
|
||||
{ id: '100', name: '100 points' },
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1.5,
|
||||
|
||||
// scrollable horizontally but hide the scrollbar:
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
// if not mobile then go to next line if not enough space( show chip on next line):
|
||||
flexWrap: isMobile ? 'nowrap' : 'wrap',
|
||||
}}
|
||||
>
|
||||
<DueDatePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onDueDateChange={handleDueDateChange}
|
||||
onDueTimeChange={handleDueTimeChange}
|
||||
onUseCustomTimeChange={handleUseCustomTimeChange}
|
||||
onClear={() => {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
}}
|
||||
/>
|
||||
<RepeatPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
onClear={() => setFrequency(null)}
|
||||
/>
|
||||
<PriorityPickerField
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
/>
|
||||
<AttachmentPickerField
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onClear={() => setAttachments([])}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
entityType='chore_attachment'
|
||||
/>
|
||||
<NotificationPickerField
|
||||
value={notificationMetadata}
|
||||
onChange={setNotificationMetadata}
|
||||
onClear={() => setNotificationMetadata({ templates: [] })}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasDescription(true)
|
||||
// Focus will be handled by the useEffect hook
|
||||
}}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
|
||||
}
|
||||
>
|
||||
Description
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasSubTasks(true)
|
||||
}}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
|
||||
}
|
||||
>
|
||||
Subtasks
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasDescription && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Description:</Typography>
|
||||
<div>
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
onChange={setDescription}
|
||||
entityType={'chore_description'}
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
value={taskText}
|
||||
isNativeScanner={isNativeScanner}
|
||||
onScanClick={() => setShowScan(true)}
|
||||
onPhotoSelected={dataUrl => {
|
||||
setPendingPhotoUrl(dataUrl)
|
||||
setShowScan(true)
|
||||
}}
|
||||
placeholder='Type your task...'
|
||||
onChange={text => {
|
||||
setTaskText(text)
|
||||
if (!text) setTaskTitle('')
|
||||
}}
|
||||
customRenderer={renderedParts}
|
||||
onEnterPressed={handleEnterPressed}
|
||||
suggestions={{
|
||||
'#': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: userLabels ? userLabels : [],
|
||||
},
|
||||
'!': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: 'P1' },
|
||||
{ id: '2', name: 'P2' },
|
||||
{ id: '3', name: 'P3' },
|
||||
{ id: '4', name: 'P4' },
|
||||
],
|
||||
},
|
||||
'@': {
|
||||
value: 'userId',
|
||||
display: 'displayName',
|
||||
options: [
|
||||
{ userId: 'anyone', displayName: 'Anyone' },
|
||||
...(circleMembers?.res || []),
|
||||
],
|
||||
},
|
||||
'*': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: '1 point' },
|
||||
{ id: '5', name: '5 points' },
|
||||
{ id: '10', name: '10 points' },
|
||||
{ id: '25', name: '25 points' },
|
||||
{ id: '50', name: '50 points' },
|
||||
{ id: '100', name: '100 points' },
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
flexWrap: isMobile ? 'nowrap' : 'wrap',
|
||||
}}
|
||||
>
|
||||
<DueDatePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onDueDateChange={handleDueDateChange}
|
||||
onDueTimeChange={handleDueTimeChange}
|
||||
onUseCustomTimeChange={handleUseCustomTimeChange}
|
||||
onClear={() => {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
}}
|
||||
/>
|
||||
<RepeatPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
onClear={() => setFrequency(null)}
|
||||
/>
|
||||
<PriorityPickerField
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
/>
|
||||
<AttachmentPickerField
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onClear={() => setAttachments([])}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
entityType='chore_attachment_draft'
|
||||
draftId={draftId}
|
||||
/>
|
||||
<NotificationPickerField
|
||||
value={notificationMetadata}
|
||||
onChange={setNotificationMetadata}
|
||||
onClear={() => setNotificationMetadata({ templates: [] })}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasDescription(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
|
||||
}
|
||||
>
|
||||
Description
|
||||
</Button>
|
||||
)}
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasSubTasks(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
|
||||
}
|
||||
>
|
||||
Subtasks
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasDescription && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Description:</Typography>
|
||||
<div>
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
onChange={setDescription}
|
||||
value={description || ''}
|
||||
entityType={'chore_description'}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{hasSubTasks && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Subtasks:</Typography>
|
||||
<SubTasks
|
||||
editMode={true}
|
||||
tasks={subTasks ? subTasks : []}
|
||||
setTasks={setSubTasks}
|
||||
shouldFocus={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasSubTasks && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Subtasks:</Typography>
|
||||
<SubTasks
|
||||
editMode={true}
|
||||
tasks={subTasks ? subTasks : []}
|
||||
setTasks={setSubTasks}
|
||||
shouldFocus={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{showScan && (
|
||||
<ScanPanel
|
||||
open
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
initialImageUrl={pendingPhotoUrl}
|
||||
onClose={() => {
|
||||
setShowScan(false)
|
||||
setPendingPhotoUrl(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,12 @@ const AttachmentPickerField = ({
|
||||
emptyDisplay = 'icon-text',
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId })
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
595
src/views/components/PhotoTaskModal.jsx
Normal file
595
src/views/components/PhotoTaskModal.jsx
Normal file
@@ -0,0 +1,595 @@
|
||||
import {
|
||||
ArrowBack,
|
||||
CameraAlt,
|
||||
CheckCircle,
|
||||
Close,
|
||||
DocumentScanner,
|
||||
PhotoCamera,
|
||||
Replay,
|
||||
TextSnippet,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
|
||||
const SYSTEM_PROMPT = `You are helping create tasks for a household task management app.
|
||||
|
||||
Given OCR text extracted from a photo, identify the most useful task a person should add to their task list.
|
||||
|
||||
The task title should always start with an action verb when possible.
|
||||
|
||||
Examples:
|
||||
|
||||
Bill -> "Pay water bill"
|
||||
Appointment -> "Attend eye doctor appointment"
|
||||
Invitation -> "RSVP for wedding"
|
||||
Renewal Notice -> "Renew vehicle registration"
|
||||
Package Notice -> "Pick up package"
|
||||
School Form -> "Complete school permission form"
|
||||
|
||||
Action Priority Rules:
|
||||
|
||||
1. Payments and bills
|
||||
2. Deadlines and renewals
|
||||
3. Appointments
|
||||
4. Required forms
|
||||
5. Informational actions (view, read, review)
|
||||
|
||||
|
||||
Rules:
|
||||
|
||||
Generate at most one task.
|
||||
Focus on the most important action.
|
||||
Extract due dates and deadlines.
|
||||
Use appointment dates as due dates when appropriate.
|
||||
Do not invent information.
|
||||
If the content contains no actionable item, return null values.
|
||||
Include any important ID or URL or instructions in the description
|
||||
Titles must be specific and useful at a glance.
|
||||
Include the organization, provider, event, or subject when available.
|
||||
Avoid generic document names.
|
||||
Return valid JSON only.
|
||||
Output:
|
||||
|
||||
{
|
||||
"taskName": string | null,
|
||||
"description": string | null,
|
||||
"dueDate": string | null,
|
||||
"confidence": number
|
||||
}`
|
||||
|
||||
async function runNativeOCR(imageSource) {
|
||||
const { Ocr } = await import('@jcesarmobile/capacitor-ocr')
|
||||
|
||||
// Convert Capacitor WebView file URL → native file:// URL the plugin can read
|
||||
const image = imageSource.includes('/_capacitor_file_/')
|
||||
? 'file://' + imageSource.replace(/^https?:\/\/localhost\/_capacitor_file_/, '')
|
||||
: imageSource
|
||||
|
||||
const result = await Ocr.process({ image })
|
||||
return result.results.map(r => r.text).join('\n').trim()
|
||||
}
|
||||
|
||||
async function runOCR(imageSource, onProgress) {
|
||||
const { createWorker } = await import('tesseract.js')
|
||||
const worker = await createWorker('eng', 1, {
|
||||
logger: m => {
|
||||
if (m.status === 'recognizing text' && onProgress) {
|
||||
onProgress(Math.round(m.progress * 100))
|
||||
}
|
||||
},
|
||||
})
|
||||
const { data } = await worker.recognize(imageSource)
|
||||
await worker.terminate()
|
||||
return data.text?.trim() || ''
|
||||
}
|
||||
|
||||
async function extractTaskFromOCR(ocrText) {
|
||||
const messages = [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: `OCR Text:\n${ocrText}` },
|
||||
]
|
||||
|
||||
const result = await localAIService.plainChat(messages)
|
||||
if (!result) return null
|
||||
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonMatch[0])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
const videoRef = useRef(null)
|
||||
const canvasRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const [phase, setPhase] = useState('capture') // capture | preview | ocr | llm | done | error
|
||||
const [capturedImage, setCapturedImage] = useState(null)
|
||||
const [ocrProgress, setOcrProgress] = useState(0)
|
||||
const [ocrText, setOcrText] = useState('')
|
||||
const [ocrMethod, setOcrMethod] = useState('tesseract')
|
||||
const [showRawText, setShowRawText] = useState(false)
|
||||
const [taskResult, setTaskResult] = useState(null)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [cameraAvailable, setCameraAvailable] = useState(true)
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
})
|
||||
streamRef.current = stream
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream
|
||||
}
|
||||
setCameraAvailable(true)
|
||||
} catch {
|
||||
setCameraAvailable(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(t => t.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && phase === 'capture' && !isNativeScanner) {
|
||||
startCamera()
|
||||
}
|
||||
return () => {
|
||||
stopCamera()
|
||||
}
|
||||
}, [open, phase, isNativeScanner, startCamera, stopCamera])
|
||||
|
||||
const handleCapture = () => {
|
||||
if (!videoRef.current || !canvasRef.current) return
|
||||
const video = videoRef.current
|
||||
const canvas = canvasRef.current
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
canvas.getContext('2d').drawImage(video, 0, 0)
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9)
|
||||
setCapturedImage(dataUrl)
|
||||
stopCamera()
|
||||
setPhase('preview')
|
||||
}
|
||||
|
||||
const handleFileSelect = e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => {
|
||||
setCapturedImage(ev.target.result)
|
||||
stopCamera()
|
||||
setPhase('preview')
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleBackToPreview = () => {
|
||||
setOcrText('')
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setShowRawText(false)
|
||||
setPhase('preview')
|
||||
}
|
||||
|
||||
const handleProcess = async (method = 'tesseract') => {
|
||||
setOcrMethod(method)
|
||||
setPhase('ocr')
|
||||
setOcrProgress(0)
|
||||
setErrorMsg('')
|
||||
setShowRawText(false)
|
||||
|
||||
try {
|
||||
let text
|
||||
if (method === 'native') {
|
||||
try {
|
||||
text = await runNativeOCR(capturedImage)
|
||||
} catch {
|
||||
throw new Error('Native OCR is only available on iOS and Android devices.')
|
||||
}
|
||||
} else {
|
||||
text = await runOCR(capturedImage, pct => setOcrProgress(pct))
|
||||
}
|
||||
setOcrText(text)
|
||||
|
||||
if (!text) {
|
||||
setErrorMsg('No text found in the image. Please try a clearer photo.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('llm')
|
||||
const task = await extractTaskFromOCR(text)
|
||||
|
||||
if (!task || !task.taskName) {
|
||||
setErrorMsg('Could not identify a task from this image. Please try a different photo.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
setTaskResult(task)
|
||||
setPhase('done')
|
||||
} catch (e) {
|
||||
setErrorMsg(`Processing failed: ${e.message || 'Unknown error'}`)
|
||||
setPhase('error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetake = () => {
|
||||
setCapturedImage(null)
|
||||
setOcrText('')
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setShowRawText(false)
|
||||
setPhase('capture')
|
||||
}
|
||||
|
||||
const handleNativeScan = async () => {
|
||||
const { image, cancelled, error } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan cancelled or failed.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
setCapturedImage(image)
|
||||
stopCamera()
|
||||
setPhase('preview')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (taskResult) {
|
||||
onTaskExtracted(taskResult)
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopCamera()
|
||||
setCapturedImage(null)
|
||||
setOcrText('')
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setShowRawText(false)
|
||||
setPhase('capture')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const isProcessing = phase === 'ocr' || phase === 'llm'
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
size='md'
|
||||
fullWidth
|
||||
title='Scan photo to create task'
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{(phase === 'capture' || phase === 'preview') && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
borderRadius: 'md',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.level1',
|
||||
minHeight: 240,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{phase === 'capture' && !isNativeScanner && cameraAvailable && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
{phase === 'capture' && isNativeScanner && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<DocumentScanner sx={{ fontSize: 64, opacity: 0.4, mb: 1 }} />
|
||||
<Typography level='body-sm' sx={{ opacity: 0.6 }}>
|
||||
Tap "Scan Document" to open the scanner
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{phase === 'capture' && !isNativeScanner && !cameraAvailable && (
|
||||
<Box sx={{ textAlign: 'center', p: 3 }}>
|
||||
<CameraAlt sx={{ fontSize: 48, opacity: 0.5, mb: 1 }} />
|
||||
<Typography level='body-sm' sx={{ opacity: 0.7 }}>
|
||||
Camera not available
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{phase === 'preview' && capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Captured document'
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
|
||||
{isProcessing && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
{capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Processing'
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
opacity: 0.6,
|
||||
maxHeight: 200,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CircularProgress size='md' />
|
||||
{phase === 'ocr' && ocrMethod === 'tesseract' && (
|
||||
<>
|
||||
<Typography level='body-sm'>
|
||||
Reading text from image… {ocrProgress}%
|
||||
</Typography>
|
||||
<LinearProgress determinate value={ocrProgress} sx={{ width: '100%' }} />
|
||||
</>
|
||||
)}
|
||||
{phase === 'ocr' && ocrMethod === 'native' && (
|
||||
<Typography level='body-sm'>Running native OCR…</Typography>
|
||||
)}
|
||||
{phase === 'llm' && (
|
||||
<Typography level='body-sm'>Identifying task with AI…</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{phase === 'done' && taskResult && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle color='success' />
|
||||
<Typography level='title-sm'>Task identified</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 'md',
|
||||
bgcolor: 'background.level1',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm'>{taskResult.taskName}</Typography>
|
||||
{taskResult.description && (
|
||||
<Typography level='body-xs' sx={{ mt: 0.5, opacity: 0.8 }}>
|
||||
{taskResult.description}
|
||||
</Typography>
|
||||
)}
|
||||
{taskResult.dueDate && (
|
||||
<Typography level='body-xs' sx={{ mt: 0.5, opacity: 0.7 }}>
|
||||
Due: {taskResult.dueDate}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{ocrText && (
|
||||
<>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<TextSnippet />}
|
||||
onClick={() => setShowRawText(v => !v)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{showRawText ? 'Hide Raw Text' : 'Show Raw Text'}
|
||||
</Button>
|
||||
{showRawText && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 'md',
|
||||
bgcolor: 'background.level2',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
maxHeight: 180,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}
|
||||
>
|
||||
{ocrText}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 'md',
|
||||
bgcolor: 'danger.softBg',
|
||||
color: 'danger.softColor',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>{errorMsg}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
{phase === 'capture' && (
|
||||
<>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<PhotoCamera />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Upload Photo
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{isNativeScanner ? (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<DocumentScanner />}
|
||||
onClick={handleNativeScan}
|
||||
>
|
||||
Scan Document
|
||||
</Button>
|
||||
) : (
|
||||
cameraAvailable && (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<CameraAlt />}
|
||||
onClick={handleCapture}
|
||||
>
|
||||
Capture
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'preview' && (
|
||||
<>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay />}
|
||||
onClick={handleRetake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
{isNativeScanner && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
onClick={() => handleProcess('native')}
|
||||
>
|
||||
Process Natively
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => handleProcess('tesseract')}
|
||||
>
|
||||
Process Image
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<ArrowBack />}
|
||||
onClick={handleBackToPreview}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay />}
|
||||
onClick={handleRetake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<ArrowBack />}
|
||||
onClick={handleBackToPreview}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay />}
|
||||
onClick={handleRetake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' onClick={handleConfirm}>
|
||||
Create Task
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isProcessing && (
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleClose}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default PhotoTaskModal
|
||||
@@ -53,20 +53,33 @@
|
||||
}
|
||||
|
||||
/* Material-UI style customizations for Quill toolbar */
|
||||
.quill-root {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background-color: var(--joy-palette-neutral-softBg, #f0f4f8);
|
||||
transition: box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.quill-root:focus-within {
|
||||
box-shadow: 0 0 0 1px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
|
||||
}
|
||||
|
||||
.quill-root:hover:not(:focus-within) {
|
||||
background-color: var(--joy-palette-neutral-softHoverBg, #dde7ee);
|
||||
}
|
||||
|
||||
.quill-root .ql-toolbar.ql-snow {
|
||||
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: var(--joy-palette-background-surface, #fff);
|
||||
box-shadow: var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05));
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--joy-palette-neutral-softActiveBg, rgba(99, 107, 116, 0.16));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.quill-root .ql-container.ql-snow {
|
||||
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--joy-palette-background-surface, #fff);
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Style toolbar buttons with Material-UI look */
|
||||
|
||||
@@ -2,6 +2,33 @@ import imageCompression from 'browser-image-compression'
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import QuillMarkdown from 'quilljs-markdown'
|
||||
|
||||
// Extend the built-in Image blot to preserve dt-data-path
|
||||
const ImageBlot = Quill.import('formats/image')
|
||||
class DtImageBlot extends ImageBlot {
|
||||
static create(value) {
|
||||
const node = super.create(typeof value === 'string' ? value : value.src)
|
||||
if (value?.path) node.setAttribute('dt-data-path', value.path)
|
||||
return node
|
||||
}
|
||||
static value(node) {
|
||||
return { src: node.getAttribute('src'), path: node.getAttribute('dt-data-path') }
|
||||
}
|
||||
static formats(node) {
|
||||
return { 'dt-data-path': node.getAttribute('dt-data-path') }
|
||||
}
|
||||
format(name, value) {
|
||||
if (name === 'dt-data-path') {
|
||||
if (value) this.domNode.setAttribute('dt-data-path', value)
|
||||
else this.domNode.removeAttribute('dt-data-path')
|
||||
} else {
|
||||
super.format(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
DtImageBlot.blotName = 'image'
|
||||
DtImageBlot.tagName = 'img'
|
||||
Quill.register(DtImageBlot, true)
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
@@ -12,7 +39,11 @@ import {
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import {
|
||||
isPlusAccount,
|
||||
refreshSignedUrlsInHtml,
|
||||
resolvePhotoURL,
|
||||
} from '../../utils/Helpers'
|
||||
import './RichTextEditor.css'
|
||||
|
||||
const RichTextEditor = forwardRef(
|
||||
@@ -32,6 +63,7 @@ const RichTextEditor = forwardRef(
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const quillRef = useRef(null)
|
||||
const editorRef = useRef(null)
|
||||
const initialContentSet = useRef(false)
|
||||
|
||||
// Expose focus method to parent components
|
||||
useImperativeHandle(
|
||||
@@ -141,11 +173,16 @@ const RichTextEditor = forwardRef(
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
const url = resolvePhotoURL(data.url || data.sign)
|
||||
// Insert image into Quill
|
||||
// Prefer the backend-proxied path (data.sign) over the direct cloud
|
||||
// signed URL (data.url) — the proxy re-signs on every request so the
|
||||
// embedded src never expires.
|
||||
const path = data.path
|
||||
const url = resolvePhotoURL(data.sign || data.url)
|
||||
// Insert image into Quill with dt-data-path tracked by the custom blot
|
||||
const quill = editorRef.current
|
||||
const range = quill.getSelection()
|
||||
quill.insertEmbed(range ? range.index : 0, 'image', url)
|
||||
const insertIndex = range ? range.index : 0
|
||||
quill.insertEmbed(insertIndex, 'image', { src: url, path })
|
||||
} catch (error) {
|
||||
console.error('Error during image processing or upload:', error)
|
||||
showError({
|
||||
@@ -202,7 +239,11 @@ const RichTextEditor = forwardRef(
|
||||
useEffect(() => {
|
||||
if (editorRef.current && isEditable) {
|
||||
if (editorRef.current.root.innerHTML !== value) {
|
||||
editorRef.current.root.innerHTML = value || ''
|
||||
const html = !initialContentSet.current
|
||||
? refreshSignedUrlsInHtml(value || '')
|
||||
: value || ''
|
||||
initialContentSet.current = true
|
||||
editorRef.current.root.innerHTML = html
|
||||
}
|
||||
}
|
||||
}, [value, isEditable])
|
||||
@@ -227,7 +268,7 @@ const RichTextEditor = forwardRef(
|
||||
boxShadow:
|
||||
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: value }}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(value) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
309
src/views/components/ScanToTask/ScanPanel.jsx
Normal file
309
src/views/components/ScanToTask/ScanPanel.jsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
CameraAlt,
|
||||
DocumentScanner,
|
||||
PhotoCamera,
|
||||
Replay,
|
||||
WarningAmber,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect } from 'react'
|
||||
import { useScanToTask } from './useScanToTask'
|
||||
|
||||
/**
|
||||
* Inline scan-to-task panel. Mounts inside AddTaskModal — no second modal.
|
||||
*
|
||||
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
|
||||
* → error [retake or cancel]
|
||||
*/
|
||||
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
|
||||
const {
|
||||
isNativeScanner,
|
||||
phase,
|
||||
capturedImage,
|
||||
ocrProgress,
|
||||
taskResult,
|
||||
errorMsg,
|
||||
cameraAvailable,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
fileInputRef,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
capture,
|
||||
handleFileSelect,
|
||||
handleNativeScan,
|
||||
retake,
|
||||
activate,
|
||||
reset,
|
||||
} = useScanToTask()
|
||||
|
||||
// Start/stop based on open state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
activate(initialImageUrl)
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialImageUrl])
|
||||
|
||||
// Start camera when entering capture phase on web
|
||||
useEffect(() => {
|
||||
if (phase === 'capture' && !isNativeScanner) {
|
||||
startCamera()
|
||||
}
|
||||
if (phase !== 'capture') {
|
||||
stopCamera()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, isNativeScanner])
|
||||
|
||||
// Auto-close and populate when done
|
||||
useEffect(() => {
|
||||
if (phase === 'done' && taskResult) {
|
||||
onTaskExtracted(taskResult)
|
||||
onClose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, taskResult])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isProcessing = phase === 'processing'
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.outlinedBorder',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.level1',
|
||||
}}
|
||||
>
|
||||
{/* ── Capture phase ── */}
|
||||
{phase === 'capture' && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: 200,
|
||||
maxHeight: 300,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'neutral.900',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isNativeScanner && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<DocumentScanner
|
||||
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
Tap "Scan Document" to open the scanner
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isNativeScanner && cameraAvailable && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
maxHeight: 300,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isNativeScanner && !cameraAvailable && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<CameraAlt
|
||||
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
Camera not available — use Upload instead
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<PhotoCamera fontSize='small' />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isNativeScanner ? (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<DocumentScanner fontSize='small' />}
|
||||
onClick={handleNativeScan}
|
||||
>
|
||||
Scan Document
|
||||
</Button>
|
||||
) : (
|
||||
cameraAvailable && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<CameraAlt fontSize='small' />}
|
||||
onClick={capture}
|
||||
>
|
||||
Capture
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Processing phase ── */}
|
||||
{isProcessing && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
p: 2.5,
|
||||
}}
|
||||
>
|
||||
{capturedImage && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
borderRadius: 'sm',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Processing'
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
maxHeight: 180,
|
||||
objectFit: 'contain',
|
||||
opacity: 0.45,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size='md' />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography level='body-sm' sx={{ opacity: 0.7 }}>
|
||||
{ocrProgress > 0 && ocrProgress < 100
|
||||
? `Reading text… ${ocrProgress}%`
|
||||
: ocrProgress >= 100
|
||||
? 'Identifying task with AI…'
|
||||
: 'Starting…'}
|
||||
</Typography>
|
||||
|
||||
{ocrProgress > 0 && ocrProgress < 100 && (
|
||||
<LinearProgress
|
||||
determinate
|
||||
value={ocrProgress}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Error phase ── */}
|
||||
{phase === 'error' && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt='Failed scan'
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
borderRadius: 8,
|
||||
maxHeight: 160,
|
||||
objectFit: 'contain',
|
||||
opacity: 0.4,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
|
||||
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
|
||||
<Typography level='body-sm'>{errorMsg}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay fontSize='small' />}
|
||||
onClick={retake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScanPanel
|
||||
256
src/views/components/ScanToTask/useScanToTask.js
Normal file
256
src/views/components/ScanToTask/useScanToTask.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useDocumentScanner } from '../../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../../service/LocalAIService'
|
||||
|
||||
const SYSTEM_PROMPT = `You are helping create tasks for a household task management app.
|
||||
|
||||
Given OCR text extracted from a photo, identify the most useful task a person should add to their task list.
|
||||
|
||||
The task title should always start with an action verb when possible.
|
||||
|
||||
Examples:
|
||||
|
||||
Bill -> "Pay water bill"
|
||||
Appointment -> "Attend eye doctor appointment"
|
||||
Invitation -> "RSVP for wedding"
|
||||
Renewal Notice -> "Renew vehicle registration"
|
||||
Package Notice -> "Pick up package"
|
||||
School Form -> "Complete school permission form"
|
||||
|
||||
Action Priority Rules:
|
||||
|
||||
1. Payments and bills
|
||||
2. Deadlines and renewals
|
||||
3. Appointments
|
||||
4. Required forms
|
||||
5. Informational actions (view, read, review)
|
||||
|
||||
Rules:
|
||||
|
||||
Generate at most one task.
|
||||
Focus on the most important action.
|
||||
Extract due dates and deadlines.
|
||||
Use appointment dates as due dates when appropriate.
|
||||
Do not invent information.
|
||||
If the content contains no actionable item, return null values.
|
||||
Include any important ID or URL or instructions in the description.
|
||||
Titles must be specific and useful at a glance.
|
||||
Include the organization, provider, event, or subject when available.
|
||||
Avoid generic document names.
|
||||
Return valid JSON only.
|
||||
|
||||
Output:
|
||||
|
||||
{
|
||||
"taskName": string | null,
|
||||
"description": string | null,
|
||||
"dueDate": string | null,
|
||||
"confidence": number
|
||||
}`
|
||||
|
||||
async function runNativeOCR(imageSource) {
|
||||
const { Ocr } = await import('@jcesarmobile/capacitor-ocr')
|
||||
const image = imageSource.includes('/_capacitor_file_/')
|
||||
? 'file://' + imageSource.replace(/^https?:\/\/localhost\/_capacitor_file_/, '')
|
||||
: imageSource
|
||||
const result = await Ocr.process({ image })
|
||||
return result.results.map(r => r.text).join('\n').trim()
|
||||
}
|
||||
|
||||
async function runBrowserOCR(imageSource, onProgress) {
|
||||
const { createWorker } = await import('tesseract.js')
|
||||
const worker = await createWorker('eng', 1, {
|
||||
logger: m => {
|
||||
if (m.status === 'recognizing text' && onProgress) {
|
||||
onProgress(Math.round(m.progress * 100))
|
||||
}
|
||||
},
|
||||
})
|
||||
const { data } = await worker.recognize(imageSource)
|
||||
await worker.terminate()
|
||||
return data.text?.trim() || ''
|
||||
}
|
||||
|
||||
async function extractTaskFromOCR(ocrText) {
|
||||
const messages = [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: `OCR Text:\n${ocrText}` },
|
||||
]
|
||||
const result = await localAIService.plainChat(messages)
|
||||
if (!result) return null
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) return null
|
||||
try {
|
||||
return JSON.parse(jsonMatch[0])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// phases: idle | capture | processing | done | error
|
||||
export function useScanToTask() {
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
const videoRef = useRef(null)
|
||||
const canvasRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const [phase, setPhase] = useState('idle')
|
||||
const [capturedImage, setCapturedImage] = useState(null)
|
||||
const [ocrProgress, setOcrProgress] = useState(0)
|
||||
const [taskResult, setTaskResult] = useState(null)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [cameraAvailable, setCameraAvailable] = useState(true)
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
})
|
||||
streamRef.current = stream
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream
|
||||
}
|
||||
setCameraAvailable(true)
|
||||
} catch {
|
||||
setCameraAvailable(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(t => t.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const processImage = useCallback(async (imageData, method = 'browser') => {
|
||||
setPhase('processing')
|
||||
setOcrProgress(0)
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
let text
|
||||
if (method === 'native') {
|
||||
text = await runNativeOCR(imageData)
|
||||
} else {
|
||||
text = await runBrowserOCR(imageData, pct => setOcrProgress(pct))
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
setErrorMsg('No text found in image. Try a clearer photo.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
const task = await extractTaskFromOCR(text)
|
||||
if (!task || !task.taskName) {
|
||||
setErrorMsg('Could not identify a task. Try a different photo.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
setTaskResult(task)
|
||||
setPhase('done')
|
||||
} catch (e) {
|
||||
setErrorMsg(e.message || 'Processing failed.')
|
||||
setPhase('error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const capture = useCallback(() => {
|
||||
if (!videoRef.current || !canvasRef.current) return
|
||||
const video = videoRef.current
|
||||
const canvas = canvasRef.current
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
canvas.getContext('2d').drawImage(video, 0, 0)
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9)
|
||||
setCapturedImage(dataUrl)
|
||||
stopCamera()
|
||||
processImage(dataUrl, 'browser')
|
||||
}, [stopCamera, processImage])
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => {
|
||||
const dataUrl = ev.target.result
|
||||
setCapturedImage(dataUrl)
|
||||
stopCamera()
|
||||
processImage(dataUrl, 'browser')
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[stopCamera, processImage],
|
||||
)
|
||||
|
||||
const handleNativeScan = useCallback(async () => {
|
||||
const { image, cancelled, error } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan failed.')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
setCapturedImage(image)
|
||||
processImage(image, 'native')
|
||||
}, [scanDocument, processImage])
|
||||
|
||||
const retake = useCallback(() => {
|
||||
setCapturedImage(null)
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setPhase('capture')
|
||||
}, [])
|
||||
|
||||
const activate = useCallback(
|
||||
(initialImageUrl = null) => {
|
||||
setCapturedImage(initialImageUrl)
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
|
||||
if (initialImageUrl) {
|
||||
processImage(initialImageUrl, 'browser')
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('capture')
|
||||
},
|
||||
[processImage],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopCamera()
|
||||
setCapturedImage(null)
|
||||
setTaskResult(null)
|
||||
setErrorMsg('')
|
||||
setOcrProgress(0)
|
||||
setPhase('idle')
|
||||
}, [stopCamera])
|
||||
|
||||
return {
|
||||
isNativeScanner,
|
||||
phase,
|
||||
capturedImage,
|
||||
ocrProgress,
|
||||
taskResult,
|
||||
errorMsg,
|
||||
cameraAvailable,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
fileInputRef,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
capture,
|
||||
handleFileSelect,
|
||||
handleNativeScan,
|
||||
retake,
|
||||
activate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2em;
|
||||
font-family: inherit;
|
||||
caret-color: var(--highlight-date-color);
|
||||
}
|
||||
|
||||
.highlight-date {
|
||||
@@ -61,8 +60,18 @@
|
||||
.task-input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #d0d5dd);
|
||||
border: none;
|
||||
background-color: var(--joy-palette-neutral-softBg, #f0f4f8);
|
||||
overflow: auto;
|
||||
transition: box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.task-input:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
|
||||
}
|
||||
|
||||
.task-input:hover:not(:focus-within) {
|
||||
background-color: var(--joy-palette-neutral-softHoverBg, #dde7ee);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useColorScheme } from '@mui/joy'
|
||||
import { CameraEnhance, PhotoFilter } from '@mui/icons-material'
|
||||
import { IconButton, Tooltip, useColorScheme } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import AutocompleteDropdown from '../TestView/AutocompleteDropdown'
|
||||
import './SmartTaskTitleInput.css'
|
||||
@@ -52,9 +53,13 @@ const SmartTaskTitleInput = ({
|
||||
suggestions,
|
||||
onEnterPressed,
|
||||
customRenderer,
|
||||
isNativeScanner,
|
||||
onScanClick,
|
||||
onPhotoSelected,
|
||||
}) => {
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const titleInputRef = useRef(null)
|
||||
const photoInputRef = useRef(null)
|
||||
const [cursorPosition, setCursorPosition] = useState(value?.length)
|
||||
const dropdownRef = useRef(null)
|
||||
const [lastWord, setLastWord] = useState('')
|
||||
@@ -181,9 +186,26 @@ const SmartTaskTitleInput = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handlePhotoInputChange = e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !onPhotoSelected) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => onPhotoSelected(ev.target.result)
|
||||
reader.readAsDataURL(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const showNativeButtons = isNativeScanner && !value
|
||||
const MIC_BUTTON_WIDTH =
|
||||
showNativeButtons && onPhotoSelected && onScanClick
|
||||
? '5rem'
|
||||
: showNativeButtons
|
||||
? '2.5rem'
|
||||
: '0rem'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='task-input' style={{ minHeight: '2.4em' }}>
|
||||
<div className='task-input' style={{ minHeight: '2.8em' }}>
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
autoFocus={autoFocus}
|
||||
@@ -196,13 +218,12 @@ const SmartTaskTitleInput = ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
width: `calc(100% - ${MIC_BUTTON_WIDTH})`,
|
||||
height: '100%',
|
||||
// opacity: 100,
|
||||
zIndex: 1,
|
||||
resize: 'none',
|
||||
overflow: 'hidden',
|
||||
padding: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
boxSizing: 'border-box',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
@@ -212,6 +233,8 @@ const SmartTaskTitleInput = ({
|
||||
backgroundColor: 'transparent',
|
||||
color: 'transparent',
|
||||
caretColor: mode === 'dark' ? '#fff' : '#000',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -221,7 +244,7 @@ const SmartTaskTitleInput = ({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: '1.2em',
|
||||
padding: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'inherit',
|
||||
@@ -231,7 +254,12 @@ const SmartTaskTitleInput = ({
|
||||
onClick={handleDisplayClick}
|
||||
>
|
||||
{placeholder && !value && (
|
||||
<span className='pointer-events-none text-gray-400'>
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
color: 'var(--joy-palette-text-tertiary, #9fa6ad)',
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
@@ -241,6 +269,55 @@ const SmartTaskTitleInput = ({
|
||||
{/* Zero-width space to maintain consistent height */}
|
||||
​
|
||||
</div>
|
||||
{showNativeButtons && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '0.3rem',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
gap: '0.1rem',
|
||||
}}
|
||||
>
|
||||
{onPhotoSelected && (
|
||||
<>
|
||||
<Tooltip title='Select photo' placement='top' size='sm'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
sx={{ borderRadius: 'xl' }}
|
||||
>
|
||||
<PhotoFilter fontSize='small' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handlePhotoInputChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{onScanClick && (
|
||||
<Tooltip title='Scan to create task' placement='top' size='sm'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={onScanClick}
|
||||
sx={{ borderRadius: 'xl' }}
|
||||
>
|
||||
<CameraEnhance fontSize='small' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showSuggestions && (
|
||||
<AutocompleteDropdown
|
||||
|
||||
Reference in New Issue
Block a user