- V3 모바일 마스터 플랜 작성 (docs/v3/00-mobile-master-plan.md) 11개 Phase, 5주 로드맵, 전략 결정사항 확정 - 7개 디자인 HTML 레퍼런스 저장 (docs/v3/designs/) - app.json → app.config.ts 전환 + 환경변수 체계 - 5탭 네비게이션 (DASH/HIST/REC FAB/TALK/SET) - metro.config.js monorepo 격리 (root RN 0.84 충돌 해결) - ui-native 테마 디자인 목업 색상 동기화 - SafeArea 적용 (useSafeAreaInsets) - DEV 로그인 우회 (__DEV__ 전용) - Supabase .env 연동 - iOS 시뮬레이터 검증 완료
208 lines
6.3 KiB
TypeScript
208 lines
6.3 KiB
TypeScript
// apps/mobile/app/(tabs)/history.tsx
|
|
// 히스토리 탭 — 전사 기록 목록
|
|
// Phase M-2에서 디자인(docs/v3/designs/history.html) 정밀 적용
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { View, FlatList, StyleSheet, ActivityIndicator, RefreshControl, Pressable } from 'react-native'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import { MetalCard, PhosphorText, Led, d3roNativePalette } from '@d3ro/ui-native'
|
|
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 [entries, setEntries] = useState<HistoryEntry[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [filter, setFilter] = useState<FilterType>('all')
|
|
|
|
async function load(): 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()
|
|
}, [])
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<ActivityIndicator size="large" color={d3roNativePalette.accent.amber} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* Header */}
|
|
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
|
<View style={styles.headerLeft}>
|
|
<Led color="amber" size={8} />
|
|
<PhosphorText variant="label" color="muted" style={{ letterSpacing: 3 }}>
|
|
HISTORY
|
|
</PhosphorText>
|
|
</View>
|
|
<Led color="green" size={6} />
|
|
</View>
|
|
|
|
{/* Filter Chips */}
|
|
<View style={styles.filters}>
|
|
{(['all', 'favorites', 'processing'] as const).map((f) => (
|
|
<Pressable
|
|
key={f}
|
|
style={[styles.chip, filter === f && styles.chipActive]}
|
|
onPress={() => setFilter(f)}
|
|
>
|
|
<PhosphorText
|
|
variant="small"
|
|
color={filter === f ? 'amber' : 'muted'}
|
|
>
|
|
{f === 'all' ? 'ALL' : f === 'favorites' ? 'SAVED' : 'PROC'}
|
|
</PhosphorText>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
|
|
<FlatList
|
|
data={entries}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={entries.length === 0 ? styles.center : styles.listContent}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={() => { setRefreshing(true); void load() }}
|
|
tintColor={d3roNativePalette.accent.amber}
|
|
/>
|
|
}
|
|
ListEmptyComponent={
|
|
<PhosphorText variant="body" color="muted" style={{ textAlign: 'center' }}>
|
|
No history yet. Start recording!
|
|
</PhosphorText>
|
|
}
|
|
renderItem={({ item }) => (
|
|
<Pressable>
|
|
<MetalCard style={styles.card}>
|
|
<View style={styles.cardHeader}>
|
|
<View style={styles.cardHeaderLeft}>
|
|
<Led
|
|
color={item.status === 'completed' ? 'green' : 'amber'}
|
|
size={6}
|
|
/>
|
|
<PhosphorText variant="label" color="primary" style={{ marginLeft: 8 }}>
|
|
{new Date(item.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
|
|
</PhosphorText>
|
|
</View>
|
|
{item.word_count != null && (
|
|
<View style={styles.wordBadge}>
|
|
<PhosphorText variant="label" color="amber">
|
|
{item.word_count} W
|
|
</PhosphorText>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<PhosphorText
|
|
variant="body"
|
|
color="primary"
|
|
style={styles.transcriptText}
|
|
numberOfLines={2}
|
|
>
|
|
{item.polished_text ?? item.original_text ?? '(empty)'}
|
|
</PhosphorText>
|
|
<View style={styles.cardMeta}>
|
|
<PhosphorText variant="label" color="muted">
|
|
{item.stt_model?.toUpperCase() ?? 'LOCAL'}
|
|
</PhosphorText>
|
|
</View>
|
|
</MetalCard>
|
|
</Pressable>
|
|
)}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
|
center: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 32,
|
|
backgroundColor: d3roNativePalette.bg.app
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 20,
|
|
paddingBottom: 12
|
|
},
|
|
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
filters: {
|
|
flexDirection: 'row',
|
|
paddingHorizontal: 20,
|
|
paddingBottom: 12,
|
|
gap: 12,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(46, 46, 50, 0.5)'
|
|
},
|
|
chip: {
|
|
backgroundColor: d3roNativePalette.bg.card,
|
|
borderWidth: 1,
|
|
borderColor: d3roNativePalette.border.default,
|
|
borderRadius: 999,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 6
|
|
},
|
|
chipActive: {
|
|
backgroundColor: d3roNativePalette.accent.amber,
|
|
borderColor: d3roNativePalette.accent.amber
|
|
},
|
|
listContent: { padding: 20, paddingBottom: 120 },
|
|
card: { marginBottom: 12 },
|
|
cardHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 8
|
|
},
|
|
cardHeaderLeft: { flexDirection: 'row', alignItems: 'center' },
|
|
wordBadge: {
|
|
backgroundColor: d3roNativePalette.accent.amberDim,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
borderRadius: 4
|
|
},
|
|
transcriptText: { marginBottom: 8 },
|
|
cardMeta: { flexDirection: 'row', gap: 12 }
|
|
})
|