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,714 @@
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: 'rgba(0,0,0,0.72)',
},
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 },
})
}