F1-F4: Inline editor AI - Backend: expand CopilotAction with fix-rewrite, change-tone, continue, explain - Backend: add tone parameter to copilot route for change-tone action - Web: copilot-client adds CopilotTone type and tone parameter - Web: NoteEditor toolbar gains AI row with Fix & Rewrite, Change Tone dropdown, Continue Writing (appends at cursor), Explain (inline popover) F15-F19: Mobile capture enhancements - Backend: POST /note-prompts/url-extract endpoint (fetch, strip HTML, LLM summarize) - Mobile API: extractFromUrl() and copilotTransform() client functions - Mobile: capture tab rewritten with 6 capture modes grid (text, photo, voice, URL, scan, paste) — URL extract + clipboard paste fully wired, camera/voice/scan surface native permission prompts (require expo-av/expo-image-picker) - expo-clipboard added as dependency F25-F27: Scheduled actions, webhook triggers, approval-gated actions - New scheduler.ts module with PromptScheduleDoc + PromptWebhookDoc types - Schedule CRUD: GET/POST/PATCH/DELETE /prompt-schedules - Webhook CRUD: GET/POST/PATCH/DELETE /prompt-webhooks - POST /prompt-webhooks/:id/trigger — execute template against note - Scheduler loop (60s tick) with cron next-run calculation - Diagnostics endpoint: GET /prompt-schedules/diagnostics - Cosmos containers: note_prompt_schedules, note_prompt_webhooks - PromptTemplateDoc gains requiresApproval field (F27) - Runner produces approvalState: proposed|applied based on template flag - Create/Update schemas accept requiresApproval boolean
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
import * as Clipboard from 'expo-clipboard';
|
|
import type { MobileWorkspace } from '../../api/workspaces';
|
|
import { useNotesStore, type NotesState } from '../../store/notes-store';
|
|
import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store';
|
|
import { extractFromUrl, copilotTransform } from '../../api/note-prompts';
|
|
import { colors } from '../../theme';
|
|
|
|
type CaptureMode = 'text' | 'photo' | 'voice' | 'url' | 'scan' | 'paste';
|
|
|
|
const CAPTURE_MODES: { mode: CaptureMode; label: string; icon: string; description: string }[] = [
|
|
{ mode: 'text', label: 'Text', icon: '✏️', description: 'Type a quick note' },
|
|
{ mode: 'photo', label: 'Photo', icon: '📷', description: 'Capture from camera' },
|
|
{ mode: 'voice', label: 'Voice', icon: '🎙️', description: 'Record & transcribe' },
|
|
{ mode: 'url', label: 'URL', icon: '🔗', description: 'Extract from web page' },
|
|
{ mode: 'scan', label: 'Scan', icon: '📄', description: 'Scan multi-page doc' },
|
|
{ mode: 'paste', label: 'Paste', icon: '📋', description: 'Paste & clean up' },
|
|
];
|
|
|
|
export default function CaptureScreen() {
|
|
const [mode, setMode] = useState<CaptureMode>('text');
|
|
const [title, setTitle] = useState('');
|
|
const [body, setBody] = useState('');
|
|
const [urlInput, setUrlInput] = useState('');
|
|
const [saved, setSaved] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const saveDraft = useNotesStore((state: NotesState) => state.saveDraft);
|
|
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
|
|
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
|
|
const setActiveWorkspace = useWorkspaceStore((state: WorkspaceState) => state.setActiveWorkspace);
|
|
const activeWorkspaceName =
|
|
workspaces.find((workspace: MobileWorkspace) => workspace.id === activeWorkspaceId)?.name ?? 'Drafts';
|
|
|
|
const resetForm = () => {
|
|
setTitle('');
|
|
setBody('');
|
|
setUrlInput('');
|
|
setSaved(false);
|
|
setError(null);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!activeWorkspaceId) return;
|
|
setBusy(true);
|
|
try {
|
|
const didSave = await saveDraft(activeWorkspaceId, title, body);
|
|
setSaved(didSave);
|
|
if (didSave) resetForm();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Save failed');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleUrlExtract = async () => {
|
|
if (!activeWorkspaceId || !urlInput.trim()) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const result = await extractFromUrl(urlInput.trim(), activeWorkspaceId);
|
|
setTitle(result.title);
|
|
setBody(result.content);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'URL extraction failed');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handlePasteAndClean = async () => {
|
|
if (!activeWorkspaceId) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const clipText = await Clipboard.getStringAsync();
|
|
if (!clipText?.trim()) {
|
|
setError('Clipboard is empty');
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
// Check if it looks like a URL
|
|
if (/^https?:\/\//.test(clipText.trim())) {
|
|
setUrlInput(clipText.trim());
|
|
setMode('url');
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
setBody(clipText);
|
|
setTitle('Pasted note');
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Paste failed');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleVoiceCapture = () => {
|
|
Alert.alert('Voice Capture', 'Voice recording requires expo-av. Install expo-av and grant microphone permission to enable this feature.', [{ text: 'OK' }]);
|
|
};
|
|
|
|
const handlePhotoCapture = () => {
|
|
Alert.alert('Photo Capture', 'Camera capture requires expo-image-picker. Install the package and grant camera permission to enable this feature.', [{ text: 'OK' }]);
|
|
};
|
|
|
|
const handleScanCapture = () => {
|
|
Alert.alert('Document Scan', 'Multi-page scanning requires expo-image-picker with continuous mode. Install the package to enable this feature.', [{ text: 'OK' }]);
|
|
};
|
|
|
|
return (
|
|
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
|
|
<Text style={styles.title}>Quick capture</Text>
|
|
|
|
{/* Workspace selector */}
|
|
<View style={styles.workspaceRow}>
|
|
{workspaces.map((workspace: MobileWorkspace) => {
|
|
const isActive = workspace.id === activeWorkspaceId;
|
|
return (
|
|
<Pressable
|
|
accessibilityLabel={`Select workspace ${workspace.name}`}
|
|
key={workspace.id}
|
|
onPress={() => setActiveWorkspace(workspace.id)}
|
|
style={[styles.workspaceChip, isActive ? styles.workspaceChipActive : null]}
|
|
>
|
|
<Text style={[styles.workspaceChipText, isActive ? styles.workspaceChipTextActive : null]}>
|
|
{workspace.name}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Capture mode selector — 6 modes */}
|
|
<View style={styles.modeGrid}>
|
|
{CAPTURE_MODES.map(({ mode: m, label, icon, description }) => {
|
|
const isActive = m === mode;
|
|
return (
|
|
<Pressable
|
|
key={m}
|
|
accessibilityLabel={`${label} capture mode: ${description}`}
|
|
onPress={() => { setMode(m); resetForm(); }}
|
|
style={[styles.modeCard, isActive ? styles.modeCardActive : null]}
|
|
>
|
|
<Text style={styles.modeIcon}>{icon}</Text>
|
|
<Text style={[styles.modeLabel, isActive ? styles.modeLabelActive : null]}>{label}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Mode-specific content */}
|
|
{mode === 'text' && (
|
|
<>
|
|
<TextInput value={title} onChangeText={setTitle} placeholder="Draft title" placeholderTextColor={colors.textTertiary} style={styles.input} />
|
|
<TextInput value={body} onChangeText={setBody} placeholder="Capture a thought, task, or note" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
|
|
</>
|
|
)}
|
|
|
|
{mode === 'url' && (
|
|
<>
|
|
<TextInput value={urlInput} onChangeText={setUrlInput} placeholder="https://example.com/article" placeholderTextColor={colors.textTertiary} style={styles.input} autoCapitalize="none" keyboardType="url" />
|
|
<Pressable accessibilityLabel="Extract content from URL" onPress={handleUrlExtract} disabled={busy || !urlInput.trim()} style={[styles.button, busy ? styles.buttonDisabled : null]}>
|
|
<Text style={styles.buttonText}>{busy ? 'Extracting...' : 'Extract & Summarize'}</Text>
|
|
</Pressable>
|
|
{body ? (
|
|
<>
|
|
<TextInput value={title} onChangeText={setTitle} placeholder="Title" placeholderTextColor={colors.textTertiary} style={styles.input} />
|
|
<TextInput value={body} onChangeText={setBody} placeholder="Extracted content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
{mode === 'paste' && (
|
|
<>
|
|
<Pressable accessibilityLabel="Read clipboard and clean text" onPress={handlePasteAndClean} disabled={busy} style={[styles.button, busy ? styles.buttonDisabled : null]}>
|
|
<Text style={styles.buttonText}>{busy ? 'Reading clipboard...' : 'Paste & Clean'}</Text>
|
|
</Pressable>
|
|
{body ? (
|
|
<>
|
|
<TextInput value={title} onChangeText={setTitle} placeholder="Title" placeholderTextColor={colors.textTertiary} style={styles.input} />
|
|
<TextInput value={body} onChangeText={setBody} placeholder="Cleaned content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
{mode === 'voice' && (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Voice-to-Note</Text>
|
|
<Text style={styles.cardBody}>Record audio and transcribe to text. Requires expo-av for audio recording.</Text>
|
|
<Pressable accessibilityLabel="Start voice recording" onPress={handleVoiceCapture} style={styles.button}>
|
|
<Text style={styles.buttonText}>Start Recording</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{mode === 'photo' && (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Screenshot-to-Note</Text>
|
|
<Text style={styles.cardBody}>Take a photo or select from gallery. Uses vision AI for OCR and text extraction.</Text>
|
|
<Pressable accessibilityLabel="Open camera for photo capture" onPress={handlePhotoCapture} style={styles.button}>
|
|
<Text style={styles.buttonText}>Open Camera</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{mode === 'scan' && (
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Document Scan</Text>
|
|
<Text style={styles.cardBody}>Photograph multiple pages of a document. Each page is processed with vision AI and combined into a single note.</Text>
|
|
<Pressable accessibilityLabel="Start document scan" onPress={handleScanCapture} style={styles.button}>
|
|
<Text style={styles.buttonText}>Start Scanning</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{/* Error display */}
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
|
|
{/* Save button (shown when we have content to save) */}
|
|
{(mode === 'text' || body) && (
|
|
<Pressable
|
|
accessibilityLabel={activeWorkspaceId ? 'Save draft note' : 'Select workspace before saving'}
|
|
onPress={handleSave}
|
|
disabled={!activeWorkspaceId || busy}
|
|
style={[styles.button, (!activeWorkspaceId || busy) ? styles.buttonDisabled : null]}
|
|
>
|
|
<Text style={styles.buttonText}>{busy ? 'Saving...' : activeWorkspaceId ? 'Save draft' : 'Select workspace'}</Text>
|
|
</Pressable>
|
|
)}
|
|
{saved ? <Text style={styles.saved}>Draft saved to the product backend.</Text> : null}
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.bgCanvas,
|
|
},
|
|
contentContainer: {
|
|
padding: 20,
|
|
gap: 14,
|
|
},
|
|
title: {
|
|
color: colors.textPrimary,
|
|
fontSize: 28,
|
|
fontWeight: '700',
|
|
},
|
|
input: {
|
|
borderWidth: 1,
|
|
borderColor: colors.borderDefault,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 12,
|
|
color: colors.textPrimary,
|
|
backgroundColor: colors.surfaceCard,
|
|
},
|
|
bodyInput: {
|
|
minHeight: 160,
|
|
},
|
|
button: {
|
|
backgroundColor: colors.accentPrimary,
|
|
borderRadius: 12,
|
|
paddingVertical: 14,
|
|
alignItems: 'center',
|
|
},
|
|
buttonText: {
|
|
color: colors.textPrimary,
|
|
fontWeight: '700',
|
|
},
|
|
buttonDisabled: {
|
|
opacity: 0.6,
|
|
},
|
|
saved: {
|
|
color: colors.success,
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
error: {
|
|
color: colors.danger,
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
card: {
|
|
backgroundColor: colors.surfaceCard,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderDefault,
|
|
padding: 14,
|
|
gap: 10,
|
|
},
|
|
workspaceRow: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 8,
|
|
},
|
|
workspaceChip: {
|
|
borderRadius: 999,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderDefault,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 8,
|
|
backgroundColor: colors.surfaceCard,
|
|
},
|
|
workspaceChipActive: {
|
|
backgroundColor: colors.accentPrimary,
|
|
borderColor: colors.accentPrimary,
|
|
},
|
|
workspaceChipText: {
|
|
color: colors.textSecondary,
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
workspaceChipTextActive: {
|
|
color: colors.textPrimary,
|
|
},
|
|
modeGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 10,
|
|
},
|
|
modeCard: {
|
|
width: '30%',
|
|
backgroundColor: colors.surfaceCard,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderDefault,
|
|
padding: 12,
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
modeCardActive: {
|
|
backgroundColor: colors.accentPrimary,
|
|
borderColor: colors.accentPrimary,
|
|
},
|
|
modeIcon: {
|
|
fontSize: 24,
|
|
},
|
|
modeLabel: {
|
|
color: colors.textSecondary,
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
},
|
|
modeLabelActive: {
|
|
color: colors.textPrimary,
|
|
},
|
|
cardTitle: {
|
|
color: colors.textPrimary,
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
},
|
|
cardBody: {
|
|
color: colors.textSecondary,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
},
|
|
});
|