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 | null>(null) const [entries, setEntries] = useState([]) const [cursor, setCursor] = useState(null) const [total, setTotal] = useState(0) const [search, setSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') const [category, setCategory] = useState('all') const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [loadingMore, setLoadingMore] = 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 [deletingId, setDeletingId] = useState(null) useEffect(() => { const timer = setTimeout(() => setDebouncedSearch(search), 350) return () => clearTimeout(timer) }, [search]) const load = useCallback(async ( reset: boolean, pullToRefresh = false, ): Promise => { 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(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 => { 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 => ( {item.word} {item.pronunciation !== null && ( [{item.pronunciation}] )} {t(categoryKey(item.category))} {t('mobile.dictionary.usage', { count: item.usage_count })} openEdit(item)} style={styles.entryButton} testID={`dictionary-edit-${item.id}`} /> removeEntry(item)} style={styles.entryButton} testID={`dictionary-delete-${item.id}`} /> ), [deletingId, openEdit, removeEntry, styles, t]) const listEmpty = loading ? ( {t('mobile.dictionary.loading')} ) : error !== null ? ( {t(errorKey(error))} void load(true)} testID="dictionary-retry" /> ) : ( {debouncedSearch.length > 0 || category !== 'all' ? t('mobile.dictionary.emptySearch') : t('mobile.dictionary.empty')} ) return ( {t('mobile.dictionary.title')} {t('mobile.dictionary.count', { count: total })} {!realtimeConnected && ( {t('mobile.dictionary.realtimeWaiting')} )} {CATEGORY_FILTERS.map((value) => { const active = category === value return ( setCategory(value)} style={[styles.filter, active && styles.filterActive]} testID={`dictionary-filter-${value}`} > {t(categoryKey(value))} ) })} {error !== null && entries.length > 0 && ( {t(errorKey(error))} void load(true)} style={styles.inlineRetry} testID="dictionary-inline-retry" /> )} item.id} renderItem={renderEntry} contentContainerStyle={entries.length === 0 ? styles.emptyList : [styles.listContent, { paddingBottom: Math.max(insets.bottom, 24) }]} keyboardShouldPersistTaps="handled" refreshControl={( void load(true, true)} tintColor={palette.accent.main} colors={[palette.accent.main]} /> )} ListEmptyComponent={listEmpty} ListFooterComponent={loadingMore ? ( {t('mobile.dictionary.loadingMore')} ) : null} onEndReached={() => { if (!loading && !loadingMore && cursor !== null) void load(false) }} onEndReachedThreshold={0.25} testID="dictionary-list" /> {editor !== null && ( {editor.entry === null ? t('mobile.dictionary.addTitle') : t('mobile.dictionary.editTitle')} {t('mobile.dictionary.word')} setEditor((current) => current === null ? null : { ...current, word })} maxLength={120} autoFocus autoCapitalize="none" autoCorrect={false} style={styles.editorInput} testID="dictionary-editor-word" /> {t('mobile.dictionary.pronunciation')} setEditor((current) => current === null ? null : { ...current, pronunciation })} maxLength={200} autoCapitalize="none" style={styles.editorInput} testID="dictionary-editor-pronunciation" /> {t('mobile.dictionary.category')} {CATEGORY_FILTERS.filter((value): value is DictionaryCategory => value !== 'all') .map((value) => { const active = editor.category === value return ( setEditor((current) => current === null ? null : { ...current, category: value })} style={[styles.editorCategory, active && styles.filterActive]} testID={`dictionary-editor-category-${value}`} > {t(categoryKey(value))} ) })} {editorError !== null && ( {t(errorKey(editorError))} )} void saveEditor()} style={styles.modalButton} testID="dictionary-editor-save" /> )} ) } type Palette = ReturnType['palette'] function createStyles(palette: Palette): ReturnType { 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 }, }) }