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:
윤찬 2026-04-13 03:22:15 +09:00
parent ffca07d120
commit 211673bc6c
30 changed files with 15010 additions and 525 deletions

22
apps/mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# Dependencies
node_modules/
package-lock.json
# Expo
.expo/
dist/
expo-env.d.ts
# Environment
.env
.env.local
# Build
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# macOS
.DS_Store

64
apps/mobile/app.config.ts Normal file
View file

@ -0,0 +1,64 @@
// apps/mobile/app.config.ts
// Expo 설정 — 환경변수 관리, 플러그인, 권한
import { type ExpoConfig, type ConfigContext } from 'expo/config'
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'D3RO Voice',
slug: 'd3ro-voice',
scheme: 'd3ro-voice',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'dark',
splash: {
image: './assets/splash.png',
resizeMode: 'contain',
backgroundColor: '#19191b'
},
ios: {
supportsTablet: true,
bundleIdentifier: 'com.d3ro.voice',
infoPlist: {
NSMicrophoneUsageDescription:
'D3RO Voice uses the microphone for voice recognition and recording.',
UIBackgroundModes: ['audio', 'fetch', 'remote-notification']
}
},
android: {
package: 'com.d3ro.voice',
permissions: [
'RECORD_AUDIO',
'FOREGROUND_SERVICE',
'FOREGROUND_SERVICE_MICROPHONE',
'POST_NOTIFICATIONS'
],
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#19191b'
}
},
plugins: [
'expo-router',
'expo-secure-store',
'expo-av',
[
'expo-notifications',
{
icon: './assets/icon.png',
color: '#ff5c35'
}
]
],
experiments: {
typedRoutes: true
},
extra: {
supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL ?? '',
supabaseAnonKey: process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? '',
eas: {
projectId: process.env.EAS_PROJECT_ID ?? ''
}
}
})

View file

@ -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
}
})

View 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' }
})

View 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 }
})

View file

@ -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' }
})

View file

@ -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 }
})

View file

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

View 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
}
})

View 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
}
})

View file

@ -21,7 +21,7 @@ export default function RootLayout(): React.ReactElement {
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ title: '로그인' }} />
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</AuthProvider>

View file

@ -13,7 +13,7 @@ export default function Index(): React.ReactElement {
useEffect(() => {
if (!loading) {
if (user) {
router.replace('/(tabs)/meetings')
router.replace('/(tabs)/dash')
} else {
router.replace('/login')
}

View file

@ -1,22 +1,33 @@
// apps/mobile/app/login.tsx
// OAuth 로그인 화면 — @d3ro/ui-native 사용
// 로그인 화면 — 디자인: docs/v3/designs/login.html
import { useState } from 'react'
import { View, StyleSheet, Alert } from 'react-native'
import { View, Text, TextInput, StyleSheet, Alert, ScrollView, Pressable, Platform } from 'react-native'
import * as WebBrowser from 'expo-web-browser'
import * as Linking from 'expo-linking'
import { MetalCard, PhosphorText, PhysicalButton, d3roNativePalette } from '@d3ro/ui-native'
import { d3roNativePalette, d3roNativeFonts, Led } from '@d3ro/ui-native'
import { supabase, isSupabaseConfigured } from '../lib/supabase'
import { useAuth } from '../lib/auth-context'
import { useRouter } from 'expo-router'
WebBrowser.maybeCompleteAuthSession()
export default function LoginScreen(): React.ReactElement {
const [busy, setBusy] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const configured = isSupabaseConfigured()
const { devBypass } = useAuth()
const router = useRouter()
async function signInWithProvider(provider: 'google' | 'github'): Promise<void> {
function handleDevSkip(): void {
devBypass()
router.replace('/(tabs)/dash')
}
async function signInWithProvider(provider: 'google' | 'github' | 'apple'): Promise<void> {
if (!configured) {
Alert.alert('미설정', 'Supabase 환경변수가 설정되지 않았습니다.')
Alert.alert('Not Configured', 'Supabase environment variables are not set.')
return
}
setBusy(true)
@ -28,7 +39,7 @@ export default function LoginScreen(): React.ReactElement {
})
if (error || !data.url) {
Alert.alert('로그인 실패', error?.message ?? 'OAuth URL을 받지 못했습니다')
Alert.alert('Login Failed', error?.message ?? 'Could not get OAuth URL')
return
}
@ -39,7 +50,7 @@ export default function LoginScreen(): React.ReactElement {
if (code) {
const { error: exchangeErr } = await supabase.auth.exchangeCodeForSession(code)
if (exchangeErr) {
Alert.alert('세션 교환 실패', exchangeErr.message)
Alert.alert('Session Error', exchangeErr.message)
}
}
}
@ -48,68 +59,323 @@ export default function LoginScreen(): React.ReactElement {
}
}
async function signInWithEmail(): Promise<void> {
if (!configured) {
Alert.alert('Not Configured', 'Supabase environment variables are not set.')
return
}
if (!email.trim() || !password.trim()) return
setBusy(true)
try {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
Alert.alert('Login Failed', error.message)
}
} finally {
setBusy(false)
}
}
const monoFont = d3roNativeFonts.mono
return (
<View style={styles.container}>
<MetalCard style={styles.card}>
<View style={styles.header}>
<PhosphorText variant="hero">D3RO VOICE</PhosphorText>
<PhosphorText variant="label" color="secondary" style={styles.subtitle}>
AI
</PhosphorText>
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
{/* Logo & Branding */}
<View style={styles.branding}>
<View style={styles.leds}>
<Led color="amber" size={10} />
<Led color="green" size={10} />
</View>
<Text style={[styles.title, { fontFamily: monoFont }]}>D3RO-VOICE</Text>
<Text style={[styles.subtitle, { fontFamily: monoFont }]}>
PRECISION VOICE INTELLIGENCE
</Text>
</View>
{!configured && (
<View style={styles.warningBox}>
<PhosphorText variant="small" color="label">
Supabase가 . app.json의 extra .
</PhosphorText>
</View>
)}
{/* OAuth Buttons */}
<View style={styles.oauthSection}>
<Pressable
style={({ pressed }) => [styles.oauthBtn, pressed && styles.oauthBtnPressed]}
onPress={() => void signInWithProvider('google')}
disabled={busy}
>
<Text style={styles.oauthIcon}>G</Text>
<Text style={styles.oauthLabel}>Google로 </Text>
</Pressable>
<View style={styles.buttons}>
<PhysicalButton
label="Google로 계속하기"
variant="primary"
disabled={!configured || busy}
onPress={() => void signInWithProvider('google')}
/>
<PhysicalButton
label="GitHub로 계속하기"
variant="secondary"
disabled={!configured || busy}
onPress={() => void signInWithProvider('github')}
<Pressable
style={({ pressed }) => [styles.oauthBtn, pressed && styles.oauthBtnPressed]}
onPress={() => void signInWithProvider('apple')}
disabled={busy}
>
<Text style={[styles.oauthIcon, { fontSize: 18 }]}></Text>
<Text style={styles.oauthLabel}>Apple로 </Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.oauthBtn, pressed && styles.oauthBtnPressed]}
onPress={() => void signInWithProvider('github')}
disabled={busy}
>
<Text style={styles.oauthIcon}></Text>
<Text style={styles.oauthLabel}>GitHub로 </Text>
</Pressable>
</View>
{/* Divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={[styles.dividerText, { fontFamily: monoFont }]}></Text>
<View style={styles.dividerLine} />
</View>
{/* Email/Password Fields */}
<View style={styles.formSection}>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { fontFamily: monoFont }]}>EMAIL</Text>
<TextInput
style={styles.input}
placeholder="user@studio.com"
placeholderTextColor="rgba(113, 113, 122, 0.3)"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
</MetalCard>
</View>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { fontFamily: monoFont }]}>PASSWORD</Text>
<TextInput
style={styles.input}
placeholder="••••••••"
placeholderTextColor="rgba(113, 113, 122, 0.3)"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
</View>
</View>
{/* Login Button */}
<Pressable
style={({ pressed }) => [styles.loginBtn, pressed && { opacity: 0.9 }]}
onPress={() => void signInWithEmail()}
disabled={busy}
>
<Text style={styles.loginBtnText}></Text>
</Pressable>
{/* Sign Up Link */}
<View style={styles.signupRow}>
<Text style={styles.signupText}> ?</Text>
<Pressable>
<Text style={styles.signupLink}></Text>
</Pressable>
</View>
{/* DEV Skip (개발 전용) */}
{__DEV__ && (
<Pressable style={styles.devSkipBtn} onPress={handleDevSkip}>
<Text style={[styles.devSkipText, { fontFamily: monoFont }]}>
DEV SKIP LOGIN
</Text>
</Pressable>
)}
{/* Supabase Warning */}
{!configured && (
<View style={styles.warningBox}>
<Text style={[styles.warningText, { fontFamily: monoFont }]}>
Supabase not configured. Set EXPO_PUBLIC_SUPABASE_URL in .env
</Text>
</View>
)}
</ScrollView>
)
}
const P = d3roNativePalette
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: d3roNativePalette.bg.app,
backgroundColor: P.bg.app
},
content: {
flexGrow: 1,
justifyContent: 'center',
padding: 24
paddingHorizontal: 24,
paddingBottom: 40,
paddingTop: 60
},
card: {
padding: 32
},
header: {
// Branding
branding: {
alignItems: 'center',
marginBottom: 32
marginBottom: 40
},
leds: {
flexDirection: 'row',
gap: 10,
marginBottom: 20
},
title: {
fontSize: 22,
letterSpacing: 5,
color: P.text.primary
},
subtitle: {
marginTop: 8
fontSize: 9,
letterSpacing: 2,
color: P.text.muted,
marginTop: 8,
textTransform: 'uppercase'
},
warningBox: {
// OAuth
oauthSection: {
gap: 12,
marginBottom: 24
},
oauthBtn: {
backgroundColor: P.bg.card,
borderWidth: 1,
borderColor: d3roNativePalette.tag.orange,
padding: 12,
borderRadius: 8,
borderColor: P.border.default,
borderRadius: 12,
paddingVertical: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12
},
oauthBtnPressed: {
backgroundColor: P.bg.cardHover,
borderColor: 'rgba(113, 113, 122, 0.4)'
},
oauthIcon: {
fontSize: 16,
color: P.text.primary
},
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: {
backgroundColor: P.bg.inset,
borderWidth: 1,
borderColor: P.border.default,
borderRadius: 12,
paddingVertical: 14,
paddingHorizontal: 16,
fontSize: 14,
color: P.text.primary,
fontFamily: Platform.OS === 'ios' ? 'System' : 'Roboto'
},
// Login Button
loginBtn: {
backgroundColor: P.accent.amber,
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
shadowColor: P.accent.amber,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 10,
elevation: 6,
marginBottom: 24
},
loginBtnText: {
fontSize: 14,
fontWeight: '600',
color: P.bg.app,
letterSpacing: 1
},
// Sign Up
signupRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 6,
marginBottom: 16
},
buttons: {
gap: 12
signupText: {
fontSize: 11,
color: P.text.muted
},
signupLink: {
fontSize: 11,
color: P.accent.amber,
fontWeight: '500'
},
// Warning
warningBox: {
borderWidth: 1,
borderColor: 'rgba(255, 92, 53, 0.3)',
backgroundColor: P.accent.amberDim,
padding: 12,
borderRadius: 8,
marginTop: 8
},
warningText: {
fontSize: 10,
color: P.accent.amber,
textAlign: 'center'
},
// DEV Skip
devSkipBtn: {
borderWidth: 1,
borderColor: P.accent.green,
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
marginBottom: 12,
backgroundColor: 'rgba(74, 222, 128, 0.08)'
},
devSkipText: {
fontSize: 10,
color: P.accent.green,
letterSpacing: 2
}
})

View file

@ -10,14 +10,26 @@ interface AuthContextValue {
session: Session | null
user: User | null
loading: boolean
devBypass: () => void
}
const AuthContext = createContext<AuthContextValue>({
session: null,
user: null,
loading: true
loading: true,
devBypass: () => {}
})
// DEV 전용 — 가짜 유저로 로그인 우회
const DEV_USER: User = {
id: 'dev-user-00000',
email: 'dev@d3ro.local',
app_metadata: {},
user_metadata: {},
aud: 'authenticated',
created_at: new Date().toISOString()
} as User
export function AuthProvider({ children }: { children: ReactNode }): React.ReactElement {
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
@ -47,8 +59,16 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
}
}, [])
const [devMode, setDevMode] = useState(false)
function devBypass(): void {
setDevMode(true)
}
const effectiveUser = devMode ? DEV_USER : (session?.user ?? null)
return (
<AuthContext.Provider value={{ session, user: session?.user ?? null, loading }}>
<AuthContext.Provider value={{ session, user: effectiveUser, loading, devBypass }}>
{children}
</AuthContext.Provider>
)

View file

@ -3,29 +3,33 @@
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from '@supabase/supabase-js'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import Constants from 'expo-constants'
const supabaseUrl = (Constants.expoConfig?.extra?.supabaseUrl as string | undefined) ?? ''
const supabaseAnonKey = (Constants.expoConfig?.extra?.supabaseAnonKey as string | undefined) ?? ''
if (!supabaseUrl || !supabaseAnonKey) {
// env 미설정 — placeholder로 client 생성, 런타임에 isConfigured 체크
export function isSupabaseConfigured(): boolean {
return Boolean(supabaseUrl && supabaseAnonKey)
}
export const supabase = createClient(
supabaseUrl || 'https://placeholder.supabase.co',
supabaseAnonKey || 'placeholder-anon-key',
{
function createSupabaseClient(): SupabaseClient {
if (!isSupabaseConfigured()) {
// env 미설정 시에도 크래시 방지용 더미 클라이언트 생성
// 런타임에서 isSupabaseConfigured()로 체크 후 사용
return createClient('https://localhost.invalid', 'no-key', {
auth: { storage: AsyncStorage, persistSession: false, detectSessionInUrl: false }
})
}
return createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false
}
}
)
export function isSupabaseConfigured(): boolean {
return Boolean(supabaseUrl && supabaseAnonKey)
})
}
export const supabase = createSupabaseClient()

View file

@ -0,0 +1,36 @@
// apps/mobile/metro.config.js
// Monorepo 호환 Metro 설정
// 핵심: root node_modules의 react-native 0.84를 절대 참조하지 않도록 격리
const { getDefaultConfig } = require('expo/metro-config')
const path = require('path')
const projectRoot = __dirname
const monorepoRoot = path.resolve(projectRoot, '../..')
const config = getDefaultConfig(projectRoot)
// packages 디렉토리만 watch (root node_modules는 watch하지 않음!)
config.watchFolders = [
path.resolve(monorepoRoot, 'packages/core'),
path.resolve(monorepoRoot, 'packages/ui-native'),
path.resolve(monorepoRoot, 'packages/i18n'),
path.resolve(monorepoRoot, 'packages/api-client')
]
// node_modules: 로컬만 사용
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules')
]
// packages 내에서 import하는 모든 모듈을 로컬 node_modules로 강제
config.resolver.extraNodeModules = new Proxy(
{},
{
get: (_target, name) => {
return path.resolve(projectRoot, 'node_modules', String(name))
}
}
)
module.exports = config

View file

@ -11,6 +11,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@d3ro/api-client": "file:../../packages/api-client",
"@d3ro/core": "file:../../packages/core",
"@d3ro/i18n": "file:../../packages/i18n",
"@d3ro/ui-native": "file:../../packages/ui-native",
"@react-native-async-storage/async-storage": "1.23.1",
"@supabase/supabase-js": "^2.45.0",
@ -35,5 +38,8 @@
"devDependencies": {
"@types/react": "~18.2.0",
"typescript": "^5.7.0"
},
"overrides": {
"@types/react": "~18.2.0"
}
}

View file

@ -5,10 +5,19 @@
"noImplicitAny": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"typeRoots": ["./node_modules/@types"]
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
"include": [
"app/**/*.ts",
"app/**/*.tsx",
"lib/**/*.ts",
"lib/**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}