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.
714 lines
23 KiB
TypeScript
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 },
|
|
})
|
|
}
|