d3ro-voice/apps/mobile-rn/src/screens/DictionaryScreen.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

714 lines
23 KiB
TypeScript

import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
ActivityIndicator,
Alert,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
StyleSheet,
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 { DictionaryEntry } 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 {
createDictionaryEntry,
deleteDictionaryEntry,
DictionaryServiceError,
listDictionaryPage,
mergeDictionaryEntries,
normalizeDictionaryDraft,
subscribeToDictionary,
updateDictionaryEntry,
type DictionaryCategory,
type DictionaryCursor,
type DictionaryServiceErrorCode,
} from '../features/dictionary/dictionary-service'
type CategoryFilter = DictionaryCategory | 'all'
interface EditorState {
entry: DictionaryEntry | null
word: string
pronunciation: string
category: DictionaryCategory
}
const EMPTY_EDITOR: EditorState = {
entry: null,
word: '',
pronunciation: '',
category: 'user',
}
const CATEGORY_FILTERS: readonly CategoryFilter[] = [
'all',
'user',
'technical',
'auto',
]
function errorKey(code: DictionaryServiceErrorCode): TranslationKey {
switch (code) {
case 'auth': return 'mobile.dictionary.error.auth'
case 'conflict': return 'mobile.dictionary.error.conflict'
case 'duplicate': return 'mobile.dictionary.error.duplicate'
case 'network': return 'mobile.dictionary.error.network'
case 'not-found': return 'mobile.dictionary.error.notFound'
case 'validation': return 'mobile.dictionary.error.validation'
default: return 'mobile.dictionary.error.server'
}
}
function categoryKey(category: CategoryFilter): TranslationKey {
switch (category) {
case 'all': return 'mobile.dictionary.category.all'
case 'user': return 'mobile.dictionary.category.user'
case 'technical': return 'mobile.dictionary.category.technical'
case 'auto': return 'mobile.dictionary.category.auto'
}
}
export default function DictionaryScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const { t } = useI18n()
const { user } = useAuth()
const isFocused = useIsFocused()
const { palette } = useMobilePreferences()
const styles = useMemo(() => createStyles(palette), [palette])
const requestGeneration = useRef(0)
const realtimeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [entries, setEntries] = useState<DictionaryEntry[]>([])
const [cursor, setCursor] = useState<DictionaryCursor | null>(null)
const [total, setTotal] = useState(0)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [category, setCategory] = useState<CategoryFilter>('all')
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<DictionaryServiceErrorCode | null>(null)
const [realtimeConnected, setRealtimeConnected] = useState(true)
const [editor, setEditor] = useState<EditorState | null>(null)
const [editorError, setEditorError] = useState<DictionaryServiceErrorCode | null>(null)
const [saving, setSaving] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 350)
return () => clearTimeout(timer)
}, [search])
const load = useCallback(async (
reset: boolean,
pullToRefresh = false,
): Promise<void> => {
const userId = user?.id
if (userId === undefined) {
setLoading(false)
setError('auth')
return
}
if (!reset && cursor === null) return
const generation = reset ? ++requestGeneration.current : requestGeneration.current
if (pullToRefresh) setRefreshing(true)
else if (reset) setLoading(true)
else setLoadingMore(true)
try {
const page = await listDictionaryPage({
userId,
search: debouncedSearch,
category,
cursor: reset ? null : cursor,
})
if (generation !== requestGeneration.current) return
setEntries((current) => reset
? page.entries
: mergeDictionaryEntries(current, page.entries))
setCursor(page.nextCursor)
if (reset) setTotal(page.total)
setError(null)
} catch (candidate) {
if (generation !== requestGeneration.current) return
const serviceError = candidate instanceof DictionaryServiceError
? candidate
: new DictionaryServiceError('server', 'Dictionary request failed')
setError(serviceError.code)
} finally {
if (generation === requestGeneration.current) {
setLoading(false)
setRefreshing(false)
setLoadingMore(false)
}
}
}, [category, cursor, debouncedSearch, user?.id])
const loadFirstPageRef = useRef<() => void>(() => undefined)
const lastFocusReloadKey = useRef<string | null>(null)
loadFirstPageRef.current = () => {
void load(true)
}
const focusReloadKey = `${user?.id ?? 'anonymous'}:${category}:${debouncedSearch}`
useEffect(() => {
if (!isFocused) {
lastFocusReloadKey.current = null
return
}
if (lastFocusReloadKey.current === focusReloadKey) return
lastFocusReloadKey.current = focusReloadKey
loadFirstPageRef.current()
}, [focusReloadKey, isFocused])
useEffect(() => {
if (user?.id === undefined) return undefined
const subscription = subscribeToDictionary(
user.id,
() => {
if (realtimeTimer.current !== null) clearTimeout(realtimeTimer.current)
realtimeTimer.current = setTimeout(() => loadFirstPageRef.current(), 250)
},
(status) => setRealtimeConnected(status === 'SUBSCRIBED'),
)
return () => {
if (realtimeTimer.current !== null) {
clearTimeout(realtimeTimer.current)
realtimeTimer.current = null
}
void subscription.unsubscribe()
}
}, [user?.id])
const openAdd = useCallback((): void => {
setEditor({ ...EMPTY_EDITOR })
setEditorError(null)
}, [])
const openEdit = useCallback((entry: DictionaryEntry): void => {
setEditor({
entry,
word: entry.word,
pronunciation: entry.pronunciation ?? '',
category: entry.category,
})
setEditorError(null)
}, [])
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
try {
normalizeDictionaryDraft(editor)
} catch (candidate) {
const serviceError = candidate instanceof DictionaryServiceError
? candidate
: new DictionaryServiceError('validation', 'Dictionary values are invalid')
setEditorError(serviceError.code)
return
}
setSaving(true)
setEditorError(null)
try {
if (editor.entry === null) {
await createDictionaryEntry(user.id, editor)
} else {
await updateDictionaryEntry(user.id, editor.entry, editor)
}
setEditor(null)
await load(true)
} catch (candidate) {
const serviceError = candidate instanceof DictionaryServiceError
? candidate
: new DictionaryServiceError('server', 'Dictionary mutation failed')
setEditorError(serviceError.code)
} finally {
setSaving(false)
}
}, [editor, load, saving, user?.id])
const removeEntry = useCallback((entry: DictionaryEntry): void => {
if (deletingId !== null || user?.id === undefined) return
Alert.alert(
t('mobile.dictionary.deleteTitle'),
t('mobile.dictionary.deleteBody', { word: entry.word }),
[
{ text: t('mobile.dictionary.cancel'), style: 'cancel' },
{
text: t('mobile.dictionary.delete'),
style: 'destructive',
onPress: () => {
setDeletingId(entry.id)
void deleteDictionaryEntry(user.id, entry)
.then(() => load(true))
.catch((candidate: unknown) => {
const serviceError = candidate instanceof DictionaryServiceError
? candidate
: new DictionaryServiceError('server', 'Dictionary delete failed')
setError(serviceError.code)
})
.finally(() => setDeletingId(null))
},
},
],
)
}, [deletingId, load, t, user?.id])
const renderEntry = useCallback(({ item }: { item: DictionaryEntry }): React.ReactElement => (
<ThemeCard style={styles.entryCard} testID={`dictionary-entry-${item.id}`}>
<View style={styles.entryHeader}>
<View style={styles.entryText}>
<ThemeText variant="title" accessibilityRole="header">{item.word}</ThemeText>
{item.pronunciation !== null && (
<ThemeText color="accent">[{item.pronunciation}]</ThemeText>
)}
</View>
<View style={styles.categoryBadge}>
<ThemeText variant="label" color="muted">{t(categoryKey(item.category))}</ThemeText>
</View>
</View>
<ThemeText variant="small" color="muted">
{t('mobile.dictionary.usage', { count: item.usage_count })}
</ThemeText>
<View style={styles.entryActions}>
<ThemeButton
label={t('mobile.dictionary.edit')}
variant="secondary"
onPress={() => openEdit(item)}
style={styles.entryButton}
testID={`dictionary-edit-${item.id}`}
/>
<ThemeButton
label={deletingId === item.id
? t('mobile.dictionary.deleting')
: t('mobile.dictionary.delete')}
variant="danger"
disabled={deletingId !== null}
onPress={() => removeEntry(item)}
style={styles.entryButton}
testID={`dictionary-delete-${item.id}`}
/>
</View>
</ThemeCard>
), [deletingId, openEdit, removeEntry, styles, t])
const listEmpty = loading ? (
<View style={styles.centerState} testID="dictionary-loading">
<ActivityIndicator color={palette.accent.main} />
<ThemeText color="muted">{t('mobile.dictionary.loading')}</ThemeText>
</View>
) : error !== null ? (
<View style={styles.centerState} testID="dictionary-error">
<ThemeText color="danger" accessibilityLiveRegion="assertive">{t(errorKey(error))}</ThemeText>
<ThemeButton
label={t('mobile.dictionary.retry')}
variant="secondary"
onPress={() => void load(true)}
testID="dictionary-retry"
/>
</View>
) : (
<View style={styles.centerState} testID="dictionary-empty">
<ThemeText color="muted" style={styles.centerText}>
{debouncedSearch.length > 0 || category !== 'all'
? t('mobile.dictionary.emptySearch')
: t('mobile.dictionary.empty')}
</ThemeText>
<ThemeButton
label={t('mobile.dictionary.add')}
onPress={openAdd}
testID="dictionary-empty-add"
/>
</View>
)
return (
<View style={styles.container} testID="dictionary-screen">
<View style={[styles.header, { paddingTop: insets.top + 10 }]}>
<View>
<ThemeText variant="title" accessibilityRole="header">{t('mobile.dictionary.title')}</ThemeText>
<ThemeText variant="label" color="muted">
{t('mobile.dictionary.count', { count: total })}
</ThemeText>
</View>
<ThemeButton
label={t('mobile.dictionary.add')}
onPress={openAdd}
style={styles.addButton}
testID="dictionary-add"
/>
</View>
{!realtimeConnected && (
<View style={styles.realtimeBanner} testID="dictionary-realtime-warning">
<ThemeText variant="small" color="muted">
{t('mobile.dictionary.realtimeWaiting')}
</ThemeText>
</View>
)}
<View style={styles.searchArea}>
<TextInput
accessibilityLabel={t('mobile.dictionary.search')}
value={search}
onChangeText={setSearch}
placeholder={t('mobile.dictionary.search')}
placeholderTextColor={palette.text.muted}
maxLength={100}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
style={styles.searchInput}
testID="dictionary-search"
/>
<View style={styles.filters} accessibilityRole="tablist">
{CATEGORY_FILTERS.map((value) => {
const active = category === value
return (
<Pressable
key={value}
accessibilityRole="tab"
accessibilityState={{ selected: active }}
onPress={() => setCategory(value)}
style={[styles.filter, active && styles.filterActive]}
testID={`dictionary-filter-${value}`}
>
<ThemeText variant="label" color={active ? 'onAccent' : 'muted'}>
{t(categoryKey(value))}
</ThemeText>
</Pressable>
)
})}
</View>
</View>
{error !== null && entries.length > 0 && (
<View style={styles.inlineError} testID="dictionary-inline-error">
<ThemeText color="danger" accessibilityLiveRegion="assertive">
{t(errorKey(error))}
</ThemeText>
<ThemeButton
label={t('mobile.dictionary.retry')}
variant="secondary"
onPress={() => void load(true)}
style={styles.inlineRetry}
testID="dictionary-inline-retry"
/>
</View>
)}
<FlatList
data={entries}
keyExtractor={(item) => item.id}
renderItem={renderEntry}
contentContainerStyle={entries.length === 0
? styles.emptyList
: [styles.listContent, { paddingBottom: Math.max(insets.bottom, 24) }]}
keyboardShouldPersistTaps="handled"
refreshControl={(
<RefreshControl
refreshing={refreshing}
onRefresh={() => void load(true, true)}
tintColor={palette.accent.main}
colors={[palette.accent.main]}
/>
)}
ListEmptyComponent={listEmpty}
ListFooterComponent={loadingMore ? (
<View style={styles.loadMore} testID="dictionary-loading-more">
<ActivityIndicator color={palette.accent.main} size="small" />
<ThemeText color="muted">{t('mobile.dictionary.loadingMore')}</ThemeText>
</View>
) : null}
onEndReached={() => {
if (!loading && !loadingMore && cursor !== null) void load(false)
}}
onEndReachedThreshold={0.25}
testID="dictionary-list"
/>
<Modal
visible={editor !== null}
transparent
animationType="fade"
onRequestClose={closeEditor}
statusBarTranslucent
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalBackdrop}
>
{editor !== null && (
<View
style={styles.modalCard}
accessibilityViewIsModal
testID="dictionary-editor"
>
<ThemeText variant="title" accessibilityRole="header">
{editor.entry === null
? t('mobile.dictionary.addTitle')
: t('mobile.dictionary.editTitle')}
</ThemeText>
<View style={styles.field}>
<ThemeText variant="label" color="muted">{t('mobile.dictionary.word')}</ThemeText>
<TextInput
accessibilityLabel={t('mobile.dictionary.word')}
value={editor.word}
onChangeText={(word) => setEditor((current) => current === null
? null
: { ...current, word })}
maxLength={120}
autoFocus
autoCapitalize="none"
autoCorrect={false}
style={styles.editorInput}
testID="dictionary-editor-word"
/>
</View>
<View style={styles.field}>
<ThemeText variant="label" color="muted">
{t('mobile.dictionary.pronunciation')}
</ThemeText>
<TextInput
accessibilityLabel={t('mobile.dictionary.pronunciation')}
value={editor.pronunciation}
onChangeText={(pronunciation) => setEditor((current) => current === null
? null
: { ...current, pronunciation })}
maxLength={200}
autoCapitalize="none"
style={styles.editorInput}
testID="dictionary-editor-pronunciation"
/>
</View>
<View style={styles.field}>
<ThemeText variant="label" color="muted">{t('mobile.dictionary.category')}</ThemeText>
<View style={styles.editorCategories}>
{CATEGORY_FILTERS.filter((value): value is DictionaryCategory => value !== 'all')
.map((value) => {
const active = editor.category === value
return (
<Pressable
key={value}
accessibilityRole="radio"
accessibilityState={{ checked: active }}
onPress={() => setEditor((current) => current === null
? null
: { ...current, category: value })}
style={[styles.editorCategory, active && styles.filterActive]}
testID={`dictionary-editor-category-${value}`}
>
<ThemeText variant="label" color={active ? 'onAccent' : 'muted'}>
{t(categoryKey(value))}
</ThemeText>
</Pressable>
)
})}
</View>
</View>
{editorError !== null && (
<ThemeText
color="danger"
accessibilityLiveRegion="assertive"
testID="dictionary-editor-error"
>
{t(errorKey(editorError))}
</ThemeText>
)}
<View style={styles.modalActions}>
<ThemeButton
label={t('mobile.dictionary.cancel')}
variant="secondary"
disabled={saving}
onPress={closeEditor}
style={styles.modalButton}
testID="dictionary-editor-cancel"
/>
<ThemeButton
label={saving
? t('mobile.dictionary.saving')
: t('mobile.dictionary.save')}
disabled={saving || editor.word.trim().length === 0}
onPress={() => void saveEditor()}
style={styles.modalButton}
testID="dictionary-editor-save"
/>
</View>
</View>
)}
</KeyboardAvoidingView>
</Modal>
</View>
)
}
type Palette = ReturnType<typeof useMobilePreferences>['palette']
function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
return StyleSheet.create({
container: { flex: 1, backgroundColor: palette.bg.app },
header: {
minHeight: 86,
paddingHorizontal: 20,
paddingBottom: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: palette.border.default,
backgroundColor: palette.bg.sidebar,
},
addButton: { minWidth: 86 },
realtimeBanner: {
paddingHorizontal: 20,
paddingVertical: 8,
backgroundColor: palette.accent.dim,
},
searchArea: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: palette.border.subtle,
gap: 10,
},
inlineError: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
paddingHorizontal: 20,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: palette.tag.red,
backgroundColor: palette.bg.card,
},
inlineRetry: { minWidth: 82, minHeight: 42 },
searchInput: {
minHeight: 48,
borderRadius: 10,
borderWidth: 1,
borderColor: palette.border.default,
backgroundColor: palette.bg.inset,
color: palette.text.primary,
paddingHorizontal: 14,
fontSize: 14,
},
filters: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
filter: {
minHeight: 40,
minWidth: 62,
paddingHorizontal: 12,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 9,
borderWidth: 1,
borderColor: palette.border.default,
backgroundColor: palette.bg.card,
},
filterActive: {
backgroundColor: palette.accent.main,
borderColor: palette.accent.main,
},
listContent: { padding: 20 },
emptyList: { flexGrow: 1 },
centerState: {
flex: 1,
minHeight: 280,
alignItems: 'center',
justifyContent: 'center',
gap: 14,
padding: 28,
},
centerText: { textAlign: 'center', lineHeight: 21 },
entryCard: { marginBottom: 12, gap: 10 },
entryHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
},
entryText: { flex: 1, gap: 4 },
categoryBadge: {
minHeight: 30,
justifyContent: 'center',
paddingHorizontal: 9,
borderRadius: 8,
backgroundColor: palette.bg.inset,
borderWidth: 1,
borderColor: palette.border.subtle,
},
entryActions: { flexDirection: 'row', gap: 10, marginTop: 4 },
entryButton: { flex: 1, minHeight: 44 },
loadMore: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 10,
padding: 16,
},
modalBackdrop: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
backgroundColor: palette.scrim,
},
modalCard: {
width: '100%',
maxWidth: 520,
padding: 20,
gap: 16,
borderRadius: 18,
borderWidth: 1,
borderColor: palette.border.strong,
backgroundColor: palette.bg.elevated,
},
field: { gap: 7 },
editorInput: {
minHeight: 48,
paddingHorizontal: 12,
borderRadius: 9,
borderWidth: 1,
borderColor: palette.border.default,
backgroundColor: palette.bg.inset,
color: palette.text.primary,
fontSize: 15,
},
editorCategories: { flexDirection: 'row', gap: 8 },
editorCategory: {
flex: 1,
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 8,
borderRadius: 9,
borderWidth: 1,
borderColor: palette.border.default,
backgroundColor: palette.bg.card,
},
modalActions: { flexDirection: 'row', gap: 10, marginTop: 2 },
modalButton: { flex: 1 },
})
}