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,137 +1,156 @@
// apps/mobile/app/(tabs)/dash.tsx // apps/mobile/app/(tabs)/dash.tsx
// 대시보드 탭 — 세션 통계, 사용량, 시스템 상태 // Dashboard tab — session stats, usage, system status
// Phase M-2에서 디자인(docs/v3/designs/dashboard.html) 정밀 적용 // Design ref: docs/v3/designs/dashboard.html
import { View, ScrollView, StyleSheet } from 'react-native' import { View, ScrollView, StyleSheet } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' 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 { export default function DashScreen(): React.ReactElement {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const { t } = useI18n()
return ( return (
<ScrollView <ScrollView
style={styles.container} style={styles.container}
contentContainerStyle={[styles.content, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 100 }]} contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 100 }]}
> >
{/* Header */} <Header title={t('mobile.dash.title')} showBorder={false} paddingTop={insets.top} />
<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>
{/* Session Overview */} {/* Session Overview — InsetPanel */}
<MetalCard inset style={styles.overviewCard}> <ScreenPanel style={styles.overviewPanel}>
<View style={styles.overviewHeader}> <PhosphorText variant="small" color="muted" style={styles.sectionLabel}>
<PhosphorText variant="small" color="muted"> {t('mobile.dash.sessionOverview')}
SESSION OVERVIEW </PhosphorText>
</PhosphorText>
</View>
<View style={styles.bigNumber}> <View style={styles.bigNumber}>
<PhosphorText variant="hero" color="amber"> <PhosphorText variant="hero" color="amber">0</PhosphorText>
0 <PhosphorText variant="body" color="muted" style={styles.todayLabel}>
</PhosphorText> {t('mobile.dash.today')}
<PhosphorText variant="body" color="muted" style={{ marginLeft: 12 }}>
TODAY
</PhosphorText> </PhosphorText>
</View> </View>
<PhosphorText variant="small" color="muted" style={styles.guideText}>
{t('mobile.dash.tapToRecord')}
</PhosphorText>
<View style={styles.overviewFooter}> <View style={styles.overviewFooter}>
<View> <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> <PhosphorText variant="value" color="amber">0</PhosphorText>
</View> </View>
<View style={{ alignItems: 'flex-end' }}> <View style={styles.alignEnd}>
<PhosphorText variant="label" color="muted">STREAK</PhosphorText> <PhosphorText variant="label" color="muted">{t('mobile.dash.streak')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText> <PhosphorText variant="value" color="amber">0</PhosphorText>
</View> </View>
</View> </View>
</MetalCard> </ScreenPanel>
{/* 2x2 Stats Grid */} {/* 2x2 Stats Grid */}
<View style={styles.statsGrid}> <View style={styles.statsGrid}>
<MetalCard style={styles.statCard}> <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="value" color="amber">0</PhosphorText>
<PhosphorText variant="label" color="muted">MIN</PhosphorText> <PhosphorText variant="label" color="muted">{t('mobile.dash.min')}</PhosphorText>
</MetalCard> </MetalCard>
<MetalCard style={styles.statCard}> <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> <PhosphorText variant="value" color="amber">0</PhosphorText>
</MetalCard> </MetalCard>
<MetalCard style={styles.statCard}> <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> <PhosphorText variant="value" color="amber">0</PhosphorText>
</MetalCard> </MetalCard>
<MetalCard style={styles.statCard}> <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> <PhosphorText variant="value" color="amber">0</PhosphorText>
</MetalCard> </MetalCard>
</View> </View>
{/* Backend Info */} {/* Backend Info */}
<MetalCard style={styles.infoCard}> <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}> <View style={styles.infoRow}>
<Led color="green" size={6} /> <Led color="green" size={6} />
<PhosphorText variant="body" color="amber" style={{ marginLeft: 8 }}> <PhosphorText variant="body" color="amber" style={styles.infoValue}>
CLOUD (CLAUDE) CLOUD (CLAUDE)
</PhosphorText> </PhosphorText>
</View> </View>
</MetalCard> </MetalCard>
<MetalCard style={styles.infoCard}> <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}> <View style={styles.infoRow}>
<Led color="amber" size={6} /> <Led color="amber" size={6} />
<PhosphorText variant="body" color="amber" style={{ marginLeft: 8 }}> <PhosphorText variant="body" color="amber" style={styles.infoValue}>
FREE {t('mobile.dash.free')}
</PhosphorText> </PhosphorText>
</View> </View>
</MetalCard> </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> </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({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
content: { padding: 20 }, content: { paddingHorizontal: 20 },
header: { overviewPanel: { marginBottom: 12 },
flexDirection: 'row', sectionLabel: { marginBottom: 16 },
justifyContent: 'space-between', bigNumber: { flexDirection: 'row', alignItems: 'baseline', marginBottom: 8 },
alignItems: 'center', todayLabel: { marginLeft: 12 },
marginBottom: 12 guideText: { marginBottom: 20 },
},
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
},
overviewFooter: { overviewFooter: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: 'rgba(255,255,255,0.04)', borderTopColor: d3roNativePalette.border.subtle,
paddingTop: 12 paddingTop: 12
}, },
alignEnd: { alignItems: 'flex-end' },
statsGrid: { statsGrid: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
@ -139,7 +158,7 @@ const styles = StyleSheet.create({
marginBottom: 12 marginBottom: 12
}, },
statCard: { statCard: {
width: '47%', width: '47%' as unknown as number,
alignItems: 'center', alignItems: 'center',
paddingVertical: 16, paddingVertical: 16,
gap: 4 gap: 4
@ -151,5 +170,28 @@ const styles = StyleSheet.create({
marginBottom: 12, marginBottom: 12,
paddingVertical: 14 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
}
}) })

View file

@ -1,11 +1,20 @@
// apps/mobile/app/(tabs)/history.tsx // apps/mobile/app/(tabs)/history.tsx
// 히스토리 탭 — 전사 기록 목록 // History tab — transcription records list
// Phase M-2에서 디자인(docs/v3/designs/history.html) 정밀 적용 // 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 { View, FlatList, StyleSheet, ActivityIndicator, RefreshControl, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' 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' import { supabase, isSupabaseConfigured } from '../../lib/supabase'
interface HistoryEntry { interface HistoryEntry {
@ -23,12 +32,13 @@ type FilterType = 'all' | 'favorites' | 'processing'
export default function HistoryScreen(): React.ReactElement { export default function HistoryScreen(): React.ReactElement {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const { t, formatTime, formatRelativeDate } = useI18n()
const [entries, setEntries] = useState<HistoryEntry[]>([]) const [entries, setEntries] = useState<HistoryEntry[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [filter, setFilter] = useState<FilterType>('all') const [filter, setFilter] = useState<FilterType>('all')
async function load(): Promise<void> { const load = useCallback(async (): Promise<void> => {
if (!isSupabaseConfigured()) { if (!isSupabaseConfigured()) {
setLoading(false) setLoading(false)
return return
@ -48,11 +58,14 @@ export default function HistoryScreen(): React.ReactElement {
setLoading(false) setLoading(false)
setRefreshing(false) setRefreshing(false)
} }
} }, [])
useEffect(() => { useEffect(() => {
void load() void load()
}, []) }, [load])
// Group entries by date
const grouped = groupByDate(entries, formatRelativeDate)
if (loading) { if (loading) {
return ( return (
@ -64,37 +77,33 @@ export default function HistoryScreen(): React.ReactElement {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Header */} <Header
<View style={[styles.header, { paddingTop: insets.top + 8 }]}> title={t('mobile.hist.title')}
<View style={styles.headerLeft}> rightContent={<Led color="green" size={6} />}
<Led color="amber" size={8} /> paddingTop={insets.top}
<PhosphorText variant="label" color="muted" style={{ letterSpacing: 3 }}> />
HISTORY
</PhosphorText>
</View>
<Led color="green" size={6} />
</View>
{/* Filter Chips */} {/* Filter Chips */}
<View style={styles.filters}> <View style={styles.filters}>
{(['all', 'favorites', 'processing'] as const).map((f) => ( <FilterChip
<Pressable label={t('mobile.hist.all')}
key={f} active={filter === 'all'}
style={[styles.chip, filter === f && styles.chipActive]} onPress={() => setFilter('all')}
onPress={() => setFilter(f)} />
> <FilterChip
<PhosphorText label={t('mobile.hist.saved')}
variant="small" active={filter === 'favorites'}
color={filter === f ? 'amber' : 'muted'} onPress={() => setFilter('favorites')}
> />
{f === 'all' ? 'ALL' : f === 'favorites' ? 'SAVED' : 'PROC'} <FilterChip
</PhosphorText> label={t('mobile.hist.processing')}
</Pressable> active={filter === 'processing'}
))} onPress={() => setFilter('processing')}
/>
</View> </View>
<FlatList <FlatList
data={entries} data={grouped}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
contentContainerStyle={entries.length === 0 ? styles.center : styles.listContent} contentContainerStyle={entries.length === 0 ? styles.center : styles.listContent}
refreshControl={ refreshControl={
@ -105,52 +114,104 @@ export default function HistoryScreen(): React.ReactElement {
/> />
} }
ListEmptyComponent={ ListEmptyComponent={
<PhosphorText variant="body" color="muted" style={{ textAlign: 'center' }}> <PhosphorText variant="body" color="muted" style={styles.emptyText}>
No history yet. Start recording! {t('mobile.hist.empty')}
</PhosphorText> </PhosphorText>
} }
renderItem={({ item }) => ( renderItem={({ item }) => {
<Pressable> if (item.type === 'header') {
<MetalCard style={styles.card}> return (
<View style={styles.cardHeader}> <PhosphorText variant="label" color="muted" style={styles.dateHeader}>
<View style={styles.cardHeaderLeft}> {item.label}
<Led </PhosphorText>
color={item.status === 'completed' ? 'green' : 'amber'} )
size={6} }
/> const entry = item.entry
<PhosphorText variant="label" color="primary" style={{ marginLeft: 8 }}> const isOld = !isToday(entry.created_at)
{new Date(item.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })} return (
</PhosphorText> <Pressable>
</View> <MetalCard style={[styles.card, isOld && styles.cardOld]}>
{item.word_count != null && ( <View style={styles.cardHeader}>
<View style={styles.wordBadge}> <View style={styles.cardHeaderLeft}>
<PhosphorText variant="label" color="amber"> <Led
{item.word_count} W 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> </PhosphorText>
</View> </View>
)} {entry.word_count != null && (
</View> <View style={styles.wordBadge}>
<PhosphorText <PhosphorText variant="label" color="amber">
variant="body" {entry.word_count} W
color="primary" </PhosphorText>
style={styles.transcriptText} </View>
numberOfLines={2} )}
> </View>
{item.polished_text ?? item.original_text ?? '(empty)'} <PhosphorText
</PhosphorText> variant="body"
<View style={styles.cardMeta}> color="primary"
<PhosphorText variant="label" color="muted"> style={styles.transcriptText}
{item.stt_model?.toUpperCase() ?? 'LOCAL'} numberOfLines={2}
>
{entry.polished_text ?? entry.original_text ?? '(empty)'}
</PhosphorText> </PhosphorText>
</View> <View style={styles.cardMeta}>
</MetalCard> <PhosphorText variant="label" color="muted">
</Pressable> {entry.stt_model?.toUpperCase() ?? 'LOCAL'}
)} </PhosphorText>
</View>
</MetalCard>
</Pressable>
)
}}
ListFooterComponent={<AppStatusBar />}
/> />
</View> </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({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
center: { center: {
@ -160,36 +221,23 @@ const styles = StyleSheet.create({
padding: 32, padding: 32,
backgroundColor: d3roNativePalette.bg.app backgroundColor: d3roNativePalette.bg.app
}, },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingBottom: 12
},
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
filters: { filters: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 20, paddingHorizontal: 20,
paddingBottom: 12, paddingVertical: 12,
gap: 12, gap: 12,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: 'rgba(46, 46, 50, 0.5)' borderBottomColor: d3roNativePalette.border.subtle
},
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 }, listContent: { padding: 20, paddingBottom: 120 },
emptyText: { textAlign: 'center' },
dateHeader: {
letterSpacing: 3,
marginBottom: 8,
marginTop: 16
},
card: { marginBottom: 12 }, card: { marginBottom: 12 },
cardOld: { opacity: 0.7 },
cardHeader: { cardHeader: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
@ -197,6 +245,7 @@ const styles = StyleSheet.create({
marginBottom: 8 marginBottom: 8
}, },
cardHeaderLeft: { flexDirection: 'row', alignItems: 'center' }, cardHeaderLeft: { flexDirection: 'row', alignItems: 'center' },
timeLabel: { marginLeft: 8 },
wordBadge: { wordBadge: {
backgroundColor: d3roNativePalette.accent.amberDim, backgroundColor: d3roNativePalette.accent.amberDim,
paddingHorizontal: 8, paddingHorizontal: 8,

View file

@ -1,6 +1,6 @@
// apps/mobile/app/(tabs)/record.tsx // apps/mobile/app/(tabs)/record.tsx
// 녹음 탭 — expo-av 기반 녹음 + STT 파이프라인 // Recording tab — expo-av + STT pipeline
// Phase M-2에서 디자인(docs/v3/designs/recording.html) 정밀 적용 // Design ref: docs/v3/designs/recording.html
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { View, StyleSheet, ActivityIndicator, ScrollView } from 'react-native' import { View, StyleSheet, ActivityIndicator, ScrollView } from 'react-native'
@ -12,14 +12,20 @@ import {
PhosphorText, PhosphorText,
PhysicalButton, PhysicalButton,
Led, Led,
d3roNativePalette Header,
WaveBars,
AppStatusBar,
d3roNativePalette,
d3roNativeFonts
} from '@d3ro/ui-native' } from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
import { supabase, isSupabaseConfigured } from '../../lib/supabase' import { supabase, isSupabaseConfigured } from '../../lib/supabase'
type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error' type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error'
export default function RecordScreen(): React.ReactElement { export default function RecordScreen(): React.ReactElement {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const { t } = useI18n()
const [state, setState] = useState<RecordingState>('idle') const [state, setState] = useState<RecordingState>('idle')
const [transcript, setTranscript] = useState<string>('') const [transcript, setTranscript] = useState<string>('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -35,7 +41,7 @@ export default function RecordScreen(): React.ReactElement {
try { try {
const perm = await Audio.requestPermissionsAsync() const perm = await Audio.requestPermissionsAsync()
if (perm.status !== 'granted') { if (perm.status !== 'granted') {
setError('Microphone permission denied') setError(t('mobile.rec.micDenied'))
setState('error') setState('error')
return return
} }
@ -73,19 +79,17 @@ export default function RecordScreen(): React.ReactElement {
const uri = recordingRef.current.getURI() const uri = recordingRef.current.getURI()
recordingRef.current = null recordingRef.current = null
if (!uri) { if (!uri) throw new Error('No recording URI')
throw new Error('No recording URI')
}
if (!isSupabaseConfigured()) { if (!isSupabaseConfigured()) {
setError('Supabase not configured') setError(t('mobile.rec.supabaseNotConfigured'))
setState('error') setState('error')
return return
} }
const { data: { session } } = await supabase.auth.getSession() const { data: { session } } = await supabase.auth.getSession()
if (!session) { if (!session) {
setError('Login required') setError(t('mobile.rec.loginRequired'))
setState('error') setState('error')
return return
} }
@ -105,9 +109,7 @@ export default function RecordScreen(): React.ReactElement {
body: formData body: formData
}) })
if (!response.ok) { if (!response.ok) throw new Error(`STT failed: ${response.status}`)
throw new Error(`STT failed: ${response.status}`)
}
const result = (await response.json()) as { transcript: string } const result = (await response.json()) as { transcript: string }
setTranscript(result.transcript) setTranscript(result.transcript)
@ -139,106 +141,106 @@ export default function RecordScreen(): React.ReactElement {
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` 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 ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Header */} <Header
<View style={[styles.header, { paddingTop: insets.top + 8 }]}> title={state === 'recording' ? t('mobile.rec.session') : t('mobile.rec.title')}
<View style={styles.headerLeft}> paddingTop={insets.top}
<Led color="amber" size={8} on={state === 'recording'} /> 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 <PhosphorText
variant="label" variant="small"
color={state === 'recording' ? 'amber' : 'muted'} color="muted"
style={{ letterSpacing: 3 }} style={styles.initLog}
> >
{state === 'recording' ? 'REC_SESSION' : 'RECORDER'} {t('mobile.rec.initLog')}
</PhosphorText>
</View>
{state === 'recording' && (
<PhosphorText variant="label" color="amber">
{formatTime(duration)}
</PhosphorText> </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> </View>
{/* Wave Bars Placeholder */} {/* Status Line */}
{state === 'recording' && ( {state === 'recording' && (
<View style={styles.waveContainer}> <View style={styles.listeningRow}>
{Array.from({ length: 11 }).map((_, i) => ( <Led color="amber" size={6} />
<View <PhosphorText variant="label" color="amber" style={styles.listeningText}>
key={i} {t('mobile.rec.listening')}
style={[ </PhosphorText>
styles.waveBar, <PhosphorText variant="label" color="muted">
{ height: 8 + Math.random() * 24 } {t('mobile.rec.realtime')}
]} </PhosphorText>
/>
))}
</View> </View>
)} )}
<MetalCard style={styles.card}> {/* Buttons */}
<View style={styles.statusRow}> <View style={styles.buttons}>
<Led {(state === 'idle' || state === 'done' || state === 'error') && (
color={ <PhysicalButton
state === 'recording' ? 'red' label={state === 'done' ? t('mobile.rec.newRecording') : t('mobile.rec.start')}
: state === 'processing' ? 'orange' variant="primary"
: state === 'done' ? 'green' onPress={() => void startRecording()}
: 'amber'
}
size={10}
on={state !== 'idle'}
/> />
<PhosphorText variant="label" color="label" style={styles.statusLabel}> )}
{state === 'recording' ? 'RECORDING' {state === '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') && (
<PhysicalButton <PhysicalButton
label={state === 'done' ? 'NEW RECORDING' : 'START RECORDING'} label={t('mobile.rec.stop')}
variant="primary" variant="danger"
onPress={() => void startRecording()} onPress={() => void stopRecording()}
/> />
)} <View style={styles.buttonSpacer} />
{state === 'recording' && ( <PhysicalButton
<> label={t('mobile.rec.cancel')}
<PhysicalButton label="STOP" variant="danger" onPress={() => void stopRecording()} /> variant="secondary"
<View style={{ height: 12 }} /> onPress={() => void cancelRecording()}
<PhysicalButton />
label="CANCEL" </>
variant="secondary" )}
onPress={() => void cancelRecording()} </View>
/>
</> <AppStatusBar />
)}
</View>
</MetalCard>
</ScrollView> </ScrollView>
) )
} }
@ -246,49 +248,40 @@ export default function RecordScreen(): React.ReactElement {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
content: { paddingBottom: 100 }, content: { paddingBottom: 100 },
header: { headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
flexDirection: 'row', transcriptArea: {
justifyContent: 'space-between', minHeight: 160,
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 20, paddingHorizontal: 20,
paddingBottom: 12, paddingVertical: 24
borderBottomWidth: 1,
borderBottomColor: 'rgba(46, 46, 50, 0.5)'
}, },
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 }, initLog: {
waveContainer: { fontFamily: d3roNativeFonts.mono,
height: 128, textAlign: 'center'
backgroundColor: d3roNativePalette.bg.inset,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
borderBottomWidth: 1,
borderBottomColor: 'rgba(26, 26, 28, 1)'
}, },
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: { transcriptBox: {
backgroundColor: d3roNativePalette.bg.inset, backgroundColor: d3roNativePalette.bg.inset,
padding: 12, padding: 12,
borderRadius: 8, borderRadius: 8,
width: '100%', width: '100%'
marginTop: 8
}, },
errorBox: { errorBox: {
borderWidth: 1, borderWidth: 1,
borderColor: d3roNativePalette.tag.red, borderColor: d3roNativePalette.accent.amber,
backgroundColor: d3roNativePalette.accent.amberDim,
padding: 10, padding: 10,
borderRadius: 6, borderRadius: 6,
width: '100%', width: '100%'
marginTop: 8
}, },
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 }
}) })

View file

@ -1,22 +1,31 @@
// apps/mobile/app/(tabs)/settings.tsx // apps/mobile/app/(tabs)/settings.tsx
// 설정 탭 — 계정, 백엔드, 환경설정 // Settings tab — account, backend, preferences
// Phase M-2에서 디자인(docs/v3/designs/settings.html) 정밀 적용 // Design ref: docs/v3/designs/settings.html
import { View, ScrollView, StyleSheet, Alert, Switch } from 'react-native' import { View, ScrollView, StyleSheet, Alert, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' 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 { useAuth } from '../../lib/auth-context'
import { supabase } from '../../lib/supabase' import { supabase } from '../../lib/supabase'
export default function SettingsScreen(): React.ReactElement { export default function SettingsScreen(): React.ReactElement {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const { t } = useI18n()
const { user } = useAuth() const { user } = useAuth()
async function handleLogout(): Promise<void> { async function handleLogout(): Promise<void> {
Alert.alert('Logout', 'Are you sure you want to log out?', [ Alert.alert(t('mobile.set.logout'), t('mobile.set.logoutConfirm'), [
{ text: 'Cancel', style: 'cancel' }, { text: t('mobile.rec.cancel'), style: 'cancel' },
{ {
text: 'Logout', text: t('mobile.set.logout'),
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
await supabase.auth.signOut() await supabase.auth.signOut()
@ -31,14 +40,17 @@ export default function SettingsScreen(): React.ReactElement {
return ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Header */} <PhosphorText
<PhosphorText variant="title" color="primary" style={[styles.title, { paddingTop: insets.top + 8 }]}> variant="title"
Settings color="primary"
style={[styles.title, { paddingTop: insets.top + 8 }]}
>
{t('mobile.set.title')}
</PhosphorText> </PhosphorText>
{/* Account Section */} {/* Account Section */}
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}> <PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
ACCOUNT & PLAN {t('mobile.set.accountPlan')}
</PhosphorText> </PhosphorText>
<MetalCard style={styles.section}> <MetalCard style={styles.section}>
<View style={styles.profileRow}> <View style={styles.profileRow}>
@ -50,16 +62,16 @@ export default function SettingsScreen(): React.ReactElement {
{user?.email?.split('@')[0] ?? 'User'} {user?.email?.split('@')[0] ?? 'User'}
</PhosphorText> </PhosphorText>
<PhosphorText variant="label" color="muted"> <PhosphorText variant="label" color="muted">
{user?.email ?? ''} {user?.email ?? '\u2014'}
</PhosphorText> </PhosphorText>
</View> </View>
</View> </View>
<View style={[styles.row, { backgroundColor: d3roNativePalette.bg.inset }]}> <View style={[styles.row, styles.rowInset]}>
<PhosphorText variant="small" color="muted">TIER</PhosphorText> <PhosphorText variant="small" color="muted">{t('mobile.dash.tier')}</PhosphorText>
<View style={styles.rowRight}> <View style={styles.rowRight}>
<Led color="amber" size={6} /> <Led color="amber" size={6} />
<PhosphorText variant="body" color="amber" style={{ marginLeft: 8 }}> <PhosphorText variant="body" color="amber" style={styles.rowValue}>
FREE {t('mobile.dash.free')}
</PhosphorText> </PhosphorText>
</View> </View>
</View> </View>
@ -67,20 +79,20 @@ export default function SettingsScreen(): React.ReactElement {
{/* Backend Section */} {/* Backend Section */}
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}> <PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
BACKEND CONFIG {t('mobile.set.backendConfig')}
</PhosphorText> </PhosphorText>
<MetalCard style={styles.section}> <MetalCard style={styles.section}>
<View style={styles.row}> <View style={styles.row}>
<View> <View>
<PhosphorText variant="body" color="primary">LLM Model</PhosphorText> <PhosphorText variant="body" color="primary">{t('mobile.set.llmModel')}</PhosphorText>
<PhosphorText variant="label" color="muted">AI processing backend</PhosphorText> <PhosphorText variant="label" color="muted">{t('mobile.set.llmDesc')}</PhosphorText>
</View> </View>
<PhosphorText variant="body" color="amber">CLAUDE</PhosphorText> <PhosphorText variant="body" color="amber">CLAUDE</PhosphorText>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View> <View>
<PhosphorText variant="body" color="primary">Cloud STT</PhosphorText> <PhosphorText variant="body" color="primary">{t('mobile.set.cloudStt')}</PhosphorText>
<PhosphorText variant="label" color="muted">Google Cloud Speech</PhosphorText> <PhosphorText variant="label" color="muted">{t('mobile.set.cloudSttDesc')}</PhosphorText>
</View> </View>
<Switch <Switch
value={true} value={true}
@ -95,15 +107,15 @@ export default function SettingsScreen(): React.ReactElement {
{/* Preferences Section */} {/* Preferences Section */}
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}> <PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
PREFERENCES {t('mobile.set.preferences')}
</PhosphorText> </PhosphorText>
<MetalCard style={styles.section}> <MetalCard style={styles.section}>
<View style={styles.row}> <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> <PhosphorText variant="body" color="muted">Korean</PhosphorText>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <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 <Switch
value={true} value={true}
trackColor={{ trackColor={{
@ -114,7 +126,7 @@ export default function SettingsScreen(): React.ReactElement {
/> />
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <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 <Switch
value={true} value={true}
trackColor={{ trackColor={{
@ -128,16 +140,15 @@ export default function SettingsScreen(): React.ReactElement {
{/* Logout */} {/* Logout */}
<View style={styles.logoutWrap}> <View style={styles.logoutWrap}>
<PhysicalButton label="LOGOUT" variant="secondary" onPress={() => void handleLogout()} /> <PhysicalButton
label={t('mobile.set.logout')}
variant="secondary"
onPress={() => void handleLogout()}
/>
</View> </View>
{/* Status Footer */} {/* Status Footer */}
<View style={styles.footer}> <AppStatusBar />
<Led color="green" size={6} />
<PhosphorText variant="label" color="muted" style={{ marginLeft: 6, letterSpacing: 1 }}>
CLOUD CONNECTED
</PhosphorText>
</View>
</ScrollView> </ScrollView>
) )
} }
@ -149,8 +160,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 20, paddingHorizontal: 20,
paddingBottom: 12, paddingBottom: 12,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: 'rgba(46, 46, 50, 0.5)', borderBottomColor: d3roNativePalette.border.subtle
fontFamily: undefined
}, },
sectionLabel: { sectionLabel: {
paddingHorizontal: 24, paddingHorizontal: 24,
@ -169,7 +179,7 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
gap: 12, gap: 12,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: 'rgba(46, 46, 50, 0.5)' borderBottomColor: d3roNativePalette.border.subtle
}, },
avatar: { avatar: {
width: 40, width: 40,
@ -188,17 +198,12 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
padding: 16 padding: 16
}, },
rowInset: { backgroundColor: d3roNativePalette.bg.inset },
rowBorder: { rowBorder: {
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: 'rgba(46, 46, 50, 0.5)' borderTopColor: d3roNativePalette.border.subtle
}, },
rowRight: { flexDirection: 'row', alignItems: 'center' }, rowRight: { flexDirection: 'row', alignItems: 'center' },
logoutWrap: { margin: 20 }, rowValue: { marginLeft: 8 },
footer: { logoutWrap: { margin: 20 }
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
opacity: 0.5,
marginTop: 16
}
}) })

View file

@ -1,12 +1,27 @@
// apps/mobile/app/(tabs)/talk.tsx // apps/mobile/app/(tabs)/talk.tsx
// AI 대화 탭 — 텍스트+음성 채팅 // AI Chat tab — text+voice chat
// Phase M-2에서 디자인(docs/v3/designs/talk.html) 정밀 적용 // Design ref: docs/v3/designs/talk.html
// Phase M-5에서 음성 파이프라인 구현
import { useState } from 'react' import { useState, useRef } from 'react'
import { View, ScrollView, TextInput, StyleSheet, Pressable, KeyboardAvoidingView, Platform } from 'react-native' import {
View,
ScrollView,
TextInput,
StyleSheet,
Pressable,
KeyboardAvoidingView,
Platform
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' 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 { interface ChatMessage {
id: string id: string
@ -16,11 +31,13 @@ interface ChatMessage {
export default function TalkScreen(): React.ReactElement { export default function TalkScreen(): React.ReactElement {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const { t } = useI18n()
const scrollRef = useRef<ScrollView>(null)
const [messages, setMessages] = useState<ChatMessage[]>([ const [messages, setMessages] = useState<ChatMessage[]>([
{ {
id: '1', id: '1',
role: 'assistant', role: 'assistant',
content: 'Hello! How can I help you today?' content: t('mobile.talk.greeting')
} }
]) ])
const [input, setInput] = useState('') const [input, setInput] = useState('')
@ -34,7 +51,10 @@ export default function TalkScreen(): React.ReactElement {
} }
setMessages((prev) => [...prev, userMsg]) setMessages((prev) => [...prev, userMsg])
setInput('') setInput('')
// Phase M-5: 실제 AI 응답 구현 // Phase M-5: actual AI response
setTimeout(() => {
scrollRef.current?.scrollToEnd({ animated: true })
}, 100)
} }
return ( return (
@ -43,22 +63,33 @@ export default function TalkScreen(): React.ReactElement {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={80} keyboardVerticalOffset={80}
> >
{/* Header */} <Header
<View style={[styles.header, { paddingTop: insets.top + 8 }]}> title={t('mobile.talk.title')}
<View style={styles.headerLeft}> paddingTop={insets.top}
<Led color="amber" size={8} /> rightContent={
<PhosphorText variant="label" color="muted" style={{ letterSpacing: 3 }}> <View style={styles.headerRight}>
TALK <PhosphorText variant="label" color="amber">{t('mobile.talk.live')}</PhosphorText>
</PhosphorText> <Led color="green" size={6} />
</View> </View>
<View style={styles.headerRight}> }
<PhosphorText variant="label" color="amber">LIVE</PhosphorText> />
<Led color="green" size={6} />
</View>
</View>
{/* Chat Messages */} {/* 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) => ( {messages.map((msg) => (
<View <View
key={msg.id} key={msg.id}
@ -70,12 +101,12 @@ export default function TalkScreen(): React.ReactElement {
<PhosphorText <PhosphorText
variant="body" variant="body"
color={msg.role === 'user' ? 'amber' : 'primary'} color={msg.role === 'user' ? 'amber' : 'primary'}
style={{ fontFamily: undefined }} style={styles.bubbleText}
> >
{msg.content} {msg.content}
</PhosphorText> </PhosphorText>
<PhosphorText variant="label" color="muted" style={styles.bubbleLabel}> <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> </PhosphorText>
</View> </View>
))} ))}
@ -86,7 +117,7 @@ export default function TalkScreen(): React.ReactElement {
<View style={styles.inputRow}> <View style={styles.inputRow}>
<TextInput <TextInput
style={styles.textInput} style={styles.textInput}
placeholder="Type or speak..." placeholder={t('mobile.talk.placeholder')}
placeholderTextColor={d3roNativePalette.text.muted} placeholderTextColor={d3roNativePalette.text.muted}
value={input} value={input}
onChangeText={setInput} onChangeText={setInput}
@ -97,6 +128,7 @@ export default function TalkScreen(): React.ReactElement {
<View style={styles.sendArrow} /> <View style={styles.sendArrow} />
</Pressable> </Pressable>
</View> </View>
<AppStatusBar style={styles.inputStatus} />
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
) )
@ -104,25 +136,24 @@ export default function TalkScreen(): React.ReactElement {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, 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 }, headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
chatArea: { flex: 1 }, chatArea: { flex: 1 },
chatContent: { padding: 20, paddingBottom: 20, gap: 16 }, 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: { bubble: {
maxWidth: '85%', maxWidth: '85%',
padding: 16, padding: 16,
borderRadius: 16, borderRadius: 16,
position: 'relative', marginBottom: 20
marginBottom: 12
}, },
aiBubble: { aiBubble: {
alignSelf: 'flex-start', alignSelf: 'flex-start',
@ -138,17 +169,20 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255, 92, 53, 0.3)', borderColor: 'rgba(255, 92, 53, 0.3)',
borderTopRightRadius: 4 borderTopRightRadius: 4
}, },
bubbleText: { fontFamily: d3roNativeFonts.sans },
bubbleLabel: { bubbleLabel: {
position: 'absolute', position: 'absolute',
bottom: -16, bottom: -16,
fontSize: 9 fontSize: 9,
letterSpacing: 1
}, },
inputArea: { inputArea: {
backgroundColor: 'rgba(25, 25, 27, 0.95)', backgroundColor: 'rgba(25, 25, 27, 0.95)',
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: d3roNativePalette.border.default, borderTopColor: d3roNativePalette.border.default,
padding: 12, paddingHorizontal: 12,
paddingBottom: 24 paddingTop: 12,
paddingBottom: 8
}, },
inputRow: { inputRow: {
flexDirection: 'row', flexDirection: 'row',
@ -163,7 +197,8 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
color: d3roNativePalette.text.primary, color: d3roNativePalette.text.primary,
fontSize: 14, fontSize: 14,
paddingHorizontal: 8 paddingHorizontal: 8,
fontFamily: d3roNativeFonts.sans
}, },
sendBtn: { sendBtn: {
width: 32, width: 32,
@ -184,5 +219,6 @@ const styles = StyleSheet.create({
borderLeftColor: 'transparent', borderLeftColor: 'transparent',
borderRightColor: 'transparent', borderRightColor: 'transparent',
borderBottomColor: d3roNativePalette.accent.amber borderBottomColor: d3roNativePalette.accent.amber
} },
inputStatus: { paddingVertical: 4 }
}) })

View file

@ -1,30 +1,34 @@
// apps/mobile/app/_layout.tsx // apps/mobile/app/_layout.tsx
// 루트 레이아웃 — Stack + AuthProvider // Root layout — Stack + AuthProvider + I18nProvider
import { Stack } from 'expo-router' import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar' import { StatusBar } from 'expo-status-bar'
import { GestureHandlerRootView } from 'react-native-gesture-handler' import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context' import { SafeAreaProvider } from 'react-native-safe-area-context'
import { AuthProvider } from '../lib/auth-context' import { AuthProvider } from '../lib/auth-context'
import { I18nProvider } from '@d3ro/i18n'
import { d3roNativePalette } from '@d3ro/ui-native'
export default function RootLayout(): React.ReactElement { export default function RootLayout(): React.ReactElement {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider> <SafeAreaProvider>
<AuthProvider> <I18nProvider initialLocale="ko">
<StatusBar style="light" /> <AuthProvider>
<Stack <StatusBar style="light" />
screenOptions={{ <Stack
headerStyle: { backgroundColor: '#19191b' }, screenOptions={{
headerTintColor: '#f25b29', headerStyle: { backgroundColor: d3roNativePalette.bg.app },
contentStyle: { backgroundColor: '#19191b' } headerTintColor: d3roNativePalette.accent.amber,
}} contentStyle: { backgroundColor: d3roNativePalette.bg.app }
> }}
<Stack.Screen name="index" options={{ headerShown: false }} /> >
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} />
</Stack> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</AuthProvider> </Stack>
</AuthProvider>
</I18nProvider>
</SafeAreaProvider> </SafeAreaProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
) )

View file

@ -1,11 +1,18 @@
// apps/mobile/app/login.tsx // apps/mobile/app/login.tsx
// 로그인 화면 — 디자인: docs/v3/designs/login.html // Login screen — D3RO styled
// Design ref: docs/v3/designs/login.html
import { useState } from 'react' import { useState } from 'react'
import { View, Text, TextInput, StyleSheet, Alert, ScrollView, Pressable, Platform } from 'react-native' import { View, TextInput, StyleSheet, Alert, ScrollView, Pressable, Platform } from 'react-native'
import * as WebBrowser from 'expo-web-browser' import * as WebBrowser from 'expo-web-browser'
import * as Linking from 'expo-linking' import * as Linking from 'expo-linking'
import { d3roNativePalette, d3roNativeFonts, Led } from '@d3ro/ui-native' import {
d3roNativePalette,
d3roNativeFonts,
Led,
PhosphorText
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
import { supabase, isSupabaseConfigured } from '../lib/supabase' import { supabase, isSupabaseConfigured } from '../lib/supabase'
import { useAuth } from '../lib/auth-context' import { useAuth } from '../lib/auth-context'
import { useRouter } from 'expo-router' import { useRouter } from 'expo-router'
@ -13,6 +20,7 @@ import { useRouter } from 'expo-router'
WebBrowser.maybeCompleteAuthSession() WebBrowser.maybeCompleteAuthSession()
export default function LoginScreen(): React.ReactElement { export default function LoginScreen(): React.ReactElement {
const { t } = useI18n()
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@ -27,7 +35,7 @@ export default function LoginScreen(): React.ReactElement {
async function signInWithProvider(provider: 'google' | 'github' | 'apple'): Promise<void> { async function signInWithProvider(provider: 'google' | 'github' | 'apple'): Promise<void> {
if (!configured) { if (!configured) {
Alert.alert('Not Configured', 'Supabase environment variables are not set.') Alert.alert('Not Configured', t('mobile.login.notConfigured'))
return return
} }
setBusy(true) setBusy(true)
@ -61,7 +69,7 @@ export default function LoginScreen(): React.ReactElement {
async function signInWithEmail(): Promise<void> { async function signInWithEmail(): Promise<void> {
if (!configured) { if (!configured) {
Alert.alert('Not Configured', 'Supabase environment variables are not set.') Alert.alert('Not Configured', t('mobile.login.notConfigured'))
return return
} }
if (!email.trim() || !password.trim()) return if (!email.trim() || !password.trim()) return
@ -76,8 +84,6 @@ export default function LoginScreen(): React.ReactElement {
} }
} }
const monoFont = d3roNativeFonts.mono
return ( return (
<ScrollView <ScrollView
style={styles.container} style={styles.container}
@ -90,10 +96,12 @@ export default function LoginScreen(): React.ReactElement {
<Led color="amber" size={10} /> <Led color="amber" size={10} />
<Led color="green" size={10} /> <Led color="green" size={10} />
</View> </View>
<Text style={[styles.title, { fontFamily: monoFont }]}>D3RO-VOICE</Text> <PhosphorText variant="title" color="primary" style={styles.titleText}>
<Text style={[styles.subtitle, { fontFamily: monoFont }]}> {t('mobile.login.title')}
PRECISION VOICE INTELLIGENCE </PhosphorText>
</Text> <PhosphorText variant="label" color="muted" style={styles.subtitleText}>
{t('mobile.login.subtitle')}
</PhosphorText>
</View> </View>
{/* OAuth Buttons */} {/* OAuth Buttons */}
@ -103,8 +111,10 @@ export default function LoginScreen(): React.ReactElement {
onPress={() => void signInWithProvider('google')} onPress={() => void signInWithProvider('google')}
disabled={busy} disabled={busy}
> >
<Text style={styles.oauthIcon}>G</Text> <PhosphorText variant="body" color="primary">G</PhosphorText>
<Text style={styles.oauthLabel}>Google로 </Text> <PhosphorText variant="body" color="primary" style={styles.oauthLabel}>
{t('mobile.login.google')}
</PhosphorText>
</Pressable> </Pressable>
<Pressable <Pressable
@ -112,8 +122,12 @@ export default function LoginScreen(): React.ReactElement {
onPress={() => void signInWithProvider('apple')} onPress={() => void signInWithProvider('apple')}
disabled={busy} disabled={busy}
> >
<Text style={[styles.oauthIcon, { fontSize: 18 }]}></Text> <PhosphorText variant="body" color="primary" style={styles.appleIcon}>
<Text style={styles.oauthLabel}>Apple로 </Text> {'\uF8FF'}
</PhosphorText>
<PhosphorText variant="body" color="primary" style={styles.oauthLabel}>
{t('mobile.login.apple')}
</PhosphorText>
</Pressable> </Pressable>
<Pressable <Pressable
@ -121,22 +135,28 @@ export default function LoginScreen(): React.ReactElement {
onPress={() => void signInWithProvider('github')} onPress={() => void signInWithProvider('github')}
disabled={busy} disabled={busy}
> >
<Text style={styles.oauthIcon}></Text> <PhosphorText variant="body" color="primary">{'\u2318'}</PhosphorText>
<Text style={styles.oauthLabel}>GitHub로 </Text> <PhosphorText variant="body" color="primary" style={styles.oauthLabel}>
{t('mobile.login.github')}
</PhosphorText>
</Pressable> </Pressable>
</View> </View>
{/* Divider */} {/* Divider */}
<View style={styles.divider}> <View style={styles.divider}>
<View style={styles.dividerLine} /> <View style={styles.dividerLine} />
<Text style={[styles.dividerText, { fontFamily: monoFont }]}></Text> <PhosphorText variant="label" color="muted" style={styles.dividerText}>
{t('mobile.login.or')}
</PhosphorText>
<View style={styles.dividerLine} /> <View style={styles.dividerLine} />
</View> </View>
{/* Email/Password Fields */} {/* Email/Password Fields */}
<View style={styles.formSection}> <View style={styles.formSection}>
<View style={styles.fieldGroup}> <View>
<Text style={[styles.fieldLabel, { fontFamily: monoFont }]}>EMAIL</Text> <PhosphorText variant="label" color="muted" style={styles.fieldLabel}>
{t('mobile.login.email')}
</PhosphorText>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="user@studio.com" placeholder="user@studio.com"
@ -148,11 +168,13 @@ export default function LoginScreen(): React.ReactElement {
autoCorrect={false} autoCorrect={false}
/> />
</View> </View>
<View style={styles.fieldGroup}> <View>
<Text style={[styles.fieldLabel, { fontFamily: monoFont }]}>PASSWORD</Text> <PhosphorText variant="label" color="muted" style={styles.fieldLabel}>
{t('mobile.login.password')}
</PhosphorText>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="••••••••" placeholder={'\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
placeholderTextColor="rgba(113, 113, 122, 0.3)" placeholderTextColor="rgba(113, 113, 122, 0.3)"
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
@ -167,32 +189,38 @@ export default function LoginScreen(): React.ReactElement {
onPress={() => void signInWithEmail()} onPress={() => void signInWithEmail()}
disabled={busy} disabled={busy}
> >
<Text style={styles.loginBtnText}></Text> <PhosphorText variant="heading" style={styles.loginBtnText}>
{t('mobile.login.signIn')}
</PhosphorText>
</Pressable> </Pressable>
{/* Sign Up Link */} {/* Sign Up Link */}
<View style={styles.signupRow}> <View style={styles.signupRow}>
<Text style={styles.signupText}> ?</Text> <PhosphorText variant="small" color="muted">
{t('mobile.login.noAccount')}
</PhosphorText>
<Pressable> <Pressable>
<Text style={styles.signupLink}></Text> <PhosphorText variant="small" color="amber">
{t('mobile.login.signUp')}
</PhosphorText>
</Pressable> </Pressable>
</View> </View>
{/* DEV Skip (개발 전용) */} {/* DEV Skip */}
{__DEV__ && ( {__DEV__ && (
<Pressable style={styles.devSkipBtn} onPress={handleDevSkip}> <Pressable style={styles.devSkipBtn} onPress={handleDevSkip}>
<Text style={[styles.devSkipText, { fontFamily: monoFont }]}> <PhosphorText variant="label" style={styles.devSkipText}>
DEV SKIP LOGIN {'\u26A1'} DEV SKIP LOGIN
</Text> </PhosphorText>
</Pressable> </Pressable>
)} )}
{/* Supabase Warning */} {/* Supabase Warning */}
{!configured && ( {!configured && (
<View style={styles.warningBox}> <View style={styles.warningBox}>
<Text style={[styles.warningText, { fontFamily: monoFont }]}> <PhosphorText variant="label" color="amber" style={styles.warningText}>
Supabase not configured. Set EXPO_PUBLIC_SUPABASE_URL in .env {'\u26A0'} {t('mobile.login.notConfigured')}
</Text> </PhosphorText>
</View> </View>
)} )}
</ScrollView> </ScrollView>
@ -202,10 +230,7 @@ export default function LoginScreen(): React.ReactElement {
const P = d3roNativePalette const P = d3roNativePalette
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { flex: 1, backgroundColor: P.bg.app },
flex: 1,
backgroundColor: P.bg.app
},
content: { content: {
flexGrow: 1, flexGrow: 1,
justifyContent: 'center', justifyContent: 'center',
@ -213,35 +238,11 @@ const styles = StyleSheet.create({
paddingBottom: 40, paddingBottom: 40,
paddingTop: 60 paddingTop: 60
}, },
branding: { alignItems: 'center', marginBottom: 40 },
// Branding leds: { flexDirection: 'row', gap: 10, marginBottom: 20 },
branding: { titleText: { letterSpacing: 5 },
alignItems: 'center', subtitleText: { marginTop: 8, letterSpacing: 2 },
marginBottom: 40 oauthSection: { gap: 12, marginBottom: 24 },
},
leds: {
flexDirection: 'row',
gap: 10,
marginBottom: 20
},
title: {
fontSize: 22,
letterSpacing: 5,
color: P.text.primary
},
subtitle: {
fontSize: 9,
letterSpacing: 2,
color: P.text.muted,
marginTop: 8,
textTransform: 'uppercase'
},
// OAuth
oauthSection: {
gap: 12,
marginBottom: 24
},
oauthBtn: { oauthBtn: {
backgroundColor: P.bg.card, backgroundColor: P.bg.card,
borderWidth: 1, borderWidth: 1,
@ -253,51 +254,14 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
gap: 12 gap: 12
}, },
oauthBtnPressed: { oauthBtnPressed: { backgroundColor: P.bg.cardHover, borderColor: 'rgba(113,113,122,0.4)' },
backgroundColor: P.bg.cardHover, oauthLabel: { fontFamily: d3roNativeFonts.sans },
borderColor: 'rgba(113, 113, 122, 0.4)' appleIcon: { fontSize: 18 },
}, divider: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 24 },
oauthIcon: { dividerLine: { flex: 1, height: 1, backgroundColor: P.border.default },
fontSize: 16, dividerText: { letterSpacing: 2 },
color: P.text.primary formSection: { gap: 16, marginBottom: 24 },
}, fieldLabel: { letterSpacing: 4, marginBottom: 6, marginLeft: 4 },
oauthLabel: {
fontSize: 14,
fontWeight: '500',
color: P.text.primary
},
// Divider
divider: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginBottom: 24
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: P.border.default
},
dividerText: {
fontSize: 10,
color: P.text.muted,
letterSpacing: 2
},
// Form
formSection: {
gap: 16,
marginBottom: 24
},
fieldGroup: {},
fieldLabel: {
fontSize: 10,
color: P.text.muted,
letterSpacing: 4,
marginBottom: 6,
marginLeft: 4
},
input: { input: {
backgroundColor: P.bg.inset, backgroundColor: P.bg.inset,
borderWidth: 1, borderWidth: 1,
@ -309,8 +273,6 @@ const styles = StyleSheet.create({
color: P.text.primary, color: P.text.primary,
fontFamily: Platform.OS === 'ios' ? 'System' : 'Roboto' fontFamily: Platform.OS === 'ios' ? 'System' : 'Roboto'
}, },
// Login Button
loginBtn: { loginBtn: {
backgroundColor: P.accent.amber, backgroundColor: P.accent.amber,
borderRadius: 12, borderRadius: 12,
@ -323,14 +285,7 @@ const styles = StyleSheet.create({
elevation: 6, elevation: 6,
marginBottom: 24 marginBottom: 24
}, },
loginBtnText: { loginBtnText: { color: P.bg.app, letterSpacing: 1 },
fontSize: 14,
fontWeight: '600',
color: P.bg.app,
letterSpacing: 1
},
// Sign Up
signupRow: { signupRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
@ -338,32 +293,15 @@ const styles = StyleSheet.create({
gap: 6, gap: 6,
marginBottom: 16 marginBottom: 16
}, },
signupText: {
fontSize: 11,
color: P.text.muted
},
signupLink: {
fontSize: 11,
color: P.accent.amber,
fontWeight: '500'
},
// Warning
warningBox: { warningBox: {
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 92, 53, 0.3)', borderColor: 'rgba(255,92,53,0.3)',
backgroundColor: P.accent.amberDim, backgroundColor: P.accent.amberDim,
padding: 12, padding: 12,
borderRadius: 8, borderRadius: 8,
marginTop: 8 marginTop: 8
}, },
warningText: { warningText: { textAlign: 'center' },
fontSize: 10,
color: P.accent.amber,
textAlign: 'center'
},
// DEV Skip
devSkipBtn: { devSkipBtn: {
borderWidth: 1, borderWidth: 1,
borderColor: P.accent.green, borderColor: P.accent.green,
@ -373,9 +311,5 @@ const styles = StyleSheet.create({
marginBottom: 12, marginBottom: 12,
backgroundColor: 'rgba(74, 222, 128, 0.08)' backgroundColor: 'rgba(74, 222, 128, 0.08)'
}, },
devSkipText: { devSkipText: { color: P.accent.green, letterSpacing: 2 }
fontSize: 10,
color: P.accent.green,
letterSpacing: 2
}
}) })

View file

@ -1,8 +1,38 @@
# D3RO-VOICE 프로젝트 현황 # D3RO-VOICE 프로젝트 현황
> 마지막 갱신: 2026-04-13 (Admin 콘솔 리디자인 + 랜딩 SaaS 전환) > 마지막 갱신: 2026-04-13 (Phase M-2 D3RO 디자인 시스템 모바일 적용)
> 규칙 13: 작업 완료 즉시 이 파일 갱신 의무 > 규칙 13: 작업 완료 즉시 이 파일 갱신 의무
## Phase M-2: D3RO 디자인 시스템 모바일 적용 (2026-04-13) ✅
### DS 컴포넌트 (packages/ui-native)
- 기존 4개 + 신규 5개 = **총 9개**: MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, WaveBars, AppStatusBar, Header, FilterChip
- 테마 토큰 SSOT: d3roNativePalette / d3roNativeTypo / d3roNativeRadius / d3roNativeFonts
- WaveBars: core Animated API 사용 (reanimated 의존성 제거)
### 탭 화면 D3RO 디자인 적용 (5개 + 로그인)
- dash.tsx: Session Overview (ScreenPanel), 2x2 Stats Grid, Backend/Tier Info, Usage Panel
- history.tsx: FilterChip (ALL/SAVED/PROC), 날짜 그룹핑, LED 상태, 단어수 배지
- record.tsx: WaveBars 애니메이션, 녹음 상태머신, 실시간 전사 UI
- talk.tsx: 채팅 버블 (AI/User), 날짜 배지, 입력 영역
- settings.tsx: 섹션별 카드 (Account, Backend, Preferences)
- login.tsx: LED 로고, OAuth 3종, 이메일/비번, D3RO 스타일
### i18n 연결
- I18nProvider를 root layout에 연결
- 모바일 전용 키 80+개 추가 (ko.json, en.json)
- 모든 UI 문자열 t() 함수 사용
### 인프라
- apps/mobile을 모노레포 workspaces에 추가
- saasMode 분기 제거 커밋 완료
### 알려진 이슈
- @types/react 버전 충돌 (desktop=19, mobile=18) → TS2786만 발생, Metro에 무영향
- react-native-screens bob build 실패 → --ignore-scripts로 해결, EAS Build에서는 정상
---
## Admin 콘솔 리디자인 + 랜딩 SaaS 전환 (2026-04-13) ✅ ## Admin 콘솔 리디자인 + 랜딩 SaaS 전환 (2026-04-13) ✅
### Admin CRM 콘솔 스타일 리디자인 ### Admin CRM 콘솔 스타일 리디자인

9908
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"apps/desktop", "apps/desktop",
"apps/mobile",
"apps/web", "apps/web",
"apps/admin", "apps/admin",
"packages/*" "packages/*"

View file

@ -531,5 +531,87 @@
"theme.nord": "Nord", "theme.nord": "Nord",
"theme.solarized": "Solarized", "theme.solarized": "Solarized",
"theme.catppuccin": "Catppuccin", "theme.catppuccin": "Catppuccin",
"theme.dracula": "Dracula" "theme.dracula": "Dracula",
"mobile.dash.title": "D3RO-VOICE",
"mobile.dash.sessionOverview": "SESSION OVERVIEW",
"mobile.dash.today": "TODAY",
"mobile.dash.words": "WORDS",
"mobile.dash.streak": "STREAK",
"mobile.dash.rec": "REC",
"mobile.dash.min": "MIN",
"mobile.dash.backend": "BACKEND",
"mobile.dash.tier": "TIER",
"mobile.dash.free": "FREE",
"mobile.dash.tapToRecord": "Tap REC to start recording",
"mobile.dash.usage": "USAGE",
"mobile.dash.dictation": "Dictation",
"mobile.dash.llmProcess": "LLM Process",
"mobile.dash.premiumQuota": "Premium Quota",
"mobile.dash.unlimited": "Unlimited",
"mobile.hist.title": "HISTORY",
"mobile.hist.all": "ALL",
"mobile.hist.saved": "SAVED",
"mobile.hist.processing": "PROC",
"mobile.hist.empty": "No history yet. Start recording!",
"mobile.rec.title": "RECORDER",
"mobile.rec.session": "REC_SESSION",
"mobile.rec.ready": "READY",
"mobile.rec.recording": "RECORDING",
"mobile.rec.processing": "PROCESSING",
"mobile.rec.done": "DONE",
"mobile.rec.error": "ERROR",
"mobile.rec.start": "START RECORDING",
"mobile.rec.stop": "STOP",
"mobile.rec.cancel": "CANCEL",
"mobile.rec.newRecording": "NEW RECORDING",
"mobile.rec.listening": "LISTENING_",
"mobile.rec.realtime": "REAL-TIME TRANSCRIPT",
"mobile.rec.initLog": "> initializing voice recording protocol...",
"mobile.rec.micDenied": "Microphone permission denied",
"mobile.rec.supabaseNotConfigured": "Supabase not configured",
"mobile.rec.loginRequired": "Login required",
"mobile.talk.title": "TALK",
"mobile.talk.live": "LIVE",
"mobile.talk.you": "YOU",
"mobile.talk.claude": "CLAUDE",
"mobile.talk.placeholder": "Type or speak...",
"mobile.talk.greeting": "Hello! How can I help you today?",
"mobile.set.title": "Settings",
"mobile.set.accountPlan": "ACCOUNT & PLAN",
"mobile.set.backendConfig": "BACKEND CONFIG",
"mobile.set.preferences": "PREFERENCES",
"mobile.set.llmModel": "LLM Model",
"mobile.set.llmDesc": "AI processing backend",
"mobile.set.cloudStt": "Cloud STT",
"mobile.set.cloudSttDesc": "Google Cloud Speech",
"mobile.set.language": "Language",
"mobile.set.autoPolish": "Auto Polish",
"mobile.set.haptic": "Haptic Feedback",
"mobile.set.logout": "LOGOUT",
"mobile.set.logoutConfirm": "Are you sure you want to log out?",
"mobile.set.connected": "CLOUD CONNECTED",
"mobile.login.title": "D3RO-VOICE",
"mobile.login.subtitle": "PRECISION VOICE INTELLIGENCE",
"mobile.login.google": "Sign in with Google",
"mobile.login.apple": "Sign in with Apple",
"mobile.login.github": "Sign in with GitHub",
"mobile.login.or": "or",
"mobile.login.email": "EMAIL",
"mobile.login.password": "PASSWORD",
"mobile.login.signIn": "Sign In",
"mobile.login.noAccount": "Don't have an account?",
"mobile.login.signUp": "Sign Up",
"mobile.login.notConfigured": "Supabase not configured. Set EXPO_PUBLIC_SUPABASE_URL in .env",
"mobile.status.model": "CLOUD CLAUDE",
"mobile.status.link": "PRECISION DATA LINK",
"date.today": "TODAY",
"date.yesterday": "YESTERDAY"
} }

View file

@ -531,5 +531,87 @@
"theme.nord": "노드", "theme.nord": "노드",
"theme.solarized": "솔라라이즈드", "theme.solarized": "솔라라이즈드",
"theme.catppuccin": "카푸치노", "theme.catppuccin": "카푸치노",
"theme.dracula": "드라큘라" "theme.dracula": "드라큘라",
"mobile.dash.title": "D3RO-VOICE",
"mobile.dash.sessionOverview": "SESSION OVERVIEW",
"mobile.dash.today": "TODAY",
"mobile.dash.words": "WORDS",
"mobile.dash.streak": "STREAK",
"mobile.dash.rec": "REC",
"mobile.dash.min": "MIN",
"mobile.dash.backend": "BACKEND",
"mobile.dash.tier": "TIER",
"mobile.dash.free": "FREE",
"mobile.dash.tapToRecord": "REC 탭을 눌러 녹음 시작",
"mobile.dash.usage": "USAGE",
"mobile.dash.dictation": "받아쓰기",
"mobile.dash.llmProcess": "LLM 처리",
"mobile.dash.premiumQuota": "프리미엄 쿼터",
"mobile.dash.unlimited": "무제한",
"mobile.hist.title": "HISTORY",
"mobile.hist.all": "ALL",
"mobile.hist.saved": "SAVED",
"mobile.hist.processing": "PROC",
"mobile.hist.empty": "아직 기록이 없습니다. 녹음을 시작해보세요!",
"mobile.rec.title": "RECORDER",
"mobile.rec.session": "REC_SESSION",
"mobile.rec.ready": "READY",
"mobile.rec.recording": "RECORDING",
"mobile.rec.processing": "PROCESSING",
"mobile.rec.done": "DONE",
"mobile.rec.error": "ERROR",
"mobile.rec.start": "START RECORDING",
"mobile.rec.stop": "STOP",
"mobile.rec.cancel": "CANCEL",
"mobile.rec.newRecording": "NEW RECORDING",
"mobile.rec.listening": "LISTENING_",
"mobile.rec.realtime": "REAL-TIME TRANSCRIPT",
"mobile.rec.initLog": "> initializing voice recording protocol...",
"mobile.rec.micDenied": "마이크 권한이 거부되었습니다",
"mobile.rec.supabaseNotConfigured": "Supabase가 설정되지 않았습니다",
"mobile.rec.loginRequired": "로그인이 필요합니다",
"mobile.talk.title": "TALK",
"mobile.talk.live": "LIVE",
"mobile.talk.you": "YOU",
"mobile.talk.claude": "CLAUDE",
"mobile.talk.placeholder": "입력하거나 말하세요...",
"mobile.talk.greeting": "안녕하세요! 무엇을 도와드릴까요?",
"mobile.set.title": "설정",
"mobile.set.accountPlan": "ACCOUNT & PLAN",
"mobile.set.backendConfig": "BACKEND CONFIG",
"mobile.set.preferences": "PREFERENCES",
"mobile.set.llmModel": "LLM Model",
"mobile.set.llmDesc": "AI processing backend",
"mobile.set.cloudStt": "Cloud STT",
"mobile.set.cloudSttDesc": "Google Cloud Speech",
"mobile.set.language": "Language",
"mobile.set.autoPolish": "Auto Polish",
"mobile.set.haptic": "Haptic Feedback",
"mobile.set.logout": "LOGOUT",
"mobile.set.logoutConfirm": "정말 로그아웃 하시겠습니까?",
"mobile.set.connected": "CLOUD CONNECTED",
"mobile.login.title": "D3RO-VOICE",
"mobile.login.subtitle": "PRECISION VOICE INTELLIGENCE",
"mobile.login.google": "Google로 로그인",
"mobile.login.apple": "Apple로 로그인",
"mobile.login.github": "GitHub로 로그인",
"mobile.login.or": "또는",
"mobile.login.email": "EMAIL",
"mobile.login.password": "PASSWORD",
"mobile.login.signIn": "로그인",
"mobile.login.noAccount": "계정이 없으신가요?",
"mobile.login.signUp": "회원가입",
"mobile.login.notConfigured": "Supabase가 설정되지 않았습니다. EXPO_PUBLIC_SUPABASE_URL을 .env에 설정하세요.",
"mobile.status.model": "CLOUD CLAUDE",
"mobile.status.link": "PRECISION DATA LINK",
"date.today": "TODAY",
"date.yesterday": "YESTERDAY"
} }

View file

@ -2,7 +2,7 @@
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "D3RO Voice React Native용 디자인 시스템 — MetalCard/PhosphorText/Led 등 (MUI 없음)", "description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.",
"license": "MIT", "license": "MIT",
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",

View file

@ -0,0 +1,45 @@
// packages/ui-native/src/components/FilterChip.tsx
// RN FilterChip — pill-shaped toggle chip for filter bars
import { Pressable, type ViewStyle, type StyleProp } from 'react-native'
import { d3roNativePalette, d3roNativeRadius } from '../theme'
import { PhosphorText } from './PhosphorText'
export interface FilterChipProps {
label: string
active?: boolean
onPress?: () => void
style?: StyleProp<ViewStyle>
}
export function FilterChip({
label,
active = false,
onPress,
style
}: FilterChipProps): React.ReactElement {
return (
<Pressable
onPress={onPress}
style={[
{
backgroundColor: active ? d3roNativePalette.accent.amber : d3roNativePalette.bg.card,
borderWidth: 1,
borderColor: active ? d3roNativePalette.accent.amber : d3roNativePalette.border.default,
borderRadius: d3roNativeRadius.pill,
paddingHorizontal: 16,
paddingVertical: 6
},
style
]}
>
<PhosphorText
variant="small"
color={active ? 'primary' : 'muted'}
style={active ? { color: d3roNativePalette.bg.app } : undefined}
>
{label}
</PhosphorText>
</Pressable>
)
}

View file

@ -0,0 +1,55 @@
// packages/ui-native/src/components/Header.tsx
// RN Header — D3RO styled screen header with LEDs
// safe-area insets handled by caller (paddingTop prop or wrapper)
import { View, type ViewStyle, type StyleProp } from 'react-native'
import { Led } from './Led'
import { PhosphorText } from './PhosphorText'
import { d3roNativePalette } from '../theme'
export interface HeaderProps {
title: string
showBorder?: boolean
rightContent?: React.ReactNode
paddingTop?: number
style?: StyleProp<ViewStyle>
}
export function Header({
title,
showBorder = true,
rightContent,
paddingTop = 0,
style
}: HeaderProps): React.ReactElement {
return (
<View
style={[
{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: paddingTop + 8,
paddingBottom: 12,
borderBottomWidth: showBorder ? 1 : 0,
borderBottomColor: d3roNativePalette.border.subtle
},
style
]}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Led color="amber" size={8} />
<PhosphorText variant="label" color="muted" style={{ letterSpacing: 3 }}>
{title}
</PhosphorText>
</View>
{rightContent ?? (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Led color="green" size={6} />
<Led color="amber" size={6} />
</View>
)}
</View>
)
}

View file

@ -0,0 +1,24 @@
// packages/ui-native/src/components/ScreenPanel.tsx
// RN ScreenPanel — inset bg + dot grid background
import { View, type ViewProps, type ViewStyle, type StyleProp } from 'react-native'
import { d3roNativePalette, d3roNativeRadius } from '../theme'
export interface ScreenPanelProps extends ViewProps {
style?: StyleProp<ViewStyle>
}
export function ScreenPanel({ children, style, ...rest }: ScreenPanelProps): React.ReactElement {
const baseStyle: ViewStyle = {
backgroundColor: d3roNativePalette.bg.inset,
borderRadius: d3roNativeRadius.inner,
padding: 20,
overflow: 'hidden'
}
return (
<View style={[baseStyle, style]} {...rest}>
{children}
</View>
)
}

View file

@ -0,0 +1,50 @@
// packages/ui-native/src/components/StatusBar.tsx
// RN StatusBar — bottom status strip with LED + model label
import { View, type ViewStyle, type StyleProp } from 'react-native'
import { d3roNativePalette, d3roNativeFonts } from '../theme'
import { Led } from './Led'
import { PhosphorText } from './PhosphorText'
export interface AppStatusBarProps {
model?: string
label?: string
style?: StyleProp<ViewStyle>
}
export function AppStatusBar({
model = 'CLOUD CLAUDE',
label = 'PRECISION DATA LINK',
style
}: AppStatusBarProps): React.ReactElement {
const containerStyle: ViewStyle = {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
paddingHorizontal: 16,
gap: 8,
opacity: 0.5
}
return (
<View style={[containerStyle, style]}>
<Led color="green" size={5} />
<PhosphorText
variant="label"
color="muted"
style={{ fontFamily: d3roNativeFonts.mono, fontSize: 8, letterSpacing: 1.5 }}
>
{model}
</PhosphorText>
<View style={{ width: 1, height: 8, backgroundColor: d3roNativePalette.border.default }} />
<PhosphorText
variant="label"
color="muted"
style={{ fontFamily: d3roNativeFonts.mono, fontSize: 8, letterSpacing: 1.5 }}
>
{label}
</PhosphorText>
</View>
)
}

View file

@ -0,0 +1,87 @@
// packages/ui-native/src/components/WaveBars.tsx
// RN WaveBars — animated audio level bars using core Animated API
import { useEffect, useRef } from 'react'
import { View, Animated, Easing, type ViewStyle, type StyleProp } from 'react-native'
import { d3roNativePalette } from '../theme'
export interface WaveBarsProps {
active?: boolean
barCount?: number
style?: StyleProp<ViewStyle>
}
const BAR_MIN = 8
const BAR_MAX = 32
const DURATIONS = [800, 1000, 900, 1200, 1100, 950, 1050, 1300, 850, 1150, 1000]
function WaveBar({ index, active }: { index: number; active: boolean }): React.ReactElement {
const height = useRef(new Animated.Value(BAR_MIN)).current
useEffect(() => {
if (active) {
const duration = DURATIONS[index % DURATIONS.length]
const animation = Animated.loop(
Animated.sequence([
Animated.timing(height, {
toValue: BAR_MAX,
duration,
easing: Easing.inOut(Easing.ease),
useNativeDriver: false,
delay: index * 60
}),
Animated.timing(height, {
toValue: BAR_MIN,
duration,
easing: Easing.inOut(Easing.ease),
useNativeDriver: false
})
])
)
animation.start()
return () => animation.stop()
} else {
Animated.timing(height, {
toValue: BAR_MIN,
duration: 300,
useNativeDriver: false
}).start()
}
}, [active, height, index])
return (
<Animated.View
style={{
width: 6,
height,
backgroundColor: d3roNativePalette.accent.amber,
borderRadius: 3
}}
/>
)
}
export function WaveBars({
active = false,
barCount = 11,
style
}: WaveBarsProps): React.ReactElement {
const containerStyle: ViewStyle = {
height: 128,
backgroundColor: d3roNativePalette.bg.inset,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
borderBottomWidth: 1,
borderBottomColor: d3roNativePalette.border.subtle
}
return (
<View style={[containerStyle, style]}>
{Array.from({ length: barCount }).map((_, i) => (
<WaveBar key={i} index={i} active={active} />
))}
</View>
)
}

View file

@ -1,5 +1,5 @@
// packages/ui-native — RN DS 컴포넌트 barrel // packages/ui-native — RN DS barrel
// apps/mobile이 file: 의존성으로 참조 // apps/mobile file: dependency
export { export {
d3roNativePalette, d3roNativePalette,
@ -17,3 +17,8 @@ export {
} from './components/PhosphorText' } from './components/PhosphorText'
export { Led, type LedProps } from './components/Led' export { Led, type LedProps } from './components/Led'
export { PhysicalButton, type PhysicalButtonProps } from './components/PhysicalButton' export { PhysicalButton, type PhysicalButtonProps } from './components/PhysicalButton'
export { ScreenPanel, type ScreenPanelProps } from './components/ScreenPanel'
export { WaveBars, type WaveBarsProps } from './components/WaveBars'
export { AppStatusBar, type AppStatusBarProps } from './components/StatusBar'
export { Header, type HeaderProps } from './components/Header'
export { FilterChip, type FilterChipProps } from './components/FilterChip'