d3ro-voice/apps/mobile-rn/src/screens/TemplatesScreen.tsx
Yun Chan 94d8bb8ebe feat(mobile): keep team, template, and report flows in sync with the server
Team, meeting, memo, template, command, and dictionary screens drifted from
the server contract, and report submission could hang instead of confirming
to the user. The screens now use the server responses directly.

The retired Expo shell is removed; the React Native app is the mobile client.
Gradle-generated vector-icon drawables are ignored rather than committed.
2026-09-16 23:25:51 +09:00

698 lines
27 KiB
TypeScript

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