d3ro-voice/apps/mobile/app/login.tsx
윤찬 211673bc6c 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 시뮬레이터 검증 완료
2026-04-13 03:22:15 +09:00

381 lines
9.7 KiB
TypeScript

// apps/mobile/app/login.tsx
// 로그인 화면 — 디자인: docs/v3/designs/login.html
import { useState } from 'react'
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 { 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()
function handleDevSkip(): void {
devBypass()
router.replace('/(tabs)/dash')
}
async function signInWithProvider(provider: 'google' | 'github' | 'apple'): Promise<void> {
if (!configured) {
Alert.alert('Not Configured', 'Supabase environment variables are not set.')
return
}
setBusy(true)
try {
const redirectTo = Linking.createURL('auth-callback')
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo, skipBrowserRedirect: true }
})
if (error || !data.url) {
Alert.alert('Login Failed', error?.message ?? 'Could not get OAuth URL')
return
}
const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo)
if (result.type === 'success' && result.url) {
const url = new URL(result.url)
const code = url.searchParams.get('code')
if (code) {
const { error: exchangeErr } = await supabase.auth.exchangeCodeForSession(code)
if (exchangeErr) {
Alert.alert('Session Error', exchangeErr.message)
}
}
}
} finally {
setBusy(false)
}
}
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 (
<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>
{/* 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>
<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>
<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: P.bg.app
},
content: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingBottom: 40,
paddingTop: 60
},
// Branding
branding: {
alignItems: 'center',
marginBottom: 40
},
leds: {
flexDirection: 'row',
gap: 10,
marginBottom: 20
},
title: {
fontSize: 22,
letterSpacing: 5,
color: P.text.primary
},
subtitle: {
fontSize: 9,
letterSpacing: 2,
color: P.text.muted,
marginTop: 8,
textTransform: 'uppercase'
},
// OAuth
oauthSection: {
gap: 12,
marginBottom: 24
},
oauthBtn: {
backgroundColor: P.bg.card,
borderWidth: 1,
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
},
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
}
})