import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react' import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Modal, Platform, Pressable, RefreshControl, StyleSheet, Switch, TextInput, View, } from 'react-native' import { useIsFocused } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useI18n, type TranslationKey } from '@d3ro/i18n' import type { DictationTemplateField, TemplateKind, UserTemplate, UserTemplateSelection, } from '@d3ro/api-client' import { useAuth } from '../lib/auth-context' import { useMobilePreferences } from '../lib/preferences-context' import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components' import { createTemplate, deleteTemplate, loadTemplateLibrary, normalizeTemplateDraft, selectTemplate, subscribeToTemplates, TemplateServiceError, type TemplateServiceErrorCode, updateTemplate, } from '../features/templates' interface TemplatesScreenProps { initialKind?: TemplateKind onSelectTemplate?: (template: UserTemplate) => void } interface TemplateEditor { source: UserTemplate | null kind: TemplateKind name: string description: string fields: DictationTemplateField[] outputFormat: string systemPrompt: string } const NEW_FIELD: DictationTemplateField = { id: 'content', name: 'content', label: 'Content', promptText: 'What would you like to dictate?', required: true, maxDurationSec: 120, } function blankEditor(kind: TemplateKind): TemplateEditor { return { source: null, kind, name: '', description: '', fields: kind === 'dictation' ? [{ ...NEW_FIELD }] : [], outputFormat: kind === 'dictation' ? '{{content}}' : '', systemPrompt: '', } } function editorFor(template: UserTemplate): TemplateEditor { return { source: template, kind: template.template_kind, name: template.name, description: template.description ?? '', fields: template.fields.map((field) => ({ ...field })), outputFormat: template.output_format ?? '', systemPrompt: template.system_prompt ?? '', } } function errorKey(code: TemplateServiceErrorCode): TranslationKey { switch (code) { case 'auth': return 'mobile.templates.error.auth' case 'builtin': return 'mobile.templates.error.builtin' case 'conflict': return 'mobile.templates.error.conflict' case 'forbidden': return 'mobile.templates.error.forbidden' case 'in-progress': return 'mobile.templates.error.inProgress' case 'network': return 'mobile.templates.error.network' case 'not-found': return 'mobile.templates.error.notFound' case 'provider': return 'mobile.templates.error.provider' case 'quota': return 'mobile.templates.error.quota' case 'validation': return 'mobile.templates.error.validation' default: return 'mobile.templates.error.server' } } function kindKey(kind: TemplateKind): TranslationKey { return kind === 'dictation' ? 'mobile.templates.kind.dictation' : 'mobile.templates.kind.meetingDocument' } function toServiceError(error: unknown): TemplateServiceError { return error instanceof TemplateServiceError ? error : new TemplateServiceError('server', 'Template operation failed', true) } export default function TemplatesScreen({ initialKind = 'dictation', onSelectTemplate, }: TemplatesScreenProps): React.ReactElement { const insets = useSafeAreaInsets() const isFocused = useIsFocused() const { t } = useI18n() const { user } = useAuth() const { palette } = useMobilePreferences() const styles = useMemo(() => createStyles(palette), [palette]) const generation = useRef(0) const realtimeTimer = useRef | null>(null) const [kind, setKind] = useState(initialKind) const [search, setSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') const [templates, setTemplates] = useState([]) const [selections, setSelections] = useState>>({}) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) const [realtimeConnected, setRealtimeConnected] = useState(true) const [editor, setEditor] = useState(null) const [editorError, setEditorError] = useState(null) const [saving, setSaving] = useState(false) const [busyId, setBusyId] = useState(null) useEffect(() => { const timer = setTimeout(() => setDebouncedSearch(search), 300) return () => clearTimeout(timer) }, [search]) const load = useCallback(async (pullToRefresh = false): Promise => { const userId = user?.id const request = ++generation.current if (userId === undefined) { setLoading(false) setError('auth') return } if (pullToRefresh) setRefreshing(true) else setLoading(true) try { const library = await loadTemplateLibrary(userId, kind, debouncedSearch) if (request !== generation.current) return setTemplates(library.templates) setSelections(library.selections) setError(null) } catch (candidate) { if (request !== generation.current) return setError(toServiceError(candidate).code) } finally { if (request === generation.current) { setLoading(false) setRefreshing(false) } } }, [debouncedSearch, kind, user?.id]) useEffect(() => { if (!isFocused) return void load() }, [isFocused, load]) useEffect(() => { if (user?.id === undefined) return undefined const subscription = subscribeToTemplates( user.id, () => { if (realtimeTimer.current !== null) clearTimeout(realtimeTimer.current) realtimeTimer.current = setTimeout(() => void load(), 120) }, (status) => setRealtimeConnected(status === 'SUBSCRIBED'), ) return () => { if (realtimeTimer.current !== null) clearTimeout(realtimeTimer.current) void subscription.unsubscribe() } }, [load, user?.id]) const openCreate = useCallback((): void => { setEditor(blankEditor(kind)) setEditorError(null) }, [kind]) const closeEditor = useCallback((): void => { if (saving) return setEditor(null) setEditorError(null) }, [saving]) const saveEditor = useCallback(async (): Promise => { if (editor === null || user?.id === undefined || saving) return const draft = { name: editor.name, description: editor.description, fields: editor.fields, outputFormat: editor.outputFormat, systemPrompt: editor.systemPrompt, } try { normalizeTemplateDraft(editor.kind, draft) } catch (candidate) { setEditorError(toServiceError(candidate).code) return } setSaving(true) setEditorError(null) try { if (editor.source === null) await createTemplate(user.id, editor.kind, draft) else await updateTemplate(user.id, editor.source, draft) setEditor(null) await load() } catch (candidate) { setEditorError(toServiceError(candidate).code) } finally { setSaving(false) } }, [editor, load, saving, user?.id]) const choose = useCallback(async (template: UserTemplate): Promise => { if (user?.id === undefined || busyId !== null) return const previous = selections[template.template_kind] const optimistic: UserTemplateSelection = { user_id: user.id, template_kind: template.template_kind, template_id: template.id, revision: (previous?.revision ?? 0) + 1, updated_at: new Date().toISOString(), } setBusyId(template.id) setSelections((current) => ({ ...current, [template.template_kind]: optimistic })) try { const saved = await selectTemplate(user.id, template, previous) setSelections((current) => ({ ...current, [template.template_kind]: saved })) setError(null) onSelectTemplate?.(template) } catch (candidate) { setSelections((current) => { const next = { ...current } if (previous === undefined) delete next[template.template_kind] else next[template.template_kind] = previous return next }) setError(toServiceError(candidate).code) } finally { setBusyId(null) } }, [busyId, onSelectTemplate, selections, user?.id]) const confirmDelete = useCallback((template: UserTemplate): void => { if (template.is_builtin || user?.id === undefined || busyId !== null) return Alert.alert( t('mobile.templates.deleteTitle'), t('mobile.templates.deleteBody', { name: template.name }), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('mobile.templates.delete'), style: 'destructive', onPress: () => { setBusyId(template.id) void deleteTemplate(user.id, template) .then(() => load()) .catch((candidate: unknown) => setError(toServiceError(candidate).code)) .finally(() => setBusyId(null)) }, }, ], ) }, [busyId, load, t, user?.id]) const updateField = useCallback((index: number, patch: Partial): void => { setEditor((current) => current === null ? null : { ...current, fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field), }) }, []) const removeField = useCallback((index: number): void => { setEditor((current) => current === null ? null : { ...current, fields: current.fields.filter((_, fieldIndex) => fieldIndex !== index), }) }, []) const addField = useCallback((): void => { setEditor((current) => { if (current === null) return null const number = current.fields.length + 1 return { ...current, fields: [...current.fields, { ...NEW_FIELD, id: `field_${number}`, name: `field_${number}`, label: `Field ${number}`, }], } }) }, []) const renderTemplate = useCallback(({ item }: { item: UserTemplate }): React.ReactElement => { const selected = selections[item.template_kind]?.template_id === item.id const busy = busyId === item.id return ( {item.name} {item.is_builtin && ( {t('mobile.templates.builtin')} )} {item.description !== null && {item.description}} {selected && {t('mobile.templates.selected')}} {item.template_kind === 'dictation' ? t('mobile.templates.fieldCount', { count: item.fields.length }) : t('mobile.templates.documentType', { type: item.template_type ?? 'custom' })} void choose(item)} style={styles.flexButton} testID={`template-select-${item.id}`} /> {!item.is_builtin && ( setEditor(editorFor(item))} style={styles.flexButton} testID={`template-edit-${item.id}`} /> )} {!item.is_builtin && ( confirmDelete(item)} style={styles.flexButton} testID={`template-delete-${item.id}`} /> )} ) }, [busyId, choose, confirmDelete, selections, styles, t]) const empty = loading ? ( {t('mobile.templates.loading')} ) : error !== null ? ( {t(errorKey(error))} void load()} /> ) : ( {debouncedSearch.length > 0 ? t('mobile.templates.emptySearch') : t('mobile.templates.empty')} ) return ( {t('mobile.templates.title')} {t('mobile.templates.description')} {!realtimeConnected && ( {t('mobile.templates.realtimeWaiting')} )} {(['dictation', 'meeting_document'] as const).map((value) => { const active = value === kind return ( setKind(value)} style={[styles.tab, active && styles.tabActive]} testID={`templates-kind-${value}`} > {t(kindKey(value))} ) })} {error !== null && templates.length > 0 && ( {t(errorKey(error))} void load()} /> )} item.id} renderItem={renderTemplate} contentContainerStyle={templates.length === 0 ? styles.emptyList : styles.list} keyboardShouldPersistTaps="handled" refreshControl={( void load(true)} tintColor={palette.accent.main} colors={[palette.accent.main]} /> )} ListEmptyComponent={empty} /> {editor !== null && ( `${field.id}-${index}`} style={styles.modalList} contentContainerStyle={styles.modalCard} keyboardShouldPersistTaps="handled" accessibilityViewIsModal ListHeaderComponent={( {editor.source === null ? t('mobile.templates.addTitle') : t('mobile.templates.editTitle')} {t(kindKey(editor.kind))} setEditor((current) => current === null ? null : { ...current, name })} palette={palette} testID="template-editor-name" maxLength={120} /> setEditor((current) => current === null ? null : { ...current, description })} palette={palette} testID="template-editor-description" maxLength={1000} multiline /> {editor.kind === 'dictation' && ( {t('mobile.templates.fields')} )} )} renderItem={({ item: field, index }) => ( {t('mobile.templates.fieldNumber', { number: index + 1 })} removeField(index)} /> updateField(index, { id, name: id })} palette={palette} maxLength={80} /> updateField(index, { label })} palette={palette} maxLength={120} /> updateField(index, { promptText })} palette={palette} maxLength={500} multiline /> {t('mobile.templates.required')} updateField(index, { required })} trackColor={{ false: palette.border.default, true: palette.accent.dim }} thumbColor={field.required ? palette.accent.main : palette.text.muted} /> updateField(index, { maxDurationSec: Number(value.replace(/\D/g, '')) || 0 })} palette={palette} keyboardType="number-pad" maxLength={4} style={styles.durationInput} /> )} ListFooterComponent={( {editor.kind === 'dictation' ? ( <> setEditor((current) => current === null ? null : { ...current, outputFormat })} palette={palette} maxLength={20000} multiline testID="template-editor-output" /> ) : ( setEditor((current) => current === null ? null : { ...current, systemPrompt })} palette={palette} maxLength={12000} multiline testID="template-editor-prompt" /> )} {editorError !== null && ( {t(errorKey(editorError))} )} void saveEditor()} style={styles.flexButton} testID="template-editor-save" /> )} /> )} ) } function EditorInput({ label, palette, style, ...props }: React.ComponentProps & { label: string palette: Palette }): React.ReactElement { return ( {label} ) } type Palette = ReturnType['palette'] const stylesStatic = StyleSheet.create({ field: { gap: 6 }, editorInput: { minHeight: 48, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14, }, multiline: { minHeight: 108 }, }) function createStyles(palette: Palette): ReturnType { return StyleSheet.create({ container: { flex: 1, backgroundColor: palette.bg.app }, header: { minHeight: 94, paddingHorizontal: 20, paddingBottom: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12, borderBottomWidth: 1, borderBottomColor: palette.border.default, backgroundColor: palette.bg.sidebar, }, headerText: { flex: 1, gap: 4 }, banner: { paddingHorizontal: 20, paddingVertical: 8, backgroundColor: palette.accent.dim }, controls: { padding: 16, gap: 10, borderBottomWidth: 1, borderBottomColor: palette.border.subtle }, tabs: { flexDirection: 'row', gap: 8 }, tab: { flex: 1, minHeight: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 9, borderWidth: 1, borderColor: palette.border.default }, tabActive: { backgroundColor: palette.accent.main, borderColor: palette.accent.main }, input: { minHeight: 48, borderWidth: 1, borderColor: palette.border.default, borderRadius: 10, backgroundColor: palette.bg.inset, color: palette.text.primary, paddingHorizontal: 12, fontSize: 14 }, inlineError: { padding: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10, borderBottomWidth: 1, borderBottomColor: palette.tag.red }, list: { padding: 16, gap: 12, paddingBottom: 36 }, emptyList: { flexGrow: 1 }, center: { flex: 1, minHeight: 260, alignItems: 'center', justifyContent: 'center', gap: 14, padding: 32 }, centerText: { textAlign: 'center' }, card: { gap: 12 }, cardSelected: { borderColor: palette.accent.main, borderWidth: 1 }, cardHeader: { flexDirection: 'row', alignItems: 'flex-start', gap: 10 }, cardTitle: { flex: 1, gap: 5 }, titleRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 8 }, badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, backgroundColor: palette.accent.dim }, actions: { flexDirection: 'row', alignItems: 'center', gap: 8 }, flexButton: { flex: 1 }, modalBackdrop: { flex: 1, justifyContent: 'center', padding: 16, backgroundColor: palette.scrim }, modalList: { width: '100%', maxWidth: 680, maxHeight: '94%', alignSelf: 'center', borderRadius: 14, backgroundColor: palette.bg.card }, modalCard: { padding: 18, gap: 14 }, editorSection: { gap: 14 }, fieldCard: { gap: 11, padding: 12 }, fieldHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, fieldOptions: { flexDirection: 'row', alignItems: 'flex-end', gap: 14 }, switchRow: { flex: 1, minHeight: 48, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, durationInput: { width: 112 }, }) }