Merge pull request #127 from donetick/add-photo-ai

Add Support for OCR and attachment on tasks, redesign AddTaskModal
This commit is contained in:
Mohamad Tarbin
2026-07-07 00:25:35 -04:00
committed by GitHub
30 changed files with 3529 additions and 819 deletions

View File

@@ -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')

View File

@@ -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

View File

@@ -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')

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}

View File

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

View File

@@ -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}

View 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 }
}

View File

@@ -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) }

View File

@@ -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,
})
}

View 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 */ }
}

View 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()

View File

@@ -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,

View File

@@ -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 }

View File

@@ -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

View File

@@ -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>
)

View 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

View 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

View File

@@ -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'

View File

@@ -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&apos;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&apos;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>
</>
)
}

View File

@@ -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

View 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 &quot;Scan Document&quot; 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

View File

@@ -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 */

View File

@@ -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) }}
/>
)
}

View 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 &quot;Scan Document&quot; 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

View 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,
}
}

View File

@@ -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);
}

View File

@@ -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 */}
&#8203;
</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