feat(mobile): Phase M-2 D3RO 디자인 시스템 모바일 적용
DS 컴포넌트 9개 (ScreenPanel, WaveBars, AppStatusBar, Header, FilterChip 신규), 5개 탭 + 로그인 화면 D3RO 인스트루먼트 미학 적용, i18n 연결 (I18nProvider + 모바일 전용 키 80+개), 모노레포 workspaces에 apps/mobile 추가.
This commit is contained in:
parent
541f04d02b
commit
55eb62189a
19 changed files with 10435 additions and 1073 deletions
|
|
@ -1,11 +1,20 @@
|
|||
// apps/mobile/app/(tabs)/history.tsx
|
||||
// 히스토리 탭 — 전사 기록 목록
|
||||
// Phase M-2에서 디자인(docs/v3/designs/history.html) 정밀 적용
|
||||
// History tab — transcription records list
|
||||
// Design ref: docs/v3/designs/history.html
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
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, d3roNativePalette } from '@d3ro/ui-native'
|
||||
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 {
|
||||
|
|
@ -23,12 +32,13 @@ 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')
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
if (!isSupabaseConfigured()) {
|
||||
setLoading(false)
|
||||
return
|
||||
|
|
@ -48,11 +58,14 @@ export default function HistoryScreen(): React.ReactElement {
|
|||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
}, [load])
|
||||
|
||||
// Group entries by date
|
||||
const grouped = groupByDate(entries, formatRelativeDate)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -64,37 +77,33 @@ export default function HistoryScreen(): React.ReactElement {
|
|||
|
||||
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>
|
||||
<Header
|
||||
title={t('mobile.hist.title')}
|
||||
rightContent={<Led color="green" size={6} />}
|
||||
paddingTop={insets.top}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
<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={entries}
|
||||
data={grouped}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={entries.length === 0 ? styles.center : styles.listContent}
|
||||
refreshControl={
|
||||
|
|
@ -105,52 +114,104 @@ export default function HistoryScreen(): React.ReactElement {
|
|||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<PhosphorText variant="body" color="muted" style={{ textAlign: 'center' }}>
|
||||
No history yet. Start recording!
|
||||
<PhosphorText variant="body" color="muted" style={styles.emptyText}>
|
||||
{t('mobile.hist.empty')}
|
||||
</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
|
||||
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>
|
||||
)}
|
||||
</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'}
|
||||
{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>
|
||||
</MetalCard>
|
||||
</Pressable>
|
||||
)}
|
||||
<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: {
|
||||
|
|
@ -160,36 +221,23 @@ const styles = StyleSheet.create({
|
|||
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,
|
||||
paddingVertical: 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
|
||||
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',
|
||||
|
|
@ -197,6 +245,7 @@ const styles = StyleSheet.create({
|
|||
marginBottom: 8
|
||||
},
|
||||
cardHeaderLeft: { flexDirection: 'row', alignItems: 'center' },
|
||||
timeLabel: { marginLeft: 8 },
|
||||
wordBadge: {
|
||||
backgroundColor: d3roNativePalette.accent.amberDim,
|
||||
paddingHorizontal: 8,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue