- packages/ui-native theme.ts accent: amber->main, amberDim->dim, amberGlow->glow (값 보존)
- apps/mobile 20건 + apps/mobile-rn 18건 + ui-native 컴포넌트 9건 = 47 참조 치환
SKIP: Led/PhosphorText/Header color prop 'amber'(의미론적 컴포넌트 API, 별도 마이그레이션)
green/greenGlow 키(amber계 범위外)
정책: docs/REFACTOR_POLICY.md DP1, 이식 인사이트 P7
257 lines
7.2 KiB
TypeScript
257 lines
7.2 KiB
TypeScript
// apps/mobile/app/(tabs)/history.tsx
|
|
// History tab — transcription records list
|
|
// Design ref: docs/v3/designs/history.html
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import { View, FlatList, StyleSheet, ActivityIndicator, RefreshControl, Pressable } from 'react-native'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import {
|
|
MetalCard,
|
|
PhosphorText,
|
|
Led,
|
|
Header,
|
|
FilterChip,
|
|
AppStatusBar,
|
|
d3roNativePalette
|
|
} from '@d3ro/ui-native'
|
|
import { useI18n } from '@d3ro/i18n'
|
|
import { supabase, isSupabaseConfigured } from '../../lib/supabase'
|
|
|
|
interface HistoryEntry {
|
|
id: string
|
|
original_text: string | null
|
|
polished_text: string | null
|
|
mode: string
|
|
status: string
|
|
created_at: string
|
|
stt_model: string | null
|
|
word_count: number | null
|
|
}
|
|
|
|
type FilterType = 'all' | 'favorites' | 'processing'
|
|
|
|
export default function HistoryScreen(): React.ReactElement {
|
|
const insets = useSafeAreaInsets()
|
|
const { t, formatTime, formatRelativeDate } = useI18n()
|
|
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [filter, setFilter] = useState<FilterType>('all')
|
|
|
|
const load = useCallback(async (): Promise<void> => {
|
|
if (!isSupabaseConfigured()) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
try {
|
|
const query = supabase
|
|
.from('history')
|
|
.select('id, original_text, polished_text, mode, status, created_at, stt_model, word_count')
|
|
.order('created_at', { ascending: false })
|
|
.limit(50)
|
|
|
|
const { data, error } = await query
|
|
if (!error && data) {
|
|
setEntries(data as HistoryEntry[])
|
|
}
|
|
} finally {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void load()
|
|
}, [load])
|
|
|
|
// Group entries by date
|
|
const grouped = groupByDate(entries, formatRelativeDate)
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<ActivityIndicator size="large" color={d3roNativePalette.accent.main} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<Header
|
|
title={t('mobile.hist.title')}
|
|
rightContent={<Led color="green" size={6} />}
|
|
paddingTop={insets.top}
|
|
/>
|
|
|
|
{/* Filter Chips */}
|
|
<View style={styles.filters}>
|
|
<FilterChip
|
|
label={t('mobile.hist.all')}
|
|
active={filter === 'all'}
|
|
onPress={() => setFilter('all')}
|
|
/>
|
|
<FilterChip
|
|
label={t('mobile.hist.saved')}
|
|
active={filter === 'favorites'}
|
|
onPress={() => setFilter('favorites')}
|
|
/>
|
|
<FilterChip
|
|
label={t('mobile.hist.processing')}
|
|
active={filter === 'processing'}
|
|
onPress={() => setFilter('processing')}
|
|
/>
|
|
</View>
|
|
|
|
<FlatList
|
|
data={grouped}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={entries.length === 0 ? styles.center : styles.listContent}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={() => { setRefreshing(true); void load() }}
|
|
tintColor={d3roNativePalette.accent.main}
|
|
/>
|
|
}
|
|
ListEmptyComponent={
|
|
<PhosphorText variant="body" color="muted" style={styles.emptyText}>
|
|
{t('mobile.hist.empty')}
|
|
</PhosphorText>
|
|
}
|
|
renderItem={({ item }) => {
|
|
if (item.type === 'header') {
|
|
return (
|
|
<PhosphorText variant="label" color="muted" style={styles.dateHeader}>
|
|
{item.label}
|
|
</PhosphorText>
|
|
)
|
|
}
|
|
const entry = item.entry
|
|
const isOld = !isToday(entry.created_at)
|
|
return (
|
|
<Pressable>
|
|
<MetalCard style={[styles.card, isOld && styles.cardOld]}>
|
|
<View style={styles.cardHeader}>
|
|
<View style={styles.cardHeaderLeft}>
|
|
<Led
|
|
color={entry.status === 'completed' ? 'green' : 'amber'}
|
|
size={6}
|
|
on={!isOld}
|
|
/>
|
|
<PhosphorText variant="label" color="primary" style={styles.timeLabel}>
|
|
{formatTime(new Date(entry.created_at).getTime())}
|
|
</PhosphorText>
|
|
</View>
|
|
{entry.word_count != null && (
|
|
<View style={styles.wordBadge}>
|
|
<PhosphorText variant="label" color="amber">
|
|
{entry.word_count} W
|
|
</PhosphorText>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<PhosphorText
|
|
variant="body"
|
|
color="primary"
|
|
style={styles.transcriptText}
|
|
numberOfLines={2}
|
|
>
|
|
{entry.polished_text ?? entry.original_text ?? '(empty)'}
|
|
</PhosphorText>
|
|
<View style={styles.cardMeta}>
|
|
<PhosphorText variant="label" color="muted">
|
|
{entry.stt_model?.toUpperCase() ?? 'LOCAL'}
|
|
</PhosphorText>
|
|
</View>
|
|
</MetalCard>
|
|
</Pressable>
|
|
)
|
|
}}
|
|
ListFooterComponent={<AppStatusBar />}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
// Helpers
|
|
|
|
interface GroupedItem {
|
|
id: string
|
|
type: 'header' | 'entry'
|
|
label?: string
|
|
entry: HistoryEntry
|
|
}
|
|
|
|
function isToday(dateStr: string): boolean {
|
|
const d = new Date(dateStr)
|
|
const now = new Date()
|
|
return d.toDateString() === now.toDateString()
|
|
}
|
|
|
|
function groupByDate(
|
|
entries: HistoryEntry[],
|
|
formatRelativeDate: (ts: number) => string
|
|
): GroupedItem[] {
|
|
const result: GroupedItem[] = []
|
|
let lastDate = ''
|
|
|
|
for (const entry of entries) {
|
|
const dateLabel = formatRelativeDate(new Date(entry.created_at).getTime())
|
|
if (dateLabel !== lastDate) {
|
|
result.push({
|
|
id: `header-${dateLabel}`,
|
|
type: 'header',
|
|
label: dateLabel,
|
|
entry
|
|
})
|
|
lastDate = dateLabel
|
|
}
|
|
result.push({ id: entry.id, type: 'entry', entry })
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
|
center: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 32,
|
|
backgroundColor: d3roNativePalette.bg.app
|
|
},
|
|
filters: {
|
|
flexDirection: 'row',
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 12,
|
|
gap: 12,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: d3roNativePalette.border.subtle
|
|
},
|
|
listContent: { padding: 20, paddingBottom: 120 },
|
|
emptyText: { textAlign: 'center' },
|
|
dateHeader: {
|
|
letterSpacing: 3,
|
|
marginBottom: 8,
|
|
marginTop: 16
|
|
},
|
|
card: { marginBottom: 12 },
|
|
cardOld: { opacity: 0.7 },
|
|
cardHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 8
|
|
},
|
|
cardHeaderLeft: { flexDirection: 'row', alignItems: 'center' },
|
|
timeLabel: { marginLeft: 8 },
|
|
wordBadge: {
|
|
backgroundColor: d3roNativePalette.accent.dim,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
borderRadius: 4
|
|
},
|
|
transcriptText: { marginBottom: 8 },
|
|
cardMeta: { flexDirection: 'row', gap: 12 }
|
|
})
|