feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
698
apps/mobile-rn/src/screens/TemplatesScreen.tsx
Normal file
698
apps/mobile-rn/src/screens/TemplatesScreen.tsx
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
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<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [kind, setKind] = useState<TemplateKind>(initialKind)
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [templates, setTemplates] = useState<UserTemplate[]>([])
|
||||
const [selections, setSelections] = useState<Partial<Record<TemplateKind, UserTemplateSelection>>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<TemplateServiceErrorCode | null>(null)
|
||||
const [realtimeConnected, setRealtimeConnected] = useState(true)
|
||||
const [editor, setEditor] = useState<TemplateEditor | null>(null)
|
||||
const [editorError, setEditorError] = useState<TemplateServiceErrorCode | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const load = useCallback(async (pullToRefresh = false): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<DictationTemplateField>): 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 (
|
||||
<ThemeCard
|
||||
style={[styles.card, selected && styles.cardSelected]}
|
||||
testID={`template-${item.id}`}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardTitle}>
|
||||
<View style={styles.titleRow}>
|
||||
<ThemeText variant="title" accessibilityRole="header">{item.name}</ThemeText>
|
||||
{item.is_builtin && (
|
||||
<View style={styles.badge}>
|
||||
<ThemeText variant="label" color="accent">{t('mobile.templates.builtin')}</ThemeText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{item.description !== null && <ThemeText color="muted">{item.description}</ThemeText>}
|
||||
</View>
|
||||
{selected && <ThemeText color="accent">{t('mobile.templates.selected')}</ThemeText>}
|
||||
</View>
|
||||
<ThemeText variant="small" color="muted">
|
||||
{item.template_kind === 'dictation'
|
||||
? t('mobile.templates.fieldCount', { count: item.fields.length })
|
||||
: t('mobile.templates.documentType', { type: item.template_type ?? 'custom' })}
|
||||
</ThemeText>
|
||||
<View style={styles.actions}>
|
||||
<ThemeButton
|
||||
label={busy ? t('mobile.templates.selecting') : t('mobile.templates.select')}
|
||||
disabled={busyId !== null || selected}
|
||||
onPress={() => void choose(item)}
|
||||
style={styles.flexButton}
|
||||
testID={`template-select-${item.id}`}
|
||||
/>
|
||||
{!item.is_builtin && (
|
||||
<ThemeButton
|
||||
label={t('mobile.templates.edit')}
|
||||
variant="secondary"
|
||||
disabled={busyId !== null}
|
||||
onPress={() => setEditor(editorFor(item))}
|
||||
style={styles.flexButton}
|
||||
testID={`template-edit-${item.id}`}
|
||||
/>
|
||||
)}
|
||||
{!item.is_builtin && (
|
||||
<ThemeButton
|
||||
label={t('mobile.templates.delete')}
|
||||
variant="danger"
|
||||
disabled={busyId !== null}
|
||||
onPress={() => confirmDelete(item)}
|
||||
style={styles.flexButton}
|
||||
testID={`template-delete-${item.id}`}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ThemeCard>
|
||||
)
|
||||
}, [busyId, choose, confirmDelete, selections, styles, t])
|
||||
|
||||
const empty = loading ? (
|
||||
<View style={styles.center} testID="templates-loading">
|
||||
<ActivityIndicator color={palette.accent.main} />
|
||||
<ThemeText color="muted">{t('mobile.templates.loading')}</ThemeText>
|
||||
</View>
|
||||
) : error !== null ? (
|
||||
<View style={styles.center} testID="templates-error">
|
||||
<ThemeText color="danger" accessibilityLiveRegion="assertive">{t(errorKey(error))}</ThemeText>
|
||||
<ThemeButton label={t('common.retry')} variant="secondary" onPress={() => void load()} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.center} testID="templates-empty">
|
||||
<ThemeText color="muted" style={styles.centerText}>
|
||||
{debouncedSearch.length > 0 ? t('mobile.templates.emptySearch') : t('mobile.templates.empty')}
|
||||
</ThemeText>
|
||||
<ThemeButton label={t('mobile.templates.add')} onPress={openCreate} />
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="templates-screen">
|
||||
<View style={[styles.header, { paddingTop: insets.top + 10 }]}>
|
||||
<View style={styles.headerText}>
|
||||
<ThemeText variant="title" accessibilityRole="header">{t('mobile.templates.title')}</ThemeText>
|
||||
<ThemeText variant="small" color="muted">{t('mobile.templates.description')}</ThemeText>
|
||||
</View>
|
||||
<ThemeButton label={t('mobile.templates.add')} onPress={openCreate} testID="templates-add" />
|
||||
</View>
|
||||
{!realtimeConnected && (
|
||||
<View style={styles.banner} testID="templates-realtime-warning">
|
||||
<ThemeText variant="small" color="muted">{t('mobile.templates.realtimeWaiting')}</ThemeText>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.controls}>
|
||||
<View style={styles.tabs} accessibilityRole="tablist">
|
||||
{(['dictation', 'meeting_document'] as const).map((value) => {
|
||||
const active = value === kind
|
||||
return (
|
||||
<Pressable
|
||||
key={value}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: active }}
|
||||
onPress={() => setKind(value)}
|
||||
style={[styles.tab, active && styles.tabActive]}
|
||||
testID={`templates-kind-${value}`}
|
||||
>
|
||||
<ThemeText color={active ? 'onAccent' : 'muted'}>{t(kindKey(value))}</ThemeText>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
<TextInput
|
||||
accessibilityLabel={t('mobile.templates.search')}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={t('mobile.templates.search')}
|
||||
placeholderTextColor={palette.text.muted}
|
||||
maxLength={100}
|
||||
returnKeyType="search"
|
||||
style={styles.input}
|
||||
testID="templates-search"
|
||||
/>
|
||||
</View>
|
||||
{error !== null && templates.length > 0 && (
|
||||
<View style={styles.inlineError}>
|
||||
<ThemeText color="danger" accessibilityLiveRegion="assertive">{t(errorKey(error))}</ThemeText>
|
||||
<ThemeButton label={t('common.retry')} variant="secondary" onPress={() => void load()} />
|
||||
</View>
|
||||
)}
|
||||
<FlatList
|
||||
data={templates}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderTemplate}
|
||||
contentContainerStyle={templates.length === 0 ? styles.emptyList : styles.list}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshControl={(
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => void load(true)}
|
||||
tintColor={palette.accent.main}
|
||||
colors={[palette.accent.main]}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={empty}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={editor !== null}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={closeEditor}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.modalBackdrop}
|
||||
>
|
||||
{editor !== null && (
|
||||
<FlatList
|
||||
data={editor.kind === 'dictation' ? editor.fields : []}
|
||||
keyExtractor={(field, index) => `${field.id}-${index}`}
|
||||
style={styles.modalList}
|
||||
contentContainerStyle={styles.modalCard}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
accessibilityViewIsModal
|
||||
ListHeaderComponent={(
|
||||
<View style={styles.editorSection}>
|
||||
<ThemeText variant="title" accessibilityRole="header">
|
||||
{editor.source === null ? t('mobile.templates.addTitle') : t('mobile.templates.editTitle')}
|
||||
</ThemeText>
|
||||
<ThemeText variant="small" color="muted">{t(kindKey(editor.kind))}</ThemeText>
|
||||
<EditorInput
|
||||
label={t('mobile.templates.name')}
|
||||
value={editor.name}
|
||||
onChangeText={(name) => setEditor((current) => current === null ? null : { ...current, name })}
|
||||
palette={palette}
|
||||
testID="template-editor-name"
|
||||
maxLength={120}
|
||||
/>
|
||||
<EditorInput
|
||||
label={t('mobile.templates.descriptionLabel')}
|
||||
value={editor.description}
|
||||
onChangeText={(description) => setEditor((current) => current === null ? null : { ...current, description })}
|
||||
palette={palette}
|
||||
testID="template-editor-description"
|
||||
maxLength={1000}
|
||||
multiline
|
||||
/>
|
||||
{editor.kind === 'dictation' && (
|
||||
<ThemeText variant="label" color="muted">{t('mobile.templates.fields')}</ThemeText>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
renderItem={({ item: field, index }) => (
|
||||
<ThemeCard style={styles.fieldCard} testID={`template-editor-field-${index}`}>
|
||||
<View style={styles.fieldHeader}>
|
||||
<ThemeText>{t('mobile.templates.fieldNumber', { number: index + 1 })}</ThemeText>
|
||||
<ThemeButton
|
||||
label={t('mobile.templates.removeField')}
|
||||
variant="quiet"
|
||||
disabled={editor.fields.length <= 1}
|
||||
onPress={() => removeField(index)}
|
||||
/>
|
||||
</View>
|
||||
<EditorInput label={t('mobile.templates.fieldId')} value={field.id}
|
||||
onChangeText={(id) => updateField(index, { id, name: id })} palette={palette} maxLength={80} />
|
||||
<EditorInput label={t('mobile.templates.fieldLabel')} value={field.label}
|
||||
onChangeText={(label) => updateField(index, { label })} palette={palette} maxLength={120} />
|
||||
<EditorInput label={t('mobile.templates.fieldPrompt')} value={field.promptText}
|
||||
onChangeText={(promptText) => updateField(index, { promptText })} palette={palette} maxLength={500} multiline />
|
||||
<View style={styles.fieldOptions}>
|
||||
<View style={styles.switchRow}>
|
||||
<ThemeText>{t('mobile.templates.required')}</ThemeText>
|
||||
<Switch
|
||||
accessibilityLabel={t('mobile.templates.required')}
|
||||
value={field.required}
|
||||
onValueChange={(required) => updateField(index, { required })}
|
||||
trackColor={{ false: palette.border.default, true: palette.accent.dim }}
|
||||
thumbColor={field.required ? palette.accent.main : palette.text.muted}
|
||||
/>
|
||||
</View>
|
||||
<EditorInput
|
||||
label={t('mobile.templates.durationSeconds')}
|
||||
value={String(field.maxDurationSec)}
|
||||
onChangeText={(value) => updateField(index, { maxDurationSec: Number(value.replace(/\D/g, '')) || 0 })}
|
||||
palette={palette}
|
||||
keyboardType="number-pad"
|
||||
maxLength={4}
|
||||
style={styles.durationInput}
|
||||
/>
|
||||
</View>
|
||||
</ThemeCard>
|
||||
)}
|
||||
ListFooterComponent={(
|
||||
<View style={styles.editorSection}>
|
||||
{editor.kind === 'dictation' ? (
|
||||
<>
|
||||
<ThemeButton label={t('mobile.templates.addField')} variant="secondary" onPress={addField} />
|
||||
<EditorInput
|
||||
label={t('mobile.templates.outputFormat')}
|
||||
value={editor.outputFormat}
|
||||
onChangeText={(outputFormat) => setEditor((current) => current === null ? null : { ...current, outputFormat })}
|
||||
palette={palette}
|
||||
maxLength={20000}
|
||||
multiline
|
||||
testID="template-editor-output"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EditorInput
|
||||
label={t('mobile.templates.systemPrompt')}
|
||||
value={editor.systemPrompt}
|
||||
onChangeText={(systemPrompt) => setEditor((current) => current === null ? null : { ...current, systemPrompt })}
|
||||
palette={palette}
|
||||
maxLength={12000}
|
||||
multiline
|
||||
testID="template-editor-prompt"
|
||||
/>
|
||||
)}
|
||||
{editorError !== null && (
|
||||
<ThemeText color="danger" accessibilityLiveRegion="assertive" testID="template-editor-error">
|
||||
{t(errorKey(editorError))}
|
||||
</ThemeText>
|
||||
)}
|
||||
<View style={styles.actions}>
|
||||
<ThemeButton label={t('common.cancel')} variant="secondary" disabled={saving}
|
||||
onPress={closeEditor} style={styles.flexButton} />
|
||||
<ThemeButton
|
||||
label={saving ? t('mobile.templates.saving') : t('common.save')}
|
||||
disabled={saving || editor.name.trim().length === 0}
|
||||
onPress={() => void saveEditor()}
|
||||
style={styles.flexButton}
|
||||
testID="template-editor-save"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorInput({
|
||||
label,
|
||||
palette,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TextInput> & {
|
||||
label: string
|
||||
palette: Palette
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<View style={stylesStatic.field}>
|
||||
<ThemeText variant="label" color="muted">{label}</ThemeText>
|
||||
<TextInput
|
||||
accessibilityLabel={label}
|
||||
placeholderTextColor={palette.text.muted}
|
||||
textAlignVertical={props.multiline === true ? 'top' : 'center'}
|
||||
style={[
|
||||
stylesStatic.editorInput,
|
||||
{ color: palette.text.primary, borderColor: palette.border.default, backgroundColor: palette.bg.inset },
|
||||
props.multiline === true && stylesStatic.multiline,
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
type Palette = ReturnType<typeof useMobilePreferences>['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<typeof StyleSheet.create> {
|
||||
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: 'rgba(0,0,0,0.68)' },
|
||||
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 },
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue