feat(mobile): Phase M-1 모바일 프로젝트 기반 + V3 마스터 플랜
- V3 모바일 마스터 플랜 작성 (docs/v3/00-mobile-master-plan.md) 11개 Phase, 5주 로드맵, 전략 결정사항 확정 - 7개 디자인 HTML 레퍼런스 저장 (docs/v3/designs/) - app.json → app.config.ts 전환 + 환경변수 체계 - 5탭 네비게이션 (DASH/HIST/REC FAB/TALK/SET) - metro.config.js monorepo 격리 (root RN 0.84 충돌 해결) - ui-native 테마 디자인 목업 색상 동기화 - SafeArea 적용 (useSafeAreaInsets) - DEV 로그인 우회 (__DEV__ 전용) - Supabase .env 연동 - iOS 시뮬레이터 검증 완료
This commit is contained in:
parent
ffca07d120
commit
211673bc6c
30 changed files with 15010 additions and 525 deletions
|
|
@ -1,10 +1,26 @@
|
|||
// apps/mobile/app/(tabs)/_layout.tsx
|
||||
// 탭 네비게이션 — Meetings / Record / Profile
|
||||
// 5탭 네비게이션: DASH / HIST / REC(FAB) / TALK / SET
|
||||
|
||||
import { Tabs } from 'expo-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { View, StyleSheet, Pressable, Platform } from 'react-native'
|
||||
import { Tabs, useRouter } from 'expo-router'
|
||||
import { useAuth } from '../../lib/auth-context'
|
||||
import { d3roNativePalette } from '@d3ro/ui-native'
|
||||
|
||||
const MONO_FONT = Platform.OS === 'ios' ? 'Menlo' : 'monospace'
|
||||
|
||||
function RecordFAB({ onPress }: { onPress: () => void }): React.ReactElement {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={styles.fab}>
|
||||
<View style={styles.fabInner}>
|
||||
<View style={styles.micIcon}>
|
||||
<View style={styles.micBody} />
|
||||
<View style={styles.micBase} />
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TabsLayout(): React.ReactElement {
|
||||
const router = useRouter()
|
||||
|
|
@ -19,17 +35,169 @@ export default function TabsLayout(): React.ReactElement {
|
|||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarStyle: { backgroundColor: '#19191b', borderTopColor: '#2a2a2d' },
|
||||
tabBarActiveTintColor: '#f25b29',
|
||||
tabBarInactiveTintColor: '#8e8e93',
|
||||
headerStyle: { backgroundColor: '#19191b' },
|
||||
headerTintColor: '#f25b29',
|
||||
headerTitleStyle: { fontWeight: '300', letterSpacing: 1 }
|
||||
tabBarStyle: {
|
||||
backgroundColor: 'rgba(25, 25, 27, 0.95)',
|
||||
borderTopColor: d3roNativePalette.border.default,
|
||||
borderTopWidth: 1,
|
||||
height: 80,
|
||||
paddingBottom: 20,
|
||||
paddingTop: 8
|
||||
},
|
||||
tabBarActiveTintColor: d3roNativePalette.accent.amber,
|
||||
tabBarInactiveTintColor: d3roNativePalette.text.muted,
|
||||
tabBarLabelStyle: {
|
||||
fontFamily: MONO_FONT,
|
||||
fontSize: 9,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
headerStyle: { backgroundColor: d3roNativePalette.bg.app },
|
||||
headerTintColor: d3roNativePalette.accent.amber,
|
||||
headerTitleStyle: { fontWeight: '300', letterSpacing: 1, fontFamily: MONO_FONT },
|
||||
headerShown: false
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen name="meetings" options={{ title: 'Meetings' }} />
|
||||
<Tabs.Screen name="record" options={{ title: 'Record' }} />
|
||||
<Tabs.Screen name="profile" options={{ title: 'Profile' }} />
|
||||
<Tabs.Screen
|
||||
name="dash"
|
||||
options={{
|
||||
title: 'DASH',
|
||||
tabBarIcon: ({ color }: { color: string }) => <TabIcon type="dash" color={color} />
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'HIST',
|
||||
tabBarIcon: ({ color }: { color: string }) => <TabIcon type="hist" color={color} />
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="record"
|
||||
options={{
|
||||
title: '',
|
||||
tabBarIcon: () => <RecordFAB onPress={() => router.push('/(tabs)/record')} />,
|
||||
tabBarLabel: () => null
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="talk"
|
||||
options={{
|
||||
title: 'TALK',
|
||||
tabBarIcon: ({ color }: { color: string }) => <TabIcon type="talk" color={color} />
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'SET',
|
||||
tabBarIcon: ({ color }: { color: string }) => <TabIcon type="set" color={color} />
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
// 단순 아이콘 대용 (Phase M-2에서 SVG 아이콘으로 교체)
|
||||
function TabIcon({ type, color }: { type: string; color: string }): React.ReactElement {
|
||||
const iconStyles: Record<string, React.ReactElement> = {
|
||||
dash: (
|
||||
<View style={[styles.iconGrid, { borderColor: color }]}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<View key={i} style={[styles.iconGridCell, { borderColor: color }]} />
|
||||
))}
|
||||
</View>
|
||||
),
|
||||
hist: (
|
||||
<View style={[styles.iconCircle, { borderColor: color }]}>
|
||||
<View style={[styles.iconClockHand, { backgroundColor: color }]} />
|
||||
</View>
|
||||
),
|
||||
talk: (
|
||||
<View style={[styles.iconChat, { borderColor: color }]} />
|
||||
),
|
||||
set: (
|
||||
<View style={[styles.iconGear, { borderColor: color }]} />
|
||||
)
|
||||
}
|
||||
return iconStyles[type] ?? <View />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fab: {
|
||||
position: 'relative',
|
||||
top: -20,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: d3roNativePalette.accent.amber,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: d3roNativePalette.accent.amber,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
borderWidth: 4,
|
||||
borderColor: d3roNativePalette.bg.app
|
||||
},
|
||||
fabInner: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
micIcon: {
|
||||
alignItems: 'center'
|
||||
},
|
||||
micBody: {
|
||||
width: 8,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
backgroundColor: d3roNativePalette.bg.app,
|
||||
marginBottom: 2
|
||||
},
|
||||
micBase: {
|
||||
width: 14,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: d3roNativePalette.bg.app
|
||||
},
|
||||
// Tab icons (placeholder — Phase M-2에서 SVG로 교체)
|
||||
iconGrid: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2
|
||||
},
|
||||
iconGridCell: {
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 2
|
||||
},
|
||||
iconCircle: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1.5,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
iconClockHand: {
|
||||
width: 1.5,
|
||||
height: 7,
|
||||
position: 'absolute',
|
||||
top: 3
|
||||
},
|
||||
iconChat: {
|
||||
width: 22,
|
||||
height: 18,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 4
|
||||
},
|
||||
iconGear: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 11
|
||||
}
|
||||
})
|
||||
|
|
|
|||
155
apps/mobile/app/(tabs)/dash.tsx
Normal file
155
apps/mobile/app/(tabs)/dash.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// apps/mobile/app/(tabs)/dash.tsx
|
||||
// 대시보드 탭 — 세션 통계, 사용량, 시스템 상태
|
||||
// Phase M-2에서 디자인(docs/v3/designs/dashboard.html) 정밀 적용
|
||||
|
||||
import { View, ScrollView, StyleSheet } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { MetalCard, PhosphorText, Led, d3roNativePalette } from '@d3ro/ui-native'
|
||||
|
||||
export default function DashScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={[styles.content, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 100 }]}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Led color="amber" size={8} />
|
||||
<PhosphorText variant="label" color="muted" style={styles.headerLabel}>
|
||||
D3RO-VOICE
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<PhosphorText variant="label" color="muted">
|
||||
v1.0.0
|
||||
</PhosphorText>
|
||||
<Led color="green" size={6} />
|
||||
<Led color="amber" size={6} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Session Overview */}
|
||||
<MetalCard inset style={styles.overviewCard}>
|
||||
<View style={styles.overviewHeader}>
|
||||
<PhosphorText variant="small" color="muted">
|
||||
SESSION OVERVIEW
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<View style={styles.bigNumber}>
|
||||
<PhosphorText variant="hero" color="amber">
|
||||
0
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body" color="muted" style={{ marginLeft: 12 }}>
|
||||
TODAY
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<View style={styles.overviewFooter}>
|
||||
<View>
|
||||
<PhosphorText variant="label" color="muted">WORDS</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<PhosphorText variant="label" color="muted">STREAK</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</View>
|
||||
</View>
|
||||
</MetalCard>
|
||||
|
||||
{/* 2x2 Stats Grid */}
|
||||
<View style={styles.statsGrid}>
|
||||
<MetalCard style={styles.statCard}>
|
||||
<PhosphorText variant="label" color="muted">REC</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">MIN</PhosphorText>
|
||||
</MetalCard>
|
||||
<MetalCard style={styles.statCard}>
|
||||
<PhosphorText variant="label" color="muted">WORDS</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</MetalCard>
|
||||
<MetalCard style={styles.statCard}>
|
||||
<PhosphorText variant="label" color="muted">TODAY</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</MetalCard>
|
||||
<MetalCard style={styles.statCard}>
|
||||
<PhosphorText variant="label" color="muted">STREAK</PhosphorText>
|
||||
<PhosphorText variant="value" color="amber">0</PhosphorText>
|
||||
</MetalCard>
|
||||
</View>
|
||||
|
||||
{/* Backend Info */}
|
||||
<MetalCard style={styles.infoCard}>
|
||||
<PhosphorText variant="small" color="muted">BACKEND</PhosphorText>
|
||||
<View style={styles.infoRow}>
|
||||
<Led color="green" size={6} />
|
||||
<PhosphorText variant="body" color="amber" style={{ marginLeft: 8 }}>
|
||||
CLOUD (CLAUDE)
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</MetalCard>
|
||||
|
||||
<MetalCard style={styles.infoCard}>
|
||||
<PhosphorText variant="small" color="muted">TIER</PhosphorText>
|
||||
<View style={styles.infoRow}>
|
||||
<Led color="amber" size={6} />
|
||||
<PhosphorText variant="body" color="amber" style={{ marginLeft: 8 }}>
|
||||
FREE
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</MetalCard>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
||||
content: { padding: 20 },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
headerLabel: { letterSpacing: 3 },
|
||||
overviewCard: {
|
||||
padding: 20,
|
||||
marginBottom: 12
|
||||
},
|
||||
overviewHeader: { marginBottom: 16 },
|
||||
bigNumber: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: 24
|
||||
},
|
||||
overviewFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255,255,255,0.04)',
|
||||
paddingTop: 12
|
||||
},
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 12
|
||||
},
|
||||
statCard: {
|
||||
width: '47%',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
gap: 4
|
||||
},
|
||||
infoCard: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
paddingVertical: 14
|
||||
},
|
||||
infoRow: { flexDirection: 'row', alignItems: 'center' }
|
||||
})
|
||||
208
apps/mobile/app/(tabs)/history.tsx
Normal file
208
apps/mobile/app/(tabs)/history.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// apps/mobile/app/(tabs)/history.tsx
|
||||
// 히스토리 탭 — 전사 기록 목록
|
||||
// Phase M-2에서 디자인(docs/v3/designs/history.html) 정밀 적용
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { View, FlatList, StyleSheet, ActivityIndicator, RefreshControl, Pressable } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { MetalCard, PhosphorText, Led, d3roNativePalette } from '@d3ro/ui-native'
|
||||
import { supabase, isSupabaseConfigured } from '../../lib/supabase'
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string
|
||||
original_text: string | null
|
||||
polished_text: string | null
|
||||
mode: string
|
||||
status: string
|
||||
created_at: string
|
||||
stt_model: string | null
|
||||
word_count: number | null
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'favorites' | 'processing'
|
||||
|
||||
export default function HistoryScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [filter, setFilter] = useState<FilterType>('all')
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!isSupabaseConfigured()) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const query = supabase
|
||||
.from('history')
|
||||
.select('id, original_text, polished_text, mode, status, created_at, stt_model, word_count')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
const { data, error } = await query
|
||||
if (!error && data) {
|
||||
setEntries(data as HistoryEntry[])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color={d3roNativePalette.accent.amber} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Led color="amber" size={8} />
|
||||
<PhosphorText variant="label" color="muted" style={{ letterSpacing: 3 }}>
|
||||
HISTORY
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<Led color="green" size={6} />
|
||||
</View>
|
||||
|
||||
{/* Filter Chips */}
|
||||
<View style={styles.filters}>
|
||||
{(['all', 'favorites', 'processing'] as const).map((f) => (
|
||||
<Pressable
|
||||
key={f}
|
||||
style={[styles.chip, filter === f && styles.chipActive]}
|
||||
onPress={() => setFilter(f)}
|
||||
>
|
||||
<PhosphorText
|
||||
variant="small"
|
||||
color={filter === f ? 'amber' : 'muted'}
|
||||
>
|
||||
{f === 'all' ? 'ALL' : f === 'favorites' ? 'SAVED' : 'PROC'}
|
||||
</PhosphorText>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={entries}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={entries.length === 0 ? styles.center : styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => { setRefreshing(true); void load() }}
|
||||
tintColor={d3roNativePalette.accent.amber}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<PhosphorText variant="body" color="muted" style={{ textAlign: 'center' }}>
|
||||
No history yet. Start recording!
|
||||
</PhosphorText>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable>
|
||||
<MetalCard style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardHeaderLeft}>
|
||||
<Led
|
||||
color={item.status === 'completed' ? 'green' : 'amber'}
|
||||
size={6}
|
||||
/>
|
||||
<PhosphorText variant="label" color="primary" style={{ marginLeft: 8 }}>
|
||||
{new Date(item.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
{item.word_count != null && (
|
||||
<View style={styles.wordBadge}>
|
||||
<PhosphorText variant="label" color="amber">
|
||||
{item.word_count} W
|
||||
</PhosphorText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<PhosphorText
|
||||
variant="body"
|
||||
color="primary"
|
||||
style={styles.transcriptText}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.polished_text ?? item.original_text ?? '(empty)'}
|
||||
</PhosphorText>
|
||||
<View style={styles.cardMeta}>
|
||||
<PhosphorText variant="label" color="muted">
|
||||
{item.stt_model?.toUpperCase() ?? 'LOCAL'}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</MetalCard>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
||||
center: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
backgroundColor: d3roNativePalette.bg.app
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
filters: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
gap: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(46, 46, 50, 0.5)'
|
||||
},
|
||||
chip: {
|
||||
backgroundColor: d3roNativePalette.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: d3roNativePalette.border.default,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: d3roNativePalette.accent.amber,
|
||||
borderColor: d3roNativePalette.accent.amber
|
||||
},
|
||||
listContent: { padding: 20, paddingBottom: 120 },
|
||||
card: { marginBottom: 12 },
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8
|
||||
},
|
||||
cardHeaderLeft: { flexDirection: 'row', alignItems: 'center' },
|
||||
wordBadge: {
|
||||
backgroundColor: d3roNativePalette.accent.amberDim,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4
|
||||
},
|
||||
transcriptText: { marginBottom: 8 },
|
||||
cardMeta: { flexDirection: 'row', gap: 12 }
|
||||
})
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
// apps/mobile/app/(tabs)/meetings.tsx
|
||||
// 회의 리스트 — @d3ro/ui-native 사용
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { View, FlatList, StyleSheet, ActivityIndicator, RefreshControl, Pressable } from 'react-native'
|
||||
import { MetalCard, PhosphorText, Led, d3roNativePalette } from '@d3ro/ui-native'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
|
||||
interface Meeting {
|
||||
id: string
|
||||
title: string | null
|
||||
started_at: string
|
||||
status: string
|
||||
}
|
||||
|
||||
function statusLedColor(status: string): 'amber' | 'green' | 'red' | 'orange' {
|
||||
if (status === 'recording') return 'red'
|
||||
if (status === 'processing') return 'orange'
|
||||
if (status === 'completed') return 'green'
|
||||
return 'amber'
|
||||
}
|
||||
|
||||
export default function MeetingsScreen(): React.ReactElement {
|
||||
const [meetings, setMeetings] = useState<Meeting[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('meetings')
|
||||
.select('id, title, started_at, status')
|
||||
.order('started_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
if (!error && data) {
|
||||
setMeetings(data as Meeting[])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color={d3roNativePalette.accent.amber} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={meetings}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={meetings.length === 0 ? styles.center : styles.listContent}
|
||||
style={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => {
|
||||
setRefreshing(true)
|
||||
void load()
|
||||
}}
|
||||
tintColor={d3roNativePalette.accent.amber}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<PhosphorText variant="small" color="muted" style={styles.empty}>
|
||||
아직 회의가 없습니다. Record 탭에서 새 녹음을 시작하세요.
|
||||
</PhosphorText>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable>
|
||||
<MetalCard style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Led color={statusLedColor(item.status)} size={8} />
|
||||
<PhosphorText variant="label" color="label" style={{ marginLeft: 8 }}>
|
||||
{item.status.toUpperCase()}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<PhosphorText variant="body" color="primary" style={{ marginTop: 6 }}>
|
||||
{item.title ?? '(제목 없음)'}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="meta" color="muted" style={{ marginTop: 4 }}>
|
||||
{new Date(item.started_at).toLocaleString('ko-KR')}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { backgroundColor: d3roNativePalette.bg.app },
|
||||
center: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
backgroundColor: d3roNativePalette.bg.app
|
||||
},
|
||||
listContent: { padding: 16 },
|
||||
empty: { textAlign: 'center' },
|
||||
card: { marginBottom: 12 },
|
||||
cardHeader: { flexDirection: 'row', alignItems: 'center' }
|
||||
})
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
// apps/mobile/app/(tabs)/profile.tsx
|
||||
// 프로필 — 사용자 정보 + 로그아웃. @d3ro/ui-native 사용
|
||||
|
||||
import { View, StyleSheet, Alert, ScrollView } from 'react-native'
|
||||
import { MetalCard, PhosphorText, PhysicalButton, d3roNativePalette } from '@d3ro/ui-native'
|
||||
import { useAuth } from '../../lib/auth-context'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
|
||||
export default function ProfileScreen(): React.ReactElement {
|
||||
const { user } = useAuth()
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
Alert.alert('로그아웃', '정말 로그아웃하시겠습니까?', [
|
||||
{ text: '취소', style: 'cancel' },
|
||||
{
|
||||
text: '로그아웃',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await supabase.auth.signOut()
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<MetalCard style={styles.section}>
|
||||
<PhosphorText variant="label" color="label">
|
||||
이메일
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body" color="primary" style={styles.value}>
|
||||
{user?.email ?? '—'}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
|
||||
<MetalCard style={styles.section}>
|
||||
<PhosphorText variant="label" color="label">
|
||||
USER ID
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="small" color="primary" style={styles.value}>
|
||||
{user?.id ?? '—'}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
|
||||
<View style={styles.logoutWrap}>
|
||||
<PhysicalButton label="로그아웃" variant="danger" onPress={() => void handleLogout()} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
||||
content: { padding: 24 },
|
||||
section: { marginBottom: 16 },
|
||||
value: { marginTop: 6 },
|
||||
logoutWrap: { marginTop: 8 }
|
||||
})
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
// apps/mobile/app/(tabs)/record.tsx
|
||||
// 녹음 — expo-av + @d3ro/ui-native
|
||||
// 녹음 탭 — expo-av 기반 녹음 + STT 파이프라인
|
||||
// Phase M-2에서 디자인(docs/v3/designs/recording.html) 정밀 적용
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { View, StyleSheet, ActivityIndicator, ScrollView } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Audio } from 'expo-av'
|
||||
import Constants from 'expo-constants'
|
||||
import {
|
||||
|
|
@ -17,19 +19,23 @@ import { supabase, isSupabaseConfigured } from '../../lib/supabase'
|
|||
type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error'
|
||||
|
||||
export default function RecordScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
const [state, setState] = useState<RecordingState>('idle')
|
||||
const [transcript, setTranscript] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const recordingRef = useRef<Audio.Recording | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
async function startRecording(): Promise<void> {
|
||||
setError(null)
|
||||
setTranscript('')
|
||||
setDuration(0)
|
||||
|
||||
try {
|
||||
const perm = await Audio.requestPermissionsAsync()
|
||||
if (perm.status !== 'granted') {
|
||||
setError('마이크 권한이 거부되었습니다')
|
||||
setError('Microphone permission denied')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
|
@ -44,6 +50,10 @@ export default function RecordScreen(): React.ReactElement {
|
|||
await recording.startAsync()
|
||||
recordingRef.current = recording
|
||||
setState('recording')
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setDuration((d) => d + 1)
|
||||
}, 1000)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start')
|
||||
setState('error')
|
||||
|
|
@ -52,6 +62,10 @@ export default function RecordScreen(): React.ReactElement {
|
|||
|
||||
async function stopRecording(): Promise<void> {
|
||||
if (!recordingRef.current) return
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
setState('processing')
|
||||
|
||||
try {
|
||||
|
|
@ -60,21 +74,18 @@ export default function RecordScreen(): React.ReactElement {
|
|||
recordingRef.current = null
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('녹음 파일 URI를 받지 못했습니다')
|
||||
throw new Error('No recording URI')
|
||||
}
|
||||
|
||||
if (!isSupabaseConfigured()) {
|
||||
setError('Supabase가 설정되지 않아 전사할 수 없습니다')
|
||||
setError('Supabase not configured')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession()
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
if (!session) {
|
||||
setError('로그인이 필요합니다')
|
||||
setError('Login required')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
|
@ -95,7 +106,7 @@ export default function RecordScreen(): React.ReactElement {
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`STT 실패: ${response.status}`)
|
||||
throw new Error(`STT failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { transcript: string }
|
||||
|
|
@ -108,36 +119,79 @@ export default function RecordScreen(): React.ReactElement {
|
|||
}
|
||||
|
||||
async function cancelRecording(): Promise<void> {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
if (recordingRef.current) {
|
||||
try {
|
||||
await recordingRef.current.stopAndUnloadAsync()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try { await recordingRef.current.stopAndUnloadAsync() } catch { /* ignore */ }
|
||||
recordingRef.current = null
|
||||
}
|
||||
setState('idle')
|
||||
setTranscript('')
|
||||
setError(null)
|
||||
setDuration(0)
|
||||
}
|
||||
|
||||
const statusLed: 'amber' | 'red' | 'orange' | 'green' =
|
||||
state === 'recording' ? 'red' : state === 'processing' ? 'orange' : state === 'done' ? 'green' : 'amber'
|
||||
const formatTime = (s: number): string => {
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = s % 60
|
||||
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Led color="amber" size={8} on={state === 'recording'} />
|
||||
<PhosphorText
|
||||
variant="label"
|
||||
color={state === 'recording' ? 'amber' : 'muted'}
|
||||
style={{ letterSpacing: 3 }}
|
||||
>
|
||||
{state === 'recording' ? 'REC_SESSION' : 'RECORDER'}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
{state === 'recording' && (
|
||||
<PhosphorText variant="label" color="amber">
|
||||
{formatTime(duration)}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 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={statusLed} size={10} on={state !== 'idle'} />
|
||||
<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'
|
||||
{state === 'recording' ? 'RECORDING'
|
||||
: state === 'processing' ? 'PROCESSING'
|
||||
: state === 'done' ? 'DONE'
|
||||
: state === 'error' ? 'ERROR'
|
||||
: 'READY'}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
|
|
@ -167,17 +221,17 @@ export default function RecordScreen(): React.ReactElement {
|
|||
<View style={styles.buttons}>
|
||||
{(state === 'idle' || state === 'done' || state === 'error') && (
|
||||
<PhysicalButton
|
||||
label={state === 'done' ? '새 녹음' : '녹음 시작'}
|
||||
label={state === 'done' ? 'NEW RECORDING' : 'START RECORDING'}
|
||||
variant="primary"
|
||||
onPress={() => void startRecording()}
|
||||
/>
|
||||
)}
|
||||
{state === 'recording' && (
|
||||
<>
|
||||
<PhysicalButton label="정지" variant="danger" onPress={() => void stopRecording()} />
|
||||
<PhysicalButton label="STOP" variant="danger" onPress={() => void stopRecording()} />
|
||||
<View style={{ height: 12 }} />
|
||||
<PhysicalButton
|
||||
label="취소"
|
||||
label="CANCEL"
|
||||
variant="secondary"
|
||||
onPress={() => void cancelRecording()}
|
||||
/>
|
||||
|
|
@ -191,8 +245,33 @@ export default function RecordScreen(): React.ReactElement {
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
||||
content: { padding: 24 },
|
||||
card: { padding: 24 },
|
||||
content: { paddingBottom: 100 },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(46, 46, 50, 0.5)'
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
waveContainer: {
|
||||
height: 128,
|
||||
backgroundColor: d3roNativePalette.bg.inset,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(26, 26, 28, 1)'
|
||||
},
|
||||
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 },
|
||||
|
|
|
|||
204
apps/mobile/app/(tabs)/settings.tsx
Normal file
204
apps/mobile/app/(tabs)/settings.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// apps/mobile/app/(tabs)/settings.tsx
|
||||
// 설정 탭 — 계정, 백엔드, 환경설정
|
||||
// Phase M-2에서 디자인(docs/v3/designs/settings.html) 정밀 적용
|
||||
|
||||
import { View, ScrollView, StyleSheet, Alert, Switch } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { MetalCard, PhosphorText, PhysicalButton, Led, d3roNativePalette } from '@d3ro/ui-native'
|
||||
import { useAuth } from '../../lib/auth-context'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
|
||||
export default function SettingsScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { user } = useAuth()
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
Alert.alert('Logout', 'Are you sure you want to log out?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Logout',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await supabase.auth.signOut()
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const initials = user?.email
|
||||
? user.email.substring(0, 2).toUpperCase()
|
||||
: 'US'
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Header */}
|
||||
<PhosphorText variant="title" color="primary" style={[styles.title, { paddingTop: insets.top + 8 }]}>
|
||||
Settings
|
||||
</PhosphorText>
|
||||
|
||||
{/* Account Section */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
|
||||
ACCOUNT & PLAN
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.section}>
|
||||
<View style={styles.profileRow}>
|
||||
<View style={styles.avatar}>
|
||||
<PhosphorText variant="body" color="amber">{initials}</PhosphorText>
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<PhosphorText variant="body" color="primary">
|
||||
{user?.email?.split('@')[0] ?? 'User'}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">
|
||||
{user?.email ?? '—'}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, { backgroundColor: d3roNativePalette.bg.inset }]}>
|
||||
<PhosphorText variant="small" color="muted">TIER</PhosphorText>
|
||||
<View style={styles.rowRight}>
|
||||
<Led color="amber" size={6} />
|
||||
<PhosphorText variant="body" color="amber" style={{ marginLeft: 8 }}>
|
||||
FREE
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</View>
|
||||
</MetalCard>
|
||||
|
||||
{/* Backend Section */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
|
||||
BACKEND CONFIG
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<View>
|
||||
<PhosphorText variant="body" color="primary">LLM Model</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">AI processing backend</PhosphorText>
|
||||
</View>
|
||||
<PhosphorText variant="body" color="amber">CLAUDE</PhosphorText>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View>
|
||||
<PhosphorText variant="body" color="primary">Cloud STT</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">Google Cloud Speech</PhosphorText>
|
||||
</View>
|
||||
<Switch
|
||||
value={true}
|
||||
trackColor={{
|
||||
false: d3roNativePalette.bg.inset,
|
||||
true: d3roNativePalette.accent.amber
|
||||
}}
|
||||
thumbColor="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
</MetalCard>
|
||||
|
||||
{/* Preferences Section */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
|
||||
PREFERENCES
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<PhosphorText variant="body" color="primary">Language</PhosphorText>
|
||||
<PhosphorText variant="body" color="muted">Korean</PhosphorText>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<PhosphorText variant="body" color="primary">Auto Polish</PhosphorText>
|
||||
<Switch
|
||||
value={true}
|
||||
trackColor={{
|
||||
false: d3roNativePalette.bg.inset,
|
||||
true: d3roNativePalette.accent.amber
|
||||
}}
|
||||
thumbColor="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<PhosphorText variant="body" color="primary">Haptic Feedback</PhosphorText>
|
||||
<Switch
|
||||
value={true}
|
||||
trackColor={{
|
||||
false: d3roNativePalette.bg.inset,
|
||||
true: d3roNativePalette.accent.amber
|
||||
}}
|
||||
thumbColor="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
</MetalCard>
|
||||
|
||||
{/* Logout */}
|
||||
<View style={styles.logoutWrap}>
|
||||
<PhysicalButton label="LOGOUT" variant="secondary" onPress={() => void handleLogout()} />
|
||||
</View>
|
||||
|
||||
{/* Status Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Led color="green" size={6} />
|
||||
<PhosphorText variant="label" color="muted" style={{ marginLeft: 6, letterSpacing: 1 }}>
|
||||
CLOUD CONNECTED
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
||||
content: { paddingBottom: 120 },
|
||||
title: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(46, 46, 50, 0.5)',
|
||||
fontFamily: undefined
|
||||
},
|
||||
sectionLabel: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 8,
|
||||
letterSpacing: 3
|
||||
},
|
||||
section: {
|
||||
marginHorizontal: 20,
|
||||
padding: 0,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
profileRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(46, 46, 50, 0.5)'
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: d3roNativePalette.bg.inset,
|
||||
borderWidth: 1,
|
||||
borderColor: d3roNativePalette.border.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
profileInfo: { gap: 2 },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16
|
||||
},
|
||||
rowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(46, 46, 50, 0.5)'
|
||||
},
|
||||
rowRight: { flexDirection: 'row', alignItems: 'center' },
|
||||
logoutWrap: { margin: 20 },
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
opacity: 0.5,
|
||||
marginTop: 16
|
||||
}
|
||||
})
|
||||
188
apps/mobile/app/(tabs)/talk.tsx
Normal file
188
apps/mobile/app/(tabs)/talk.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// apps/mobile/app/(tabs)/talk.tsx
|
||||
// AI 대화 탭 — 텍스트+음성 채팅
|
||||
// Phase M-2에서 디자인(docs/v3/designs/talk.html) 정밀 적용
|
||||
// Phase M-5에서 음성 파이프라인 구현
|
||||
|
||||
import { useState } from 'react'
|
||||
import { View, ScrollView, TextInput, StyleSheet, Pressable, KeyboardAvoidingView, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { MetalCard, PhosphorText, Led, d3roNativePalette } from '@d3ro/ui-native'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export default function TalkScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! How can I help you today?'
|
||||
}
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
function handleSend(): void {
|
||||
if (!input.trim()) return
|
||||
const userMsg: ChatMessage = {
|
||||
id: String(Date.now()),
|
||||
role: 'user',
|
||||
content: input.trim()
|
||||
}
|
||||
setMessages((prev) => [...prev, userMsg])
|
||||
setInput('')
|
||||
// Phase M-5: 실제 AI 응답 구현
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={80}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Led color="amber" size={8} />
|
||||
<PhosphorText variant="label" color="muted" style={{ letterSpacing: 3 }}>
|
||||
TALK
|
||||
</PhosphorText>
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<PhosphorText variant="label" color="amber">LIVE</PhosphorText>
|
||||
<Led color="green" size={6} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<ScrollView style={styles.chatArea} contentContainerStyle={styles.chatContent}>
|
||||
{messages.map((msg) => (
|
||||
<View
|
||||
key={msg.id}
|
||||
style={[
|
||||
styles.bubble,
|
||||
msg.role === 'user' ? styles.userBubble : styles.aiBubble
|
||||
]}
|
||||
>
|
||||
<PhosphorText
|
||||
variant="body"
|
||||
color={msg.role === 'user' ? 'amber' : 'primary'}
|
||||
style={{ fontFamily: undefined }}
|
||||
>
|
||||
{msg.content}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted" style={styles.bubbleLabel}>
|
||||
{msg.role === 'user' ? 'YOU' : 'CLAUDE'}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Input Area */}
|
||||
<View style={styles.inputArea}>
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
placeholder="Type or speak..."
|
||||
placeholderTextColor={d3roNativePalette.text.muted}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={handleSend}
|
||||
returnKeyType="send"
|
||||
/>
|
||||
<Pressable style={styles.sendBtn} onPress={handleSend}>
|
||||
<View style={styles.sendArrow} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: d3roNativePalette.border.default
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
chatArea: { flex: 1 },
|
||||
chatContent: { padding: 20, paddingBottom: 20, gap: 16 },
|
||||
bubble: {
|
||||
maxWidth: '85%',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
position: 'relative',
|
||||
marginBottom: 12
|
||||
},
|
||||
aiBubble: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: d3roNativePalette.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: d3roNativePalette.border.default,
|
||||
borderTopLeftRadius: 4
|
||||
},
|
||||
userBubble: {
|
||||
alignSelf: 'flex-end',
|
||||
backgroundColor: d3roNativePalette.accent.amberDim,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 92, 53, 0.3)',
|
||||
borderTopRightRadius: 4
|
||||
},
|
||||
bubbleLabel: {
|
||||
position: 'absolute',
|
||||
bottom: -16,
|
||||
fontSize: 9
|
||||
},
|
||||
inputArea: {
|
||||
backgroundColor: 'rgba(25, 25, 27, 0.95)',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: d3roNativePalette.border.default,
|
||||
padding: 12,
|
||||
paddingBottom: 24
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: d3roNativePalette.bg.inset,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: d3roNativePalette.border.default,
|
||||
padding: 8
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
color: d3roNativePalette.text.primary,
|
||||
fontSize: 14,
|
||||
paddingHorizontal: 8
|
||||
},
|
||||
sendBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: d3roNativePalette.accent.amberDim,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 92, 53, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
sendArrow: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeftWidth: 5,
|
||||
borderRightWidth: 5,
|
||||
borderBottomWidth: 8,
|
||||
borderLeftColor: 'transparent',
|
||||
borderRightColor: 'transparent',
|
||||
borderBottomColor: d3roNativePalette.accent.amber
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue