d3ro-voice/apps/mobile/app/(tabs)/history.tsx
Yun Chan 660e622a93 refactor(ui-native): d3roNativePalette amber->main 개명 (WS-NATIVE)
- 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
2026-07-22 02:37:45 +09:00

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 }
})