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:
yunchan8804 2026-04-13 17:32:31 +09:00
parent 541f04d02b
commit 55eb62189a
19 changed files with 10435 additions and 1073 deletions

View file

@ -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,