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
|
// 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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,27 +114,38 @@ 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 }) => {
|
||||||
|
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>
|
<Pressable>
|
||||||
<MetalCard style={styles.card}>
|
<MetalCard style={[styles.card, isOld && styles.cardOld]}>
|
||||||
<View style={styles.cardHeader}>
|
<View style={styles.cardHeader}>
|
||||||
<View style={styles.cardHeaderLeft}>
|
<View style={styles.cardHeaderLeft}>
|
||||||
<Led
|
<Led
|
||||||
color={item.status === 'completed' ? 'green' : 'amber'}
|
color={entry.status === 'completed' ? 'green' : 'amber'}
|
||||||
size={6}
|
size={6}
|
||||||
|
on={!isOld}
|
||||||
/>
|
/>
|
||||||
<PhosphorText variant="label" color="primary" style={{ marginLeft: 8 }}>
|
<PhosphorText variant="label" color="primary" style={styles.timeLabel}>
|
||||||
{new Date(item.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
|
{formatTime(new Date(entry.created_at).getTime())}
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
</View>
|
</View>
|
||||||
{item.word_count != null && (
|
{entry.word_count != null && (
|
||||||
<View style={styles.wordBadge}>
|
<View style={styles.wordBadge}>
|
||||||
<PhosphorText variant="label" color="amber">
|
<PhosphorText variant="label" color="amber">
|
||||||
{item.word_count} W
|
{entry.word_count} W
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -136,21 +156,62 @@ export default function HistoryScreen(): React.ReactElement {
|
||||||
style={styles.transcriptText}
|
style={styles.transcriptText}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{item.polished_text ?? item.original_text ?? '(empty)'}
|
{entry.polished_text ?? entry.original_text ?? '(empty)'}
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
<View style={styles.cardMeta}>
|
<View style={styles.cardMeta}>
|
||||||
<PhosphorText variant="label" color="muted">
|
<PhosphorText variant="label" color="muted">
|
||||||
{item.stt_model?.toUpperCase() ?? 'LOCAL'}
|
{entry.stt_model?.toUpperCase() ?? 'LOCAL'}
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
</View>
|
</View>
|
||||||
</MetalCard>
|
</MetalCard>
|
||||||
</Pressable>
|
</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,
|
||||||
|
|
|
||||||
|
|
@ -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,69 +141,50 @@ 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={
|
||||||
<PhosphorText
|
state === 'recording' ? (
|
||||||
variant="label"
|
<View style={styles.headerRight}>
|
||||||
color={state === 'recording' ? 'amber' : 'muted'}
|
|
||||||
style={{ letterSpacing: 3 }}
|
|
||||||
>
|
|
||||||
{state === 'recording' ? 'REC_SESSION' : 'RECORDER'}
|
|
||||||
</PhosphorText>
|
|
||||||
</View>
|
|
||||||
{state === 'recording' && (
|
|
||||||
<PhosphorText variant="label" color="amber">
|
<PhosphorText variant="label" color="amber">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
)}
|
<Led color="green" size={6} />
|
||||||
</View>
|
</View>
|
||||||
|
) : undefined
|
||||||
{/* Wave Bars Placeholder */}
|
|
||||||
{state === 'recording' && (
|
|
||||||
<View style={styles.waveContainer}>
|
|
||||||
{Array.from({ length: 11 }).map((_, i) => (
|
|
||||||
<View
|
|
||||||
key={i}
|
|
||||||
style={[
|
|
||||||
styles.waveBar,
|
|
||||||
{ height: 8 + Math.random() * 24 }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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'}
|
|
||||||
/>
|
/>
|
||||||
<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}>
|
{/* Wave Bars */}
|
||||||
|
<WaveBars active={state === 'recording'} />
|
||||||
|
|
||||||
|
{/* Transcript Area */}
|
||||||
|
<View style={styles.transcriptArea}>
|
||||||
|
{state === 'idle' && (
|
||||||
|
<PhosphorText
|
||||||
|
variant="small"
|
||||||
|
color="muted"
|
||||||
|
style={styles.initLog}
|
||||||
|
>
|
||||||
|
{t('mobile.rec.initLog')}
|
||||||
|
</PhosphorText>
|
||||||
|
)}
|
||||||
|
|
||||||
{state === 'processing' && (
|
{state === 'processing' && (
|
||||||
<ActivityIndicator size="large" color={d3roNativePalette.accent.amber} />
|
<ActivityIndicator size="large" color={d3roNativePalette.accent.amber} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{transcript && state === 'done' && (
|
{transcript !== '' && state === 'done' && (
|
||||||
<View style={styles.transcriptBox}>
|
<View style={styles.transcriptBox}>
|
||||||
<PhosphorText variant="body" color="primary">
|
<PhosphorText variant="body" color="primary">
|
||||||
{transcript}
|
{transcript}
|
||||||
|
|
@ -209,7 +192,7 @@ export default function RecordScreen(): React.ReactElement {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error !== null && (
|
||||||
<View style={styles.errorBox}>
|
<View style={styles.errorBox}>
|
||||||
<PhosphorText variant="small" color="label">
|
<PhosphorText variant="small" color="label">
|
||||||
{error}
|
{error}
|
||||||
|
|
@ -218,27 +201,46 @@ export default function RecordScreen(): React.ReactElement {
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Status Line */}
|
||||||
|
{state === 'recording' && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
<View style={styles.buttons}>
|
<View style={styles.buttons}>
|
||||||
{(state === 'idle' || state === 'done' || state === 'error') && (
|
{(state === 'idle' || state === 'done' || state === 'error') && (
|
||||||
<PhysicalButton
|
<PhysicalButton
|
||||||
label={state === 'done' ? 'NEW RECORDING' : 'START RECORDING'}
|
label={state === 'done' ? t('mobile.rec.newRecording') : t('mobile.rec.start')}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onPress={() => void startRecording()}
|
onPress={() => void startRecording()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{state === 'recording' && (
|
{state === 'recording' && (
|
||||||
<>
|
<>
|
||||||
<PhysicalButton label="STOP" variant="danger" onPress={() => void stopRecording()} />
|
|
||||||
<View style={{ height: 12 }} />
|
|
||||||
<PhysicalButton
|
<PhysicalButton
|
||||||
label="CANCEL"
|
label={t('mobile.rec.stop')}
|
||||||
|
variant="danger"
|
||||||
|
onPress={() => void stopRecording()}
|
||||||
|
/>
|
||||||
|
<View style={styles.buttonSpacer} />
|
||||||
|
<PhysicalButton
|
||||||
|
label={t('mobile.rec.cancel')}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => void cancelRecording()}
|
onPress={() => void cancelRecording()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</MetalCard>
|
|
||||||
|
<AppStatusBar />
|
||||||
</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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
|
||||||
TALK
|
|
||||||
</PhosphorText>
|
|
||||||
</View>
|
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
<PhosphorText variant="label" color="amber">LIVE</PhosphorText>
|
<PhosphorText variant="label" color="amber">{t('mobile.talk.live')}</PhosphorText>
|
||||||
<Led color="green" size={6} />
|
<Led color="green" size={6} />
|
||||||
</View>
|
</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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,26 @@
|
||||||
// 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>
|
||||||
|
<I18nProvider initialLocale="ko">
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: { backgroundColor: '#19191b' },
|
headerStyle: { backgroundColor: d3roNativePalette.bg.app },
|
||||||
headerTintColor: '#f25b29',
|
headerTintColor: d3roNativePalette.accent.amber,
|
||||||
contentStyle: { backgroundColor: '#19191b' }
|
contentStyle: { backgroundColor: d3roNativePalette.bg.app }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
|
|
@ -25,6 +28,7 @@ export default function RootLayout(): React.ReactElement {
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</I18nProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
9908
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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/*"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
45
packages/ui-native/src/components/FilterChip.tsx
Normal file
45
packages/ui-native/src/components/FilterChip.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
packages/ui-native/src/components/Header.tsx
Normal file
55
packages/ui-native/src/components/Header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
packages/ui-native/src/components/ScreenPanel.tsx
Normal file
24
packages/ui-native/src/components/ScreenPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
packages/ui-native/src/components/StatusBar.tsx
Normal file
50
packages/ui-native/src/components/StatusBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
packages/ui-native/src/components/WaveBars.tsx
Normal file
87
packages/ui-native/src/components/WaveBars.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue