feat(V2-6/V2-7/V2-8): Mobile MVP + Teams + Billing 스캐폴딩
V2-6 — Mobile (Expo) MVP
- apps/mobile/ 신규, npm workspace에서 제외 (Expo deps 부담 회피)
- Expo SDK 51 + Expo Router + AsyncStorage Supabase 클라이언트
- 화면: index/login/(tabs)/{meetings,record,profile}
- expo-av로 녹음 → Edge Function stt-proxy 호출
- expo-web-browser + expo-linking으로 OAuth 콜백 처리
- README에 setup/EAS build 가이드
- 루트 package.json workspaces를 명시 나열로 변경 (apps/mobile 제외)
V2-7 — 팀 기능 (Web)
- apps/web/src/app/teams/page.tsx — 가입한 팀 카드 그리드
- apps/web/src/app/teams/[id]/page.tsx — 멤버 + 공유 회의
- components/teams/create-team-form.tsx — 팀 생성 + owner 자동 team_members
- components/teams/invite-member-form.tsx — user_id 직접 초대 (V2-7b에서 invite flow)
- Sidebar에 Teams/Billing 메뉴 + 아이콘
- ko.json에 nav.teams/nav.billing 키 추가
- V2-2 teams/team_members RLS 활용
V2-8 — 결제 스캐폴딩
- apps/web/src/app/billing/page.tsx — Free/Pro/Team 가격표 + 현재 구독
- components/billing/checkout-button.tsx — Edge Function 호출 후 redirect
- server/supabase/functions/stripe-checkout/index.ts:
- JWT 인증 -> 기존 customer 조회/생성 -> Checkout Session 생성
- subscriptions 테이블에 customer_id upsert
- server/supabase/functions/stripe-webhook/index.ts:
- checkout.session.completed -> subscriptions tier=pro/team active
- customer.subscription.updated/created -> status/period 업데이트
- customer.subscription.deleted -> tier=free, status=canceled
- signature 검증은 placeholder (V2-8b에서 정식)
- server/supabase/config.toml에 두 함수 등록 (webhook verify_jwt=false)
검증:
- desktop typecheck OK (회귀 없음)
- web typecheck OK
- web next build OK (11 라우트)
- mobile은 별도 install 필요 (workspace 제외)
memory/project_status.md 갱신 — V2 마스터 플랜 전 페이즈 로컬 완료
This commit is contained in:
parent
5c0f4a2b98
commit
61a96b3e9b
136 changed files with 2641 additions and 251 deletions
35
apps/mobile/app/(tabs)/_layout.tsx
Normal file
35
apps/mobile/app/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// apps/mobile/app/(tabs)/_layout.tsx
|
||||
// 탭 네비게이션 — Meetings / Record / Profile
|
||||
|
||||
import { Tabs } from 'expo-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useAuth } from '../../lib/auth-context'
|
||||
|
||||
export default function TabsLayout(): React.ReactElement {
|
||||
const router = useRouter()
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.replace('/login')
|
||||
}
|
||||
}, [user, loading, router])
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarStyle: { backgroundColor: '#19191b', borderTopColor: '#2a2a2d' },
|
||||
tabBarActiveTintColor: '#f25b29',
|
||||
tabBarInactiveTintColor: '#8e8e93',
|
||||
headerStyle: { backgroundColor: '#19191b' },
|
||||
headerTintColor: '#f25b29',
|
||||
headerTitleStyle: { fontWeight: '300', letterSpacing: 1 }
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen name="meetings" options={{ title: 'Meetings' }} />
|
||||
<Tabs.Screen name="record" options={{ title: 'Record' }} />
|
||||
<Tabs.Screen name="profile" options={{ title: 'Profile' }} />
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
92
apps/mobile/app/(tabs)/meetings.tsx
Normal file
92
apps/mobile/app/(tabs)/meetings.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// apps/mobile/app/(tabs)/meetings.tsx
|
||||
// 회의 리스트 — Supabase에서 fetch
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { View, Text, FlatList, Pressable, StyleSheet, ActivityIndicator, RefreshControl } from 'react-native'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
|
||||
interface Meeting {
|
||||
id: string
|
||||
title: string | null
|
||||
started_at: string
|
||||
status: string
|
||||
}
|
||||
|
||||
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="#f25b29" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={meetings}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={meetings.length === 0 ? styles.center : { padding: 16 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => {
|
||||
setRefreshing(true)
|
||||
void load()
|
||||
}}
|
||||
tintColor="#f25b29"
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.empty}>아직 회의가 없습니다. Record 탭에서 새 녹음을 시작하세요.</Text>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.card}>
|
||||
<Text style={styles.title}>{item.title ?? '(제목 없음)'}</Text>
|
||||
<Text style={styles.meta}>
|
||||
{new Date(item.started_at).toLocaleString('ko-KR')} · {item.status}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 },
|
||||
empty: { color: '#8e8e93', textAlign: 'center', fontSize: 13 },
|
||||
card: {
|
||||
backgroundColor: '#242427',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.04)'
|
||||
},
|
||||
title: { color: '#ffffff', fontSize: 14, fontWeight: '500', marginBottom: 4 },
|
||||
meta: { color: '#8e8e93', fontSize: 11 }
|
||||
})
|
||||
71
apps/mobile/app/(tabs)/profile.tsx
Normal file
71
apps/mobile/app/(tabs)/profile.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// apps/mobile/app/(tabs)/profile.tsx
|
||||
// 프로필 — 사용자 정보 + 로그아웃
|
||||
|
||||
import { View, Text, Pressable, StyleSheet, Alert } from 'react-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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>이메일</Text>
|
||||
<Text style={styles.value}>{user?.email ?? '—'}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>User ID</Text>
|
||||
<Text style={styles.value}>{user?.id ?? '—'}</Text>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.logoutButton} onPress={() => void handleLogout()}>
|
||||
<Text style={styles.logoutText}>로그아웃</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 24 },
|
||||
section: {
|
||||
backgroundColor: '#242427',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.04)'
|
||||
},
|
||||
label: {
|
||||
color: '#8e8e93',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.5,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 4
|
||||
},
|
||||
value: { color: '#ffffff', fontSize: 14 },
|
||||
logoutButton: {
|
||||
marginTop: 16,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ef4444',
|
||||
alignItems: 'center'
|
||||
},
|
||||
logoutText: { color: '#ef4444', fontSize: 14, fontWeight: '600' }
|
||||
})
|
||||
179
apps/mobile/app/(tabs)/record.tsx
Normal file
179
apps/mobile/app/(tabs)/record.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// apps/mobile/app/(tabs)/record.tsx
|
||||
// 녹음 — expo-av Audio.Recording 사용
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator } from 'react-native'
|
||||
import { Audio } from 'expo-av'
|
||||
import { supabase, isSupabaseConfigured } from '../../lib/supabase'
|
||||
import Constants from 'expo-constants'
|
||||
|
||||
type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error'
|
||||
|
||||
export default function RecordScreen(): React.ReactElement {
|
||||
const [state, setState] = useState<RecordingState>('idle')
|
||||
const [transcript, setTranscript] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const recordingRef = useRef<Audio.Recording | null>(null)
|
||||
|
||||
async function startRecording(): Promise<void> {
|
||||
setError(null)
|
||||
setTranscript('')
|
||||
|
||||
try {
|
||||
const perm = await Audio.requestPermissionsAsync()
|
||||
if (perm.status !== 'granted') {
|
||||
setError('마이크 권한이 거부되었습니다')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true
|
||||
})
|
||||
|
||||
const recording = new Audio.Recording()
|
||||
await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY)
|
||||
await recording.startAsync()
|
||||
recordingRef.current = recording
|
||||
setState('recording')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start')
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording(): Promise<void> {
|
||||
if (!recordingRef.current) return
|
||||
setState('processing')
|
||||
|
||||
try {
|
||||
await recordingRef.current.stopAndUnloadAsync()
|
||||
const uri = recordingRef.current.getURI()
|
||||
recordingRef.current = null
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('녹음 파일 URI를 받지 못했습니다')
|
||||
}
|
||||
|
||||
if (!isSupabaseConfigured()) {
|
||||
setError('Supabase가 설정되지 않아 전사할 수 없습니다')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
// Edge Function 호출
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession()
|
||||
|
||||
if (!session) {
|
||||
setError('로그인이 필요합니다')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
// RN의 FormData는 { uri, name, type } 형태
|
||||
formData.append('audio', {
|
||||
uri,
|
||||
name: 'recording.m4a',
|
||||
type: 'audio/m4a'
|
||||
} as unknown as Blob)
|
||||
formData.append('language_code', 'ko-KR')
|
||||
|
||||
const url = (Constants.expoConfig?.extra?.supabaseUrl as string) ?? ''
|
||||
const response = await fetch(`${url}/functions/v1/stt-proxy`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${session.access_token}` },
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`STT 실패: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { transcript: string }
|
||||
setTranscript(result.transcript)
|
||||
setState('done')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRecording(): Promise<void> {
|
||||
if (recordingRef.current) {
|
||||
try {
|
||||
await recordingRef.current.stopAndUnloadAsync()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recordingRef.current = null
|
||||
}
|
||||
setState('idle')
|
||||
setTranscript('')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{state === 'recording' ? 'RECORDING' : state === 'processing' ? 'PROCESSING' : 'READY'}</Text>
|
||||
|
||||
{state === 'processing' && <ActivityIndicator size="large" color="#f25b29" style={styles.spinner} />}
|
||||
|
||||
{transcript && state === 'done' && (
|
||||
<View style={styles.transcriptBox}>
|
||||
<Text style={styles.transcript}>{transcript}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.error}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.buttons}>
|
||||
{(state === 'idle' || state === 'done' || state === 'error') && (
|
||||
<Pressable style={styles.bigButton} onPress={() => void startRecording()}>
|
||||
<Text style={styles.bigButtonText}>{state === 'done' ? '새 녹음' : '녹음 시작'}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{state === 'recording' && (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Pressable style={[styles.bigButton, styles.stop]} onPress={() => void stopRecording()}>
|
||||
<Text style={styles.bigButtonText}>정지</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.cancelButton} onPress={() => void cancelRecording()}>
|
||||
<Text style={styles.cancelText}>취소</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
title: { color: '#f25b29', fontSize: 20, fontWeight: '600', letterSpacing: 2, marginBottom: 32 },
|
||||
spinner: { marginVertical: 24 },
|
||||
transcriptBox: { backgroundColor: '#242427', padding: 16, borderRadius: 8, marginBottom: 24, width: '100%' },
|
||||
transcript: { color: '#ffffff', fontSize: 14, lineHeight: 20 },
|
||||
errorBox: { borderWidth: 1, borderColor: '#ef4444', padding: 12, borderRadius: 8, marginBottom: 24 },
|
||||
error: { color: '#ef4444', fontSize: 12 },
|
||||
buttons: { width: '100%', alignItems: 'center' },
|
||||
bigButton: {
|
||||
backgroundColor: '#f25b29',
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 64,
|
||||
borderRadius: 12,
|
||||
minWidth: 200,
|
||||
alignItems: 'center'
|
||||
},
|
||||
bigButtonText: { color: '#ffffff', fontSize: 16, fontWeight: '600', letterSpacing: 1 },
|
||||
stop: { backgroundColor: '#ef4444' },
|
||||
cancelButton: { paddingVertical: 12, alignItems: 'center' },
|
||||
cancelText: { color: '#8e8e93', fontSize: 13 }
|
||||
})
|
||||
31
apps/mobile/app/_layout.tsx
Normal file
31
apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// apps/mobile/app/_layout.tsx
|
||||
// 루트 레이아웃 — Stack + AuthProvider
|
||||
|
||||
import { Stack } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import { AuthProvider } from '../lib/auth-context'
|
||||
|
||||
export default function RootLayout(): React.ReactElement {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<AuthProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#19191b' },
|
||||
headerTintColor: '#f25b29',
|
||||
contentStyle: { backgroundColor: '#19191b' }
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="login" options={{ title: '로그인' }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</AuthProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
)
|
||||
}
|
||||
28
apps/mobile/app/index.tsx
Normal file
28
apps/mobile/app/index.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// apps/mobile/app/index.tsx
|
||||
// 진입점 — 로그인 상태에 따라 리다이렉트
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { ActivityIndicator, View } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useAuth } from '../lib/auth-context'
|
||||
|
||||
export default function Index(): React.ReactElement {
|
||||
const router = useRouter()
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (user) {
|
||||
router.replace('/(tabs)/meetings')
|
||||
} else {
|
||||
router.replace('/login')
|
||||
}
|
||||
}
|
||||
}, [user, loading, router])
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#19191b' }}>
|
||||
<ActivityIndicator size="large" color="#f25b29" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
138
apps/mobile/app/login.tsx
Normal file
138
apps/mobile/app/login.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// apps/mobile/app/login.tsx
|
||||
// OAuth 로그인 화면
|
||||
|
||||
import { useState } from 'react'
|
||||
import { View, Text, Pressable, Alert, StyleSheet, ActivityIndicator } from 'react-native'
|
||||
import * as WebBrowser from 'expo-web-browser'
|
||||
import * as Linking from 'expo-linking'
|
||||
import { supabase, isSupabaseConfigured } from '../lib/supabase'
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession()
|
||||
|
||||
export default function LoginScreen(): React.ReactElement {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const configured = isSupabaseConfigured()
|
||||
|
||||
async function signInWithProvider(provider: 'google' | 'github'): Promise<void> {
|
||||
if (!configured) {
|
||||
Alert.alert('미설정', 'Supabase 환경변수가 설정되지 않았습니다.')
|
||||
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('로그인 실패', error?.message ?? '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('세션 교환 실패', exchangeErr.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>D3RO VOICE</Text>
|
||||
<Text style={styles.subtitle}>AI 음성 어시스턴트</Text>
|
||||
|
||||
{!configured && (
|
||||
<Text style={styles.warning}>
|
||||
Supabase가 설정되지 않았습니다. app.json의 extra 필드를 확인하세요.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.buttons}>
|
||||
<Pressable
|
||||
style={[styles.button, styles.google, (!configured || busy) && styles.disabled]}
|
||||
onPress={() => void signInWithProvider('google')}
|
||||
disabled={!configured || busy}
|
||||
>
|
||||
<Text style={styles.buttonText}>Google로 계속하기</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.button, styles.github, (!configured || busy) && styles.disabled]}
|
||||
onPress={() => void signInWithProvider('github')}
|
||||
disabled={!configured || busy}
|
||||
>
|
||||
<Text style={styles.buttonText}>GitHub로 계속하기</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{busy && <ActivityIndicator size="small" color="#f25b29" style={{ marginTop: 16 }} />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#19191b',
|
||||
justifyContent: 'center',
|
||||
padding: 32
|
||||
},
|
||||
title: {
|
||||
color: '#f25b29',
|
||||
fontSize: 36,
|
||||
fontWeight: '300',
|
||||
textAlign: 'center',
|
||||
letterSpacing: 2,
|
||||
marginBottom: 8
|
||||
},
|
||||
subtitle: {
|
||||
color: '#8e8e93',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginBottom: 48
|
||||
},
|
||||
warning: {
|
||||
color: '#f59e0b',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
padding: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#f59e0b',
|
||||
borderRadius: 8
|
||||
},
|
||||
buttons: {
|
||||
gap: 12
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center'
|
||||
},
|
||||
google: {
|
||||
backgroundColor: '#f25b29'
|
||||
},
|
||||
github: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#8e8e93'
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue