720 lines
25 KiB
TypeScript
720 lines
25 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
FlatList,
|
|
RefreshControl,
|
|
StyleSheet,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native'
|
|
import { useFocusEffect } from '@react-navigation/native'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import type { KnowledgeDocument } from '@d3ro/api-client'
|
|
import { useI18n, type TFunction } from '@d3ro/i18n'
|
|
import { useAuth } from '../lib/auth-context'
|
|
import { useMobilePreferences } from '../lib/preferences-context'
|
|
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
|
|
import { pickKnowledgeTextFile } from '../features/knowledge/knowledge-file-picker'
|
|
import {
|
|
createKnowledgeDocument,
|
|
deleteKnowledgeDocument,
|
|
indexKnowledgeDocument,
|
|
KnowledgeServiceError,
|
|
listKnowledgeDocuments,
|
|
mergeKnowledgeDocuments,
|
|
searchKnowledge,
|
|
subscribeToKnowledgeDocuments,
|
|
type KnowledgeSearchResult,
|
|
type KnowledgeServiceErrorCode,
|
|
} from '../features/knowledge/knowledge-service'
|
|
|
|
export default function KnowledgeScreen(): React.ReactElement {
|
|
const insets = useSafeAreaInsets()
|
|
const { t, formatDate } = useI18n()
|
|
const { user, session } = useAuth()
|
|
const { palette } = useMobilePreferences()
|
|
const styles = useMemo(() => createStyles(palette), [palette])
|
|
const requestGeneration = useRef(0)
|
|
const searchController = useRef<AbortController | null>(null)
|
|
const indexController = useRef<AbortController | null>(null)
|
|
const importInFlight = useRef(false)
|
|
const indexInFlight = useRef(false)
|
|
const queryInFlight = useRef(false)
|
|
const deletingIds = useRef(new Set<string>())
|
|
const [documents, setDocuments] = useState<KnowledgeDocument[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [page, setPage] = useState(0)
|
|
const [hasMore, setHasMore] = useState(false)
|
|
const [documentSearch, setDocumentSearch] = useState('')
|
|
const [activeDocumentSearch, setActiveDocumentSearch] = useState('')
|
|
const [query, setQuery] = useState('')
|
|
const [results, setResults] = useState<KnowledgeSearchResult[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
const [importing, setImporting] = useState(false)
|
|
const [indexingId, setIndexingId] = useState<string | null>(null)
|
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
const [querying, setQuerying] = useState(false)
|
|
const [connected, setConnected] = useState(false)
|
|
const [error, setError] = useState<KnowledgeServiceErrorCode | null>(null)
|
|
const [notice, setNotice] = useState<string | null>(null)
|
|
|
|
const loadFirstPage = useCallback(async (pullToRefresh = false): Promise<void> => {
|
|
const generation = ++requestGeneration.current
|
|
if (user === null) {
|
|
setDocuments([])
|
|
setTotal(0)
|
|
setHasMore(false)
|
|
setError('auth')
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
return
|
|
}
|
|
if (pullToRefresh) setRefreshing(true)
|
|
else setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const result = await listKnowledgeDocuments({
|
|
userId: user.id,
|
|
search: activeDocumentSearch,
|
|
page: 0,
|
|
})
|
|
if (generation !== requestGeneration.current) return
|
|
setDocuments(result.documents)
|
|
setTotal(result.total)
|
|
setPage(0)
|
|
setHasMore(result.hasMore)
|
|
} catch (requestError) {
|
|
if (generation === requestGeneration.current) setError(errorCode(requestError))
|
|
} finally {
|
|
if (generation === requestGeneration.current) {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}
|
|
}, [activeDocumentSearch, user])
|
|
|
|
useFocusEffect(useCallback(() => {
|
|
void loadFirstPage(false)
|
|
return () => {
|
|
requestGeneration.current += 1
|
|
searchController.current?.abort()
|
|
indexController.current?.abort()
|
|
}
|
|
}, [loadFirstPage]))
|
|
|
|
useEffect(() => {
|
|
if (user === null) return undefined
|
|
const subscription = subscribeToKnowledgeDocuments(
|
|
() => { void loadFirstPage(false) },
|
|
setConnected,
|
|
)
|
|
return () => { void subscription.unsubscribe() }
|
|
}, [loadFirstPage, user])
|
|
|
|
const submitDocumentSearch = (): void => {
|
|
const normalized = documentSearch.trim()
|
|
if (normalized === activeDocumentSearch) void loadFirstPage(false)
|
|
else setActiveDocumentSearch(normalized)
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadFirstPage(false)
|
|
}, [activeDocumentSearch, loadFirstPage])
|
|
|
|
const loadMore = async (): Promise<void> => {
|
|
if (user === null || loading || loadingMore || !hasMore) return
|
|
const targetPage = page + 1
|
|
setLoadingMore(true)
|
|
try {
|
|
const result = await listKnowledgeDocuments({
|
|
userId: user.id,
|
|
search: activeDocumentSearch,
|
|
page: targetPage,
|
|
})
|
|
setDocuments((current) => mergeKnowledgeDocuments(current, result.documents))
|
|
setPage(targetPage)
|
|
setTotal(result.total)
|
|
setHasMore(result.hasMore)
|
|
} catch (requestError) {
|
|
setError(errorCode(requestError))
|
|
} finally {
|
|
setLoadingMore(false)
|
|
}
|
|
}
|
|
|
|
const importDocument = async (): Promise<void> => {
|
|
if (
|
|
user === null
|
|
|| session === null
|
|
|| importInFlight.current
|
|
|| indexInFlight.current
|
|
|| importing
|
|
|| indexingId !== null
|
|
) return
|
|
importInFlight.current = true
|
|
setImporting(true)
|
|
setError(null)
|
|
setNotice(null)
|
|
let picked: Awaited<ReturnType<typeof pickKnowledgeTextFile>> | null = null
|
|
try {
|
|
picked = await pickKnowledgeTextFile()
|
|
const created = await createKnowledgeDocument({
|
|
userId: user.id,
|
|
title: picked.title,
|
|
fileName: picked.fileName,
|
|
fileType: picked.fileType,
|
|
content: picked.content,
|
|
})
|
|
setDocuments((current) => mergeKnowledgeDocuments([created], current))
|
|
setTotal((current) => current + 1)
|
|
setIndexingId(created.id)
|
|
const controller = new AbortController()
|
|
indexController.current = controller
|
|
try {
|
|
const indexed = await indexKnowledgeDocument({
|
|
accessToken: session.access_token,
|
|
userId: user.id,
|
|
document: created,
|
|
signal: controller.signal,
|
|
})
|
|
setDocuments((current) => current.map((document) => (
|
|
document.id === indexed.id ? indexed : document
|
|
)))
|
|
setNotice(t('mobile.knowledge.importReady'))
|
|
} catch (indexError) {
|
|
if (errorCode(indexError) !== 'cancelled') {
|
|
setError(errorCode(indexError))
|
|
setNotice(t('mobile.knowledge.importSavedPending'))
|
|
}
|
|
} finally {
|
|
if (indexController.current === controller) indexController.current = null
|
|
setIndexingId(null)
|
|
}
|
|
} catch (requestError) {
|
|
const code = errorCode(requestError)
|
|
if (code !== 'cancelled') setError(code)
|
|
} finally {
|
|
if (picked !== null) {
|
|
try {
|
|
await picked.dispose()
|
|
} catch {
|
|
setNotice(t('mobile.knowledge.cleanupWarning'))
|
|
}
|
|
}
|
|
setImporting(false)
|
|
importInFlight.current = false
|
|
}
|
|
}
|
|
|
|
const retryIndex = async (document: KnowledgeDocument): Promise<void> => {
|
|
if (
|
|
user === null
|
|
|| session === null
|
|
|| document.user_id !== user.id
|
|
|| indexInFlight.current
|
|
|| indexingId !== null
|
|
) return
|
|
indexInFlight.current = true
|
|
setError(null)
|
|
setNotice(null)
|
|
setIndexingId(document.id)
|
|
const controller = new AbortController()
|
|
indexController.current = controller
|
|
try {
|
|
const indexed = await indexKnowledgeDocument({
|
|
accessToken: session.access_token,
|
|
userId: user.id,
|
|
document,
|
|
signal: controller.signal,
|
|
})
|
|
setDocuments((current) => current.map((candidate) => (
|
|
candidate.id === indexed.id ? indexed : candidate
|
|
)))
|
|
setNotice(t('mobile.knowledge.indexReady'))
|
|
} catch (requestError) {
|
|
const code = errorCode(requestError)
|
|
if (code !== 'cancelled') setError(code)
|
|
} finally {
|
|
if (indexController.current === controller) indexController.current = null
|
|
setIndexingId(null)
|
|
indexInFlight.current = false
|
|
}
|
|
}
|
|
|
|
const confirmDelete = (document: KnowledgeDocument): void => {
|
|
Alert.alert(
|
|
t('mobile.knowledge.deleteTitle'),
|
|
t('mobile.knowledge.deleteBody', { title: document.title }),
|
|
[
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
{
|
|
text: t('mobile.knowledge.delete'),
|
|
style: 'destructive',
|
|
onPress: () => { void performDelete(document) },
|
|
},
|
|
],
|
|
)
|
|
}
|
|
|
|
const performDelete = async (document: KnowledgeDocument): Promise<void> => {
|
|
if (user === null || deletingId !== null || deletingIds.current.has(document.id)) return
|
|
deletingIds.current.add(document.id)
|
|
setDeletingId(document.id)
|
|
setError(null)
|
|
try {
|
|
await deleteKnowledgeDocument(user.id, document)
|
|
setDocuments((current) => current.filter((candidate) => candidate.id !== document.id))
|
|
setTotal((current) => Math.max(0, current - 1))
|
|
setResults((current) => current.filter((result) => result.documentId !== document.id))
|
|
} catch (requestError) {
|
|
const code = errorCode(requestError)
|
|
setError(code)
|
|
if (code === 'conflict') void loadFirstPage(false)
|
|
} finally {
|
|
setDeletingId(null)
|
|
deletingIds.current.delete(document.id)
|
|
}
|
|
}
|
|
|
|
const runQuery = async (): Promise<void> => {
|
|
if (session === null || query.trim().length === 0 || queryInFlight.current || querying) return
|
|
queryInFlight.current = true
|
|
const controller = new AbortController()
|
|
searchController.current = controller
|
|
setQuerying(true)
|
|
setError(null)
|
|
setNotice(null)
|
|
try {
|
|
const response = await searchKnowledge({
|
|
accessToken: session.access_token,
|
|
query,
|
|
count: 8,
|
|
signal: controller.signal,
|
|
})
|
|
setResults(response)
|
|
if (response.length === 0) setNotice(t('mobile.knowledge.queryEmpty'))
|
|
} catch (requestError) {
|
|
const code = errorCode(requestError)
|
|
if (code !== 'cancelled') setError(code)
|
|
} finally {
|
|
if (searchController.current === controller) searchController.current = null
|
|
setQuerying(false)
|
|
queryInFlight.current = false
|
|
}
|
|
}
|
|
|
|
const header = (
|
|
<View>
|
|
<View style={[styles.header, { paddingTop: insets.top + 12 }] }>
|
|
<View style={styles.headerCopy}>
|
|
<ThemeText variant="title" accessibilityRole="header">
|
|
{t('mobile.knowledge.title')}
|
|
</ThemeText>
|
|
<ThemeText variant="label" color="muted">
|
|
{t('mobile.knowledge.count', { count: total })}
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeButton
|
|
label={importing ? t('mobile.knowledge.importing') : t('mobile.knowledge.import')}
|
|
disabled={importing || indexingId !== null || user === null}
|
|
onPress={() => { void importDocument() }}
|
|
testID="knowledge-import"
|
|
/>
|
|
</View>
|
|
|
|
{!connected && user !== null && (
|
|
<View style={styles.syncBanner} testID="knowledge-realtime-pending">
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.knowledge.realtimeWaiting')}
|
|
</ThemeText>
|
|
</View>
|
|
)}
|
|
|
|
<ThemeCard inset style={styles.contractCard} testID="knowledge-contract">
|
|
<ThemeText color="secondary" style={styles.contractText}>
|
|
{t('mobile.knowledge.contractNotice')}
|
|
</ThemeText>
|
|
</ThemeCard>
|
|
|
|
<View style={styles.searchRow}>
|
|
<TextInput
|
|
accessibilityLabel={t('mobile.knowledge.documentSearch')}
|
|
value={documentSearch}
|
|
onChangeText={setDocumentSearch}
|
|
onSubmitEditing={submitDocumentSearch}
|
|
placeholder={t('mobile.knowledge.documentSearch')}
|
|
placeholderTextColor={palette.text.muted}
|
|
returnKeyType="search"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
maxLength={500}
|
|
style={styles.input}
|
|
testID="knowledge-document-search"
|
|
/>
|
|
<ThemeButton
|
|
label={t('mobile.knowledge.searchDocuments')}
|
|
variant="secondary"
|
|
onPress={submitDocumentSearch}
|
|
style={styles.searchButton}
|
|
testID="knowledge-document-search-submit"
|
|
/>
|
|
</View>
|
|
|
|
<ThemeCard style={styles.queryCard} testID="knowledge-query-card">
|
|
<ThemeText variant="heading">{t('mobile.knowledge.queryTitle')}</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.knowledge.queryNotice')}
|
|
</ThemeText>
|
|
<TextInput
|
|
accessibilityLabel={t('mobile.knowledge.queryPlaceholder')}
|
|
value={query}
|
|
onChangeText={setQuery}
|
|
onSubmitEditing={() => { void runQuery() }}
|
|
editable={!querying}
|
|
placeholder={t('mobile.knowledge.queryPlaceholder')}
|
|
placeholderTextColor={palette.text.muted}
|
|
returnKeyType="search"
|
|
maxLength={500}
|
|
style={styles.input}
|
|
testID="knowledge-query-input"
|
|
/>
|
|
<View style={styles.queryActions}>
|
|
<ThemeButton
|
|
label={querying ? t('mobile.knowledge.querying') : t('mobile.knowledge.query')}
|
|
disabled={querying || query.trim().length === 0 || session === null}
|
|
onPress={() => { void runQuery() }}
|
|
style={styles.flexButton}
|
|
testID="knowledge-query-submit"
|
|
/>
|
|
{querying && (
|
|
<ThemeButton
|
|
label={t('mobile.knowledge.cancel')}
|
|
variant="secondary"
|
|
onPress={() => searchController.current?.abort()}
|
|
style={styles.cancelButton}
|
|
testID="knowledge-query-cancel"
|
|
/>
|
|
)}
|
|
</View>
|
|
{results.length > 0 && (
|
|
<View style={styles.results} accessibilityLiveRegion="polite" testID="knowledge-results">
|
|
<ThemeText variant="label" color="accent">
|
|
{t('mobile.knowledge.resultCount', { count: results.length })}
|
|
</ThemeText>
|
|
{results.map((result) => (
|
|
<View key={result.id} style={styles.resultItem} testID={`knowledge-result-${result.id}`}>
|
|
<View style={styles.resultMeta}>
|
|
<ThemeText variant="label" color="muted">
|
|
{documentTitle(documents, result.documentId, t)} · #{result.chunkIndex + 1}
|
|
</ThemeText>
|
|
<ThemeText variant="label" color="accent">
|
|
{Math.round(result.similarity * 100)}%
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeText style={styles.resultText}>{result.content}</ThemeText>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
</ThemeCard>
|
|
|
|
{error !== null && (
|
|
<View style={styles.errorBanner} accessibilityLiveRegion="assertive" testID="knowledge-error">
|
|
<ThemeText color="danger" style={styles.flexText}>
|
|
{knowledgeErrorMessage(error, t)}
|
|
</ThemeText>
|
|
<ThemeButton
|
|
label={t('mobile.knowledge.retry')}
|
|
variant="quiet"
|
|
onPress={() => { void loadFirstPage(false) }}
|
|
testID="knowledge-retry"
|
|
/>
|
|
</View>
|
|
)}
|
|
{notice !== null && (
|
|
<View style={styles.notice} accessibilityLiveRegion="polite" testID="knowledge-notice">
|
|
<ThemeText color="secondary">{notice}</ThemeText>
|
|
</View>
|
|
)}
|
|
|
|
<ThemeText variant="label" color="muted" style={styles.libraryLabel}>
|
|
{t('mobile.knowledge.library')}
|
|
</ThemeText>
|
|
</View>
|
|
)
|
|
|
|
return (
|
|
<View style={styles.container} testID="knowledge-screen">
|
|
{loading && documents.length === 0 ? (
|
|
<View style={styles.loadingWrap}>
|
|
{header}
|
|
<View style={styles.center} testID="knowledge-loading">
|
|
<ActivityIndicator size="large" color={palette.accent.main} />
|
|
<ThemeText color="muted">{t('mobile.knowledge.loading')}</ThemeText>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={documents}
|
|
keyExtractor={(document) => document.id}
|
|
ListHeaderComponent={header}
|
|
contentContainerStyle={documents.length === 0 ? styles.emptyList : styles.list}
|
|
keyboardShouldPersistTaps="handled"
|
|
refreshControl={(
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={() => { void loadFirstPage(true) }}
|
|
tintColor={palette.accent.main}
|
|
colors={[palette.accent.main]}
|
|
/>
|
|
)}
|
|
onEndReached={() => { void loadMore() }}
|
|
onEndReachedThreshold={0.25}
|
|
ListEmptyComponent={(
|
|
<ThemeCard style={styles.emptyCard} testID="knowledge-empty">
|
|
<ThemeText color="muted" style={styles.emptyText}>
|
|
{activeDocumentSearch.length > 0
|
|
? t('mobile.knowledge.emptySearch')
|
|
: t('mobile.knowledge.empty')}
|
|
</ThemeText>
|
|
</ThemeCard>
|
|
)}
|
|
ListFooterComponent={loadingMore ? (
|
|
<View style={styles.loadingMore} testID="knowledge-loading-more">
|
|
<ActivityIndicator color={palette.accent.main} />
|
|
</View>
|
|
) : <View style={{ height: insets.bottom + 90 }} />}
|
|
renderItem={({ item: document }) => {
|
|
const owner = document.user_id === user?.id
|
|
const indexing = indexingId === document.id
|
|
const deleting = deletingId === document.id
|
|
return (
|
|
<ThemeCard style={styles.documentCard} testID={`knowledge-document-${document.id}`}>
|
|
<View style={styles.documentTop}>
|
|
<View style={styles.flexText}>
|
|
<ThemeText numberOfLines={2}>{document.title}</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{document.file_name ?? t('mobile.knowledge.noFileName')}
|
|
</ThemeText>
|
|
</View>
|
|
<StatusBadge indexed={document.indexed} processing={indexing} />
|
|
</View>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.knowledge.chunkCount', { count: document.chunk_count })} · {' '}
|
|
{formatDate(new Date(document.created_at), {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
})}
|
|
</ThemeText>
|
|
{!owner && (
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.knowledge.teamShared')}
|
|
</ThemeText>
|
|
)}
|
|
{owner && (
|
|
<View style={styles.documentActions}>
|
|
{!document.indexed && (
|
|
<ThemeButton
|
|
label={indexing
|
|
? t('mobile.knowledge.indexing')
|
|
: t('mobile.knowledge.retryIndex')}
|
|
variant="secondary"
|
|
disabled={indexing || deleting || indexingId !== null}
|
|
onPress={() => { void retryIndex(document) }}
|
|
style={styles.flexButton}
|
|
testID={`knowledge-index-${document.id}`}
|
|
/>
|
|
)}
|
|
{indexing && (
|
|
<ThemeButton
|
|
label={t('mobile.knowledge.cancel')}
|
|
variant="quiet"
|
|
onPress={() => indexController.current?.abort()}
|
|
testID={`knowledge-index-cancel-${document.id}`}
|
|
/>
|
|
)}
|
|
<ThemeButton
|
|
label={deleting
|
|
? t('mobile.knowledge.deleting')
|
|
: t('mobile.knowledge.delete')}
|
|
variant="quiet"
|
|
disabled={deleting || indexing}
|
|
onPress={() => confirmDelete(document)}
|
|
testID={`knowledge-delete-${document.id}`}
|
|
/>
|
|
</View>
|
|
)}
|
|
</ThemeCard>
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function errorCode(error: unknown): KnowledgeServiceErrorCode {
|
|
return error instanceof KnowledgeServiceError ? error.code : 'server'
|
|
}
|
|
|
|
function knowledgeErrorMessage(code: KnowledgeServiceErrorCode, t: TFunction): string {
|
|
if (code === 'auth') return t('mobile.knowledge.error.auth')
|
|
if (code === 'conflict') return t('mobile.knowledge.error.conflict')
|
|
if (code === 'forbidden') return t('mobile.knowledge.error.forbidden')
|
|
if (code === 'index-failed') return t('mobile.knowledge.error.indexFailed')
|
|
if (code === 'index-unavailable') return t('mobile.knowledge.error.indexUnavailable')
|
|
if (code === 'invalid-response') return t('mobile.knowledge.error.response')
|
|
if (code === 'network') return t('mobile.knowledge.error.network')
|
|
if (code === 'not-found') return t('mobile.knowledge.error.notFound')
|
|
if (code === 'timeout') return t('mobile.knowledge.error.timeout')
|
|
if (code === 'validation') return t('mobile.knowledge.error.validation')
|
|
if (code === 'cancelled') return t('mobile.knowledge.error.cancelled')
|
|
return t('mobile.knowledge.error.server')
|
|
}
|
|
|
|
function documentTitle(
|
|
documents: KnowledgeDocument[],
|
|
documentId: string,
|
|
t: TFunction,
|
|
): string {
|
|
return documents.find((document) => document.id === documentId)?.title
|
|
?? t('mobile.knowledge.unknownDocument')
|
|
}
|
|
|
|
function StatusBadge({
|
|
indexed,
|
|
processing,
|
|
}: {
|
|
indexed: boolean
|
|
processing: boolean
|
|
}): React.ReactElement {
|
|
const { t } = useI18n()
|
|
const { palette } = useMobilePreferences()
|
|
const label = processing
|
|
? t('mobile.knowledge.statusProcessing')
|
|
: indexed
|
|
? t('mobile.knowledge.statusReady')
|
|
: t('mobile.knowledge.statusPending')
|
|
const color = processing || !indexed ? palette.tag.orange : palette.tag.green
|
|
return (
|
|
<View style={[stylesStatic.badge, { borderColor: color }]}>
|
|
<ThemeText variant="label" style={{ color }}>{label}</ThemeText>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
type Palette = ReturnType<typeof useMobilePreferences>['palette']
|
|
|
|
const stylesStatic = StyleSheet.create({
|
|
badge: {
|
|
minHeight: 30,
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 9,
|
|
borderWidth: 1,
|
|
borderRadius: 999,
|
|
},
|
|
})
|
|
|
|
function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|
return StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: palette.bg.app },
|
|
loadingWrap: { flex: 1 },
|
|
list: { paddingBottom: 100 },
|
|
emptyList: { flexGrow: 1, paddingBottom: 100 },
|
|
header: {
|
|
minHeight: 92,
|
|
paddingHorizontal: 20,
|
|
paddingBottom: 10,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 12,
|
|
},
|
|
headerCopy: { flex: 1, gap: 4 },
|
|
syncBanner: {
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 8,
|
|
borderTopWidth: 1,
|
|
borderBottomWidth: 1,
|
|
borderColor: palette.border.subtle,
|
|
backgroundColor: palette.bg.sidebar,
|
|
},
|
|
contractCard: { marginHorizontal: 20, marginBottom: 12 },
|
|
contractText: { lineHeight: 20 },
|
|
searchRow: {
|
|
paddingHorizontal: 20,
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
marginBottom: 12,
|
|
},
|
|
input: {
|
|
flex: 1,
|
|
minHeight: 48,
|
|
borderWidth: 1,
|
|
borderColor: palette.border.default,
|
|
borderRadius: 10,
|
|
paddingHorizontal: 14,
|
|
backgroundColor: palette.bg.inset,
|
|
color: palette.text.primary,
|
|
fontSize: 14,
|
|
},
|
|
searchButton: { minHeight: 48, paddingHorizontal: 12 },
|
|
queryCard: { marginHorizontal: 20, gap: 12 },
|
|
queryActions: { flexDirection: 'row', gap: 8 },
|
|
flexButton: { flex: 1 },
|
|
cancelButton: { minWidth: 92 },
|
|
results: { gap: 10, marginTop: 4 },
|
|
resultItem: {
|
|
gap: 7,
|
|
padding: 12,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
borderColor: palette.border.subtle,
|
|
backgroundColor: palette.bg.inset,
|
|
},
|
|
resultMeta: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 },
|
|
resultText: { lineHeight: 20 },
|
|
errorBanner: {
|
|
marginHorizontal: 20,
|
|
marginTop: 12,
|
|
paddingHorizontal: 12,
|
|
minHeight: 56,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
borderWidth: 1,
|
|
borderColor: palette.tag.red,
|
|
borderRadius: 10,
|
|
backgroundColor: palette.bg.card,
|
|
},
|
|
notice: {
|
|
marginHorizontal: 20,
|
|
marginTop: 12,
|
|
padding: 12,
|
|
borderWidth: 1,
|
|
borderColor: palette.accent.main,
|
|
borderRadius: 10,
|
|
backgroundColor: palette.accent.dim,
|
|
},
|
|
libraryLabel: { marginHorizontal: 24, marginTop: 24, marginBottom: 10, letterSpacing: 2.2 },
|
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, padding: 32 },
|
|
emptyCard: { marginHorizontal: 20, alignItems: 'center', paddingVertical: 36 },
|
|
emptyText: { textAlign: 'center' },
|
|
loadingMore: { minHeight: 72, justifyContent: 'center', alignItems: 'center' },
|
|
documentCard: { marginHorizontal: 20, marginBottom: 12, gap: 10 },
|
|
documentTop: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
|
|
flexText: { flex: 1 },
|
|
documentActions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-end',
|
|
gap: 8,
|
|
paddingTop: 4,
|
|
borderTopWidth: 1,
|
|
borderTopColor: palette.border.subtle,
|
|
},
|
|
})
|
|
}
|