learning_ai_notes/mobile/src/app/(tabs)/capture.tsx
saravanakumardb1 3260b7ea0a feat(smart-actions): F1-F4 inline editor AI, F15-F19 mobile capture modes, F25-F27 scheduler/webhooks/approval
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
2026-04-06 10:25:34 -07:00

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