feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

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