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,137 +1,156 @@
|
|||
// apps/mobile/app/(tabs)/dash.tsx
|
||||
// 대시보드 탭 — 세션 통계, 사용량, 시스템 상태
|
||||
// Phase M-2에서 디자인(docs/v3/designs/dashboard.html) 정밀 적용
|
||||
// Dashboard tab — session stats, usage, system status
|
||||
// Design ref: docs/v3/designs/dashboard.html
|
||||
|
||||
import { View, ScrollView, StyleSheet } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { MetalCard, PhosphorText, Led, d3roNativePalette } from '@d3ro/ui-native'
|
||||
import {
|
||||
MetalCard,
|
||||
PhosphorText,
|
||||
Led,
|
||||
ScreenPanel,
|
||||
AppStatusBar,
|
||||
Header,
|
||||
d3roNativePalette
|
||||
} from '@d3ro/ui-native'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
||||
export default function DashScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={[styles.content, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 100 }]}
|
||||
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 100 }]}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Led color="amber" size={8} />
|
||||
<PhosphorText variant="label" color="muted" style={styles.headerLabel}>
|
||||
D3RO-VOICE
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<PhosphorText variant="label" color="muted">
|
||||
v1.0.0
|
||||
</PhosphorText>
|
||||
<Led color="green" size={6} />
|
||||
<Led color="amber" size={6} />
|
||||
</View>
|
||||
</View>
|
||||
<Header title={t('mobile.dash.title')} showBorder={false} paddingTop={insets.top} />
|
||||
|
||||
{/* Session Overview */}
|
||||
<MetalCard inset style={styles.overviewCard}>
|
||||
<View style={styles.overviewHeader}>
|
||||
<PhosphorText variant="small" color="muted">
|
||||
SESSION OVERVIEW
|
||||
</PhosphorText>
|
||||
</View>
|
||||
{/* Session Overview — InsetPanel */}
|
||||
<ScreenPanel style={styles.overviewPanel}>
|
||||
<PhosphorText variant="small" color="muted" style={styles.sectionLabel}>
|
||||
{t('mobile.dash.sessionOverview')}
|
||||
</PhosphorText>
|
||||
<View style={styles.bigNumber}>
|
||||
<PhosphorText variant="hero" color="amber">
|
||||
0
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body" color="muted" style={{ marginLeft: 12 }}>
|
||||
TODAY
|
||||
<PhosphorText variant="hero" color="amber">0</PhosphorText>
|
||||
<PhosphorText variant="body" color="muted" style={styles.todayLabel}>
|
||||
{t('mobile.dash.today')}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<PhosphorText variant="small" color="muted" style={styles.guideText}>
|
||||
{t('mobile.dash.tapToRecord')}
|
||||
</PhosphorText>
|
||||
<View style={styles.overviewFooter}>
|
||||
<View>
|
||||
<PhosphorText variant="label" color="muted">WORDS</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.dash.words')}</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<PhosphorText variant="label" color="muted">STREAK</PhosphorText>
|
||||
<View style={styles.alignEnd}>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.dash.streak')}</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</View>
|
||||
</View>
|
||||
</MetalCard>
|
||||
</ScreenPanel>
|
||||
|
||||
{/* 2x2 Stats Grid */}
|
||||
<View style={styles.statsGrid}>
|
||||
<MetalCard style={styles.statCard}>
|
||||
<PhosphorText variant="label" color="muted">REC</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.dash.rec')}</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">MIN</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.dash.min')}</PhosphorText>
|
||||
</MetalCard>
|
||||
<MetalCard style={styles.statCard}>
|
||||
<PhosphorText variant="label" color="muted">WORDS</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.dash.words')}</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</MetalCard>
|
||||
<MetalCard style={styles.statCard}>
|
||||
<PhosphorText variant="label" color="muted">TODAY</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.dash.today')}</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</MetalCard>
|
||||
<MetalCard style={styles.statCard}>
|
||||
<PhosphorText variant="label" color="muted">STREAK</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.dash.streak')}</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</MetalCard>
|
||||
</View>
|
||||
|
||||
{/* Backend Info */}
|
||||
<MetalCard style={styles.infoCard}>
|
||||
<PhosphorText variant="small" color="muted">BACKEND</PhosphorText>
|
||||
<PhosphorText variant="small" color="muted">{t('mobile.dash.backend')}</PhosphorText>
|
||||
<View style={styles.infoRow}>
|
||||
<Led color="green" size={6} />
|
||||
<PhosphorText variant="body" color="amber" style={{ marginLeft: 8 }}>
|
||||
<PhosphorText variant="body" color="amber" style={styles.infoValue}>
|
||||
CLOUD (CLAUDE)
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</MetalCard>
|
||||
|
||||
<MetalCard style={styles.infoCard}>
|
||||
<PhosphorText variant="small" color="muted">TIER</PhosphorText>
|
||||
<PhosphorText variant="small" color="muted">{t('mobile.dash.tier')}</PhosphorText>
|
||||
<View style={styles.infoRow}>
|
||||
<Led color="amber" size={6} />
|
||||
<PhosphorText variant="body" color="amber" style={{ marginLeft: 8 }}>
|
||||
FREE
|
||||
<PhosphorText variant="body" color="amber" style={styles.infoValue}>
|
||||
{t('mobile.dash.free')}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</MetalCard>
|
||||
|
||||
{/* Usage */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.usageTitle}>
|
||||
{t('mobile.dash.usage')}
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.usageCard}>
|
||||
<UsageRow label={t('mobile.dash.dictation')} value={t('mobile.dash.unlimited')} />
|
||||
<View style={styles.usageDivider} />
|
||||
<UsageRow label={t('mobile.dash.llmProcess')} value={t('mobile.dash.unlimited')} />
|
||||
<View style={styles.usageDivider} />
|
||||
<UsageRow label={t('mobile.dash.premiumQuota')} value="250 / 500" progress={0.5} />
|
||||
</MetalCard>
|
||||
|
||||
<AppStatusBar />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
function UsageRow({
|
||||
label,
|
||||
value,
|
||||
progress
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
progress?: number
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<View style={styles.usageRow}>
|
||||
<PhosphorText variant="body" color="primary">{label}</PhosphorText>
|
||||
<View style={styles.usageRight}>
|
||||
<PhosphorText variant="small" color="amber">{value}</PhosphorText>
|
||||
{progress != null && (
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
||||
content: { padding: 20 },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
headerLabel: { letterSpacing: 3 },
|
||||
overviewCard: {
|
||||
padding: 20,
|
||||
marginBottom: 12
|
||||
},
|
||||
overviewHeader: { marginBottom: 16 },
|
||||
bigNumber: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: 24
|
||||
},
|
||||
content: { paddingHorizontal: 20 },
|
||||
overviewPanel: { marginBottom: 12 },
|
||||
sectionLabel: { marginBottom: 16 },
|
||||
bigNumber: { flexDirection: 'row', alignItems: 'baseline', marginBottom: 8 },
|
||||
todayLabel: { marginLeft: 12 },
|
||||
guideText: { marginBottom: 20 },
|
||||
overviewFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255,255,255,0.04)',
|
||||
borderTopColor: d3roNativePalette.border.subtle,
|
||||
paddingTop: 12
|
||||
},
|
||||
alignEnd: { alignItems: 'flex-end' },
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
|
|
@ -139,7 +158,7 @@ const styles = StyleSheet.create({
|
|||
marginBottom: 12
|
||||
},
|
||||
statCard: {
|
||||
width: '47%',
|
||||
width: '47%' as unknown as number,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
gap: 4
|
||||
|
|
@ -151,5 +170,28 @@ const styles = StyleSheet.create({
|
|||
marginBottom: 12,
|
||||
paddingVertical: 14
|
||||
},
|
||||
infoRow: { flexDirection: 'row', alignItems: 'center' }
|
||||
infoRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
infoValue: { marginLeft: 8 },
|
||||
usageTitle: { letterSpacing: 3, marginBottom: 8, marginLeft: 4 },
|
||||
usageCard: { marginBottom: 12, padding: 0, overflow: 'hidden' },
|
||||
usageRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16
|
||||
},
|
||||
usageRight: { alignItems: 'flex-end', gap: 4 },
|
||||
usageDivider: { height: 1, backgroundColor: d3roNativePalette.border.subtle },
|
||||
progressBar: {
|
||||
width: 80,
|
||||
height: 4,
|
||||
backgroundColor: d3roNativePalette.bg.inset,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%' as unknown as number,
|
||||
backgroundColor: d3roNativePalette.accent.green,
|
||||
borderRadius: 2
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// apps/mobile/app/(tabs)/record.tsx
|
||||
// 녹음 탭 — expo-av 기반 녹음 + STT 파이프라인
|
||||
// Phase M-2에서 디자인(docs/v3/designs/recording.html) 정밀 적용
|
||||
// Recording tab — expo-av + STT pipeline
|
||||
// Design ref: docs/v3/designs/recording.html
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { View, StyleSheet, ActivityIndicator, ScrollView } from 'react-native'
|
||||
|
|
@ -12,14 +12,20 @@ import {
|
|||
PhosphorText,
|
||||
PhysicalButton,
|
||||
Led,
|
||||
d3roNativePalette
|
||||
Header,
|
||||
WaveBars,
|
||||
AppStatusBar,
|
||||
d3roNativePalette,
|
||||
d3roNativeFonts
|
||||
} from '@d3ro/ui-native'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { supabase, isSupabaseConfigured } from '../../lib/supabase'
|
||||
|
||||
type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error'
|
||||
|
||||
export default function RecordScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { t } = useI18n()
|
||||
const [state, setState] = useState<RecordingState>('idle')
|
||||
const [transcript, setTranscript] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -35,7 +41,7 @@ export default function RecordScreen(): React.ReactElement {
|
|||
try {
|
||||
const perm = await Audio.requestPermissionsAsync()
|
||||
if (perm.status !== 'granted') {
|
||||
setError('Microphone permission denied')
|
||||
setError(t('mobile.rec.micDenied'))
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
|
@ -73,19 +79,17 @@ export default function RecordScreen(): React.ReactElement {
|
|||
const uri = recordingRef.current.getURI()
|
||||
recordingRef.current = null
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('No recording URI')
|
||||
}
|
||||
if (!uri) throw new Error('No recording URI')
|
||||
|
||||
if (!isSupabaseConfigured()) {
|
||||
setError('Supabase not configured')
|
||||
setError(t('mobile.rec.supabaseNotConfigured'))
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
if (!session) {
|
||||
setError('Login required')
|
||||
setError(t('mobile.rec.loginRequired'))
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
|
@ -105,9 +109,7 @@ export default function RecordScreen(): React.ReactElement {
|
|||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`STT failed: ${response.status}`)
|
||||
}
|
||||
if (!response.ok) throw new Error(`STT failed: ${response.status}`)
|
||||
|
||||
const result = (await response.json()) as { transcript: string }
|
||||
setTranscript(result.transcript)
|
||||
|
|
@ -139,106 +141,106 @@ export default function RecordScreen(): React.ReactElement {
|
|||
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const statusLabel =
|
||||
state === 'recording' ? t('mobile.rec.recording')
|
||||
: state === 'processing' ? t('mobile.rec.processing')
|
||||
: state === 'done' ? t('mobile.rec.done')
|
||||
: state === 'error' ? t('mobile.rec.error')
|
||||
: t('mobile.rec.ready')
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Led color="amber" size={8} on={state === 'recording'} />
|
||||
<Header
|
||||
title={state === 'recording' ? t('mobile.rec.session') : t('mobile.rec.title')}
|
||||
paddingTop={insets.top}
|
||||
rightContent={
|
||||
state === 'recording' ? (
|
||||
<View style={styles.headerRight}>
|
||||
<PhosphorText variant="label" color="amber">
|
||||
{formatTime(duration)}
|
||||
</PhosphorText>
|
||||
<Led color="green" size={6} />
|
||||
</View>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Wave Bars */}
|
||||
<WaveBars active={state === 'recording'} />
|
||||
|
||||
{/* Transcript Area */}
|
||||
<View style={styles.transcriptArea}>
|
||||
{state === 'idle' && (
|
||||
<PhosphorText
|
||||
variant="label"
|
||||
color={state === 'recording' ? 'amber' : 'muted'}
|
||||
style={{ letterSpacing: 3 }}
|
||||
variant="small"
|
||||
color="muted"
|
||||
style={styles.initLog}
|
||||
>
|
||||
{state === 'recording' ? 'REC_SESSION' : 'RECORDER'}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
{state === 'recording' && (
|
||||
<PhosphorText variant="label" color="amber">
|
||||
{formatTime(duration)}
|
||||
{t('mobile.rec.initLog')}
|
||||
</PhosphorText>
|
||||
)}
|
||||
|
||||
{state === 'processing' && (
|
||||
<ActivityIndicator size="large" color={d3roNativePalette.accent.amber} />
|
||||
)}
|
||||
|
||||
{transcript !== '' && state === 'done' && (
|
||||
<View style={styles.transcriptBox}>
|
||||
<PhosphorText variant="body" color="primary">
|
||||
{transcript}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<View style={styles.errorBox}>
|
||||
<PhosphorText variant="small" color="label">
|
||||
{error}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Wave Bars Placeholder */}
|
||||
{/* Status Line */}
|
||||
{state === 'recording' && (
|
||||
<View style={styles.waveContainer}>
|
||||
{Array.from({ length: 11 }).map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
styles.waveBar,
|
||||
{ height: 8 + Math.random() * 24 }
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
<View style={styles.listeningRow}>
|
||||
<Led color="amber" size={6} />
|
||||
<PhosphorText variant="label" color="amber" style={styles.listeningText}>
|
||||
{t('mobile.rec.listening')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">
|
||||
{t('mobile.rec.realtime')}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<MetalCard style={styles.card}>
|
||||
<View style={styles.statusRow}>
|
||||
<Led
|
||||
color={
|
||||
state === 'recording' ? 'red'
|
||||
: state === 'processing' ? 'orange'
|
||||
: state === 'done' ? 'green'
|
||||
: 'amber'
|
||||
}
|
||||
size={10}
|
||||
on={state !== 'idle'}
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttons}>
|
||||
{(state === 'idle' || state === 'done' || state === 'error') && (
|
||||
<PhysicalButton
|
||||
label={state === 'done' ? t('mobile.rec.newRecording') : t('mobile.rec.start')}
|
||||
variant="primary"
|
||||
onPress={() => void startRecording()}
|
||||
/>
|
||||
<PhosphorText variant="label" color="label" style={styles.statusLabel}>
|
||||
{state === 'recording' ? 'RECORDING'
|
||||
: state === 'processing' ? 'PROCESSING'
|
||||
: state === 'done' ? 'DONE'
|
||||
: state === 'error' ? 'ERROR'
|
||||
: 'READY'}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
|
||||
<View style={styles.center}>
|
||||
{state === 'processing' && (
|
||||
<ActivityIndicator size="large" color={d3roNativePalette.accent.amber} />
|
||||
)}
|
||||
|
||||
{transcript && state === 'done' && (
|
||||
<View style={styles.transcriptBox}>
|
||||
<PhosphorText variant="body" color="primary">
|
||||
{transcript}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBox}>
|
||||
<PhosphorText variant="small" color="label">
|
||||
{error}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.buttons}>
|
||||
{(state === 'idle' || state === 'done' || state === 'error') && (
|
||||
)}
|
||||
{state === 'recording' && (
|
||||
<>
|
||||
<PhysicalButton
|
||||
label={state === 'done' ? 'NEW RECORDING' : 'START RECORDING'}
|
||||
variant="primary"
|
||||
onPress={() => void startRecording()}
|
||||
label={t('mobile.rec.stop')}
|
||||
variant="danger"
|
||||
onPress={() => void stopRecording()}
|
||||
/>
|
||||
)}
|
||||
{state === 'recording' && (
|
||||
<>
|
||||
<PhysicalButton label="STOP" variant="danger" onPress={() => void stopRecording()} />
|
||||
<View style={{ height: 12 }} />
|
||||
<PhysicalButton
|
||||
label="CANCEL"
|
||||
variant="secondary"
|
||||
onPress={() => void cancelRecording()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</MetalCard>
|
||||
<View style={styles.buttonSpacer} />
|
||||
<PhysicalButton
|
||||
label={t('mobile.rec.cancel')}
|
||||
variant="secondary"
|
||||
onPress={() => void cancelRecording()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<AppStatusBar />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
|
@ -246,49 +248,40 @@ export default function RecordScreen(): React.ReactElement {
|
|||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
||||
content: { paddingBottom: 100 },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
transcriptArea: {
|
||||
minHeight: 160,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(46, 46, 50, 0.5)'
|
||||
paddingVertical: 24
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
waveContainer: {
|
||||
height: 128,
|
||||
backgroundColor: d3roNativePalette.bg.inset,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(26, 26, 28, 1)'
|
||||
initLog: {
|
||||
fontFamily: d3roNativeFonts.mono,
|
||||
textAlign: 'center'
|
||||
},
|
||||
waveBar: {
|
||||
width: 6,
|
||||
backgroundColor: d3roNativePalette.accent.amber,
|
||||
borderRadius: 3
|
||||
},
|
||||
card: { margin: 20, padding: 24 },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
statusLabel: { marginLeft: 8 },
|
||||
center: { alignItems: 'center', minHeight: 140, justifyContent: 'center', marginVertical: 16 },
|
||||
transcriptBox: {
|
||||
backgroundColor: d3roNativePalette.bg.inset,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
width: '100%',
|
||||
marginTop: 8
|
||||
width: '100%'
|
||||
},
|
||||
errorBox: {
|
||||
borderWidth: 1,
|
||||
borderColor: d3roNativePalette.tag.red,
|
||||
borderColor: d3roNativePalette.accent.amber,
|
||||
backgroundColor: d3roNativePalette.accent.amberDim,
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
width: '100%',
|
||||
marginTop: 8
|
||||
width: '100%'
|
||||
},
|
||||
buttons: { marginTop: 16 }
|
||||
listeningRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 16
|
||||
},
|
||||
listeningText: { letterSpacing: 2 },
|
||||
buttons: { paddingHorizontal: 20, paddingBottom: 16 },
|
||||
buttonSpacer: { height: 12 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,22 +1,31 @@
|
|||
// apps/mobile/app/(tabs)/settings.tsx
|
||||
// 설정 탭 — 계정, 백엔드, 환경설정
|
||||
// Phase M-2에서 디자인(docs/v3/designs/settings.html) 정밀 적용
|
||||
// Settings tab — account, backend, preferences
|
||||
// Design ref: docs/v3/designs/settings.html
|
||||
|
||||
import { View, ScrollView, StyleSheet, Alert, Switch } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { MetalCard, PhosphorText, PhysicalButton, Led, d3roNativePalette } from '@d3ro/ui-native'
|
||||
import {
|
||||
MetalCard,
|
||||
PhosphorText,
|
||||
PhysicalButton,
|
||||
Led,
|
||||
AppStatusBar,
|
||||
d3roNativePalette
|
||||
} from '@d3ro/ui-native'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { useAuth } from '../../lib/auth-context'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
|
||||
export default function SettingsScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { t } = useI18n()
|
||||
const { user } = useAuth()
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
Alert.alert('Logout', 'Are you sure you want to log out?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
Alert.alert(t('mobile.set.logout'), t('mobile.set.logoutConfirm'), [
|
||||
{ text: t('mobile.rec.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: 'Logout',
|
||||
text: t('mobile.set.logout'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await supabase.auth.signOut()
|
||||
|
|
@ -31,14 +40,17 @@ export default function SettingsScreen(): React.ReactElement {
|
|||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Header */}
|
||||
<PhosphorText variant="title" color="primary" style={[styles.title, { paddingTop: insets.top + 8 }]}>
|
||||
Settings
|
||||
<PhosphorText
|
||||
variant="title"
|
||||
color="primary"
|
||||
style={[styles.title, { paddingTop: insets.top + 8 }]}
|
||||
>
|
||||
{t('mobile.set.title')}
|
||||
</PhosphorText>
|
||||
|
||||
{/* Account Section */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
|
||||
ACCOUNT & PLAN
|
||||
{t('mobile.set.accountPlan')}
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.section}>
|
||||
<View style={styles.profileRow}>
|
||||
|
|
@ -50,16 +62,16 @@ export default function SettingsScreen(): React.ReactElement {
|
|||
{user?.email?.split('@')[0] ?? 'User'}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">
|
||||
{user?.email ?? '—'}
|
||||
{user?.email ?? '\u2014'}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, { backgroundColor: d3roNativePalette.bg.inset }]}>
|
||||
<PhosphorText variant="small" color="muted">TIER</PhosphorText>
|
||||
<View style={[styles.row, styles.rowInset]}>
|
||||
<PhosphorText variant="small" color="muted">{t('mobile.dash.tier')}</PhosphorText>
|
||||
<View style={styles.rowRight}>
|
||||
<Led color="amber" size={6} />
|
||||
<PhosphorText variant="body" color="amber" style={{ marginLeft: 8 }}>
|
||||
FREE
|
||||
<PhosphorText variant="body" color="amber" style={styles.rowValue}>
|
||||
{t('mobile.dash.free')}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -67,20 +79,20 @@ export default function SettingsScreen(): React.ReactElement {
|
|||
|
||||
{/* Backend Section */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
|
||||
BACKEND CONFIG
|
||||
{t('mobile.set.backendConfig')}
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<View>
|
||||
<PhosphorText variant="body" color="primary">LLM Model</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">AI processing backend</PhosphorText>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.llmModel')}</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.set.llmDesc')}</PhosphorText>
|
||||
</View>
|
||||
<PhosphorText variant="body" color="amber">CLAUDE</PhosphorText>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View>
|
||||
<PhosphorText variant="body" color="primary">Cloud STT</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">Google Cloud Speech</PhosphorText>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.cloudStt')}</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.set.cloudSttDesc')}</PhosphorText>
|
||||
</View>
|
||||
<Switch
|
||||
value={true}
|
||||
|
|
@ -95,15 +107,15 @@ export default function SettingsScreen(): React.ReactElement {
|
|||
|
||||
{/* Preferences Section */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
|
||||
PREFERENCES
|
||||
{t('mobile.set.preferences')}
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<PhosphorText variant="body" color="primary">Language</PhosphorText>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.language')}</PhosphorText>
|
||||
<PhosphorText variant="body" color="muted">Korean</PhosphorText>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<PhosphorText variant="body" color="primary">Auto Polish</PhosphorText>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.autoPolish')}</PhosphorText>
|
||||
<Switch
|
||||
value={true}
|
||||
trackColor={{
|
||||
|
|
@ -114,7 +126,7 @@ export default function SettingsScreen(): React.ReactElement {
|
|||
/>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<PhosphorText variant="body" color="primary">Haptic Feedback</PhosphorText>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.haptic')}</PhosphorText>
|
||||
<Switch
|
||||
value={true}
|
||||
trackColor={{
|
||||
|
|
@ -128,16 +140,15 @@ export default function SettingsScreen(): React.ReactElement {
|
|||
|
||||
{/* Logout */}
|
||||
<View style={styles.logoutWrap}>
|
||||
<PhysicalButton label="LOGOUT" variant="secondary" onPress={() => void handleLogout()} />
|
||||
<PhysicalButton
|
||||
label={t('mobile.set.logout')}
|
||||
variant="secondary"
|
||||
onPress={() => void handleLogout()}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Status Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Led color="green" size={6} />
|
||||
<PhosphorText variant="label" color="muted" style={{ marginLeft: 6, letterSpacing: 1 }}>
|
||||
CLOUD CONNECTED
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<AppStatusBar />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
|
@ -149,8 +160,7 @@ const styles = StyleSheet.create({
|
|||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(46, 46, 50, 0.5)',
|
||||
fontFamily: undefined
|
||||
borderBottomColor: d3roNativePalette.border.subtle
|
||||
},
|
||||
sectionLabel: {
|
||||
paddingHorizontal: 24,
|
||||
|
|
@ -169,7 +179,7 @@ const styles = StyleSheet.create({
|
|||
padding: 16,
|
||||
gap: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(46, 46, 50, 0.5)'
|
||||
borderBottomColor: d3roNativePalette.border.subtle
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
|
|
@ -188,17 +198,12 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
padding: 16
|
||||
},
|
||||
rowInset: { backgroundColor: d3roNativePalette.bg.inset },
|
||||
rowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(46, 46, 50, 0.5)'
|
||||
borderTopColor: d3roNativePalette.border.subtle
|
||||
},
|
||||
rowRight: { flexDirection: 'row', alignItems: 'center' },
|
||||
logoutWrap: { margin: 20 },
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
opacity: 0.5,
|
||||
marginTop: 16
|
||||
}
|
||||
rowValue: { marginLeft: 8 },
|
||||
logoutWrap: { margin: 20 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
// apps/mobile/app/(tabs)/talk.tsx
|
||||
// AI 대화 탭 — 텍스트+음성 채팅
|
||||
// Phase M-2에서 디자인(docs/v3/designs/talk.html) 정밀 적용
|
||||
// Phase M-5에서 음성 파이프라인 구현
|
||||
// AI Chat tab — text+voice chat
|
||||
// Design ref: docs/v3/designs/talk.html
|
||||
|
||||
import { useState } from 'react'
|
||||
import { View, ScrollView, TextInput, StyleSheet, Pressable, KeyboardAvoidingView, Platform } from 'react-native'
|
||||
import { useState, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
KeyboardAvoidingView,
|
||||
Platform
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { MetalCard, PhosphorText, Led, d3roNativePalette } from '@d3ro/ui-native'
|
||||
import {
|
||||
PhosphorText,
|
||||
Led,
|
||||
Header,
|
||||
AppStatusBar,
|
||||
d3roNativePalette,
|
||||
d3roNativeFonts
|
||||
} from '@d3ro/ui-native'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
|
|
@ -16,11 +31,13 @@ interface ChatMessage {
|
|||
|
||||
export default function TalkScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { t } = useI18n()
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! How can I help you today?'
|
||||
content: t('mobile.talk.greeting')
|
||||
}
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
|
|
@ -34,7 +51,10 @@ export default function TalkScreen(): React.ReactElement {
|
|||
}
|
||||
setMessages((prev) => [...prev, userMsg])
|
||||
setInput('')
|
||||
// Phase M-5: 실제 AI 응답 구현
|
||||
// Phase M-5: actual AI response
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: true })
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -43,22 +63,33 @@ export default function TalkScreen(): React.ReactElement {
|
|||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={80}
|
||||
>
|
||||
{/* 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 }}>
|
||||
TALK
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<PhosphorText variant="label" color="amber">LIVE</PhosphorText>
|
||||
<Led color="green" size={6} />
|
||||
</View>
|
||||
</View>
|
||||
<Header
|
||||
title={t('mobile.talk.title')}
|
||||
paddingTop={insets.top}
|
||||
rightContent={
|
||||
<View style={styles.headerRight}>
|
||||
<PhosphorText variant="label" color="amber">{t('mobile.talk.live')}</PhosphorText>
|
||||
<Led color="green" size={6} />
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<ScrollView style={styles.chatArea} contentContainerStyle={styles.chatContent}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.chatArea}
|
||||
contentContainerStyle={styles.chatContent}
|
||||
>
|
||||
{/* Date Badge */}
|
||||
<View style={styles.dateBadge}>
|
||||
<PhosphorText variant="label" color="muted">
|
||||
{new Date().toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).toUpperCase()}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
|
||||
{messages.map((msg) => (
|
||||
<View
|
||||
key={msg.id}
|
||||
|
|
@ -70,12 +101,12 @@ export default function TalkScreen(): React.ReactElement {
|
|||
<PhosphorText
|
||||
variant="body"
|
||||
color={msg.role === 'user' ? 'amber' : 'primary'}
|
||||
style={{ fontFamily: undefined }}
|
||||
style={styles.bubbleText}
|
||||
>
|
||||
{msg.content}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted" style={styles.bubbleLabel}>
|
||||
{msg.role === 'user' ? 'YOU' : 'CLAUDE'}
|
||||
{msg.role === 'user' ? t('mobile.talk.you') : t('mobile.talk.claude')}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
))}
|
||||
|
|
@ -86,7 +117,7 @@ export default function TalkScreen(): React.ReactElement {
|
|||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
placeholder="Type or speak..."
|
||||
placeholder={t('mobile.talk.placeholder')}
|
||||
placeholderTextColor={d3roNativePalette.text.muted}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
|
|
@ -97,6 +128,7 @@ export default function TalkScreen(): React.ReactElement {
|
|||
<View style={styles.sendArrow} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<AppStatusBar style={styles.inputStatus} />
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
|
|
@ -104,25 +136,24 @@ export default function TalkScreen(): React.ReactElement {
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: d3roNativePalette.border.default
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
chatArea: { flex: 1 },
|
||||
chatContent: { padding: 20, paddingBottom: 20, gap: 16 },
|
||||
dateBadge: {
|
||||
alignSelf: 'center',
|
||||
backgroundColor: d3roNativePalette.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: d3roNativePalette.border.default,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 8
|
||||
},
|
||||
bubble: {
|
||||
maxWidth: '85%',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
position: 'relative',
|
||||
marginBottom: 12
|
||||
marginBottom: 20
|
||||
},
|
||||
aiBubble: {
|
||||
alignSelf: 'flex-start',
|
||||
|
|
@ -138,17 +169,20 @@ const styles = StyleSheet.create({
|
|||
borderColor: 'rgba(255, 92, 53, 0.3)',
|
||||
borderTopRightRadius: 4
|
||||
},
|
||||
bubbleText: { fontFamily: d3roNativeFonts.sans },
|
||||
bubbleLabel: {
|
||||
position: 'absolute',
|
||||
bottom: -16,
|
||||
fontSize: 9
|
||||
fontSize: 9,
|
||||
letterSpacing: 1
|
||||
},
|
||||
inputArea: {
|
||||
backgroundColor: 'rgba(25, 25, 27, 0.95)',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: d3roNativePalette.border.default,
|
||||
padding: 12,
|
||||
paddingBottom: 24
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 8
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -163,7 +197,8 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
color: d3roNativePalette.text.primary,
|
||||
fontSize: 14,
|
||||
paddingHorizontal: 8
|
||||
paddingHorizontal: 8,
|
||||
fontFamily: d3roNativeFonts.sans
|
||||
},
|
||||
sendBtn: {
|
||||
width: 32,
|
||||
|
|
@ -184,5 +219,6 @@ const styles = StyleSheet.create({
|
|||
borderLeftColor: 'transparent',
|
||||
borderRightColor: 'transparent',
|
||||
borderBottomColor: d3roNativePalette.accent.amber
|
||||
}
|
||||
},
|
||||
inputStatus: { paddingVertical: 4 }
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue