- 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 시뮬레이터 검증 완료
381 lines
9.7 KiB
TypeScript
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
|
|
}
|
|
})
|