feat(mobile): Expo → RN CLI 전환 + Android 빌드 성공

RN 0.85 + React 19 기반 apps/mobile-rn 프로젝트 생성.
6개 화면 이식 (Login, Dash, History, Record, Talk, Settings).
@react-navigation/native + bottom-tabs 네비게이션 구성.
Windows NDK CMake libc++_shared 링크 버그 워크아라운드 포함.
Galaxy Fold (SM_F956N) 무선 디버깅 APK 설치 확인.
This commit is contained in:
yunchan8804 2026-04-13 21:13:40 +09:00
parent 55eb62189a
commit bf36a9d97d
68 changed files with 13955 additions and 9375 deletions

View file

@ -0,0 +1,55 @@
// src/lib/auth-context.tsx — Auth provider using Supabase
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import type { ReactNode } from 'react'
import type { User, Session } from '@supabase/supabase-js'
import { supabase } from './supabase'
interface AuthContextValue {
user: User | null
session: Session | null
loading: boolean
devBypass: () => void
}
const AuthContext = createContext<AuthContextValue>({
user: null,
session: null,
loading: true,
devBypass: () => {},
})
export function AuthProvider({ children }: { children: ReactNode }): React.ReactElement {
const [user, setUser] = useState<User | null>(null)
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
supabase.auth.getSession().then(({ data: { session: s } }) => {
setSession(s)
setUser(s?.user ?? null)
setLoading(false)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s)
setUser(s?.user ?? null)
})
return () => subscription.unsubscribe()
}, [])
const devBypass = useCallback(() => {
setUser({ id: 'dev-user', email: 'dev@d3ro.local' } as User)
setLoading(false)
}, [])
return (
<AuthContext.Provider value={{ user, session, loading, devBypass }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
return useContext(AuthContext)
}

View file

@ -0,0 +1,25 @@
// src/lib/supabase.ts — Supabase client for RN
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
// TODO: move to env config or supabase-config.ts
const SUPABASE_URL = ''
const SUPABASE_ANON_KEY = ''
export const supabase: SupabaseClient = createClient(
SUPABASE_URL,
SUPABASE_ANON_KEY,
{
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
}
)
export function isSupabaseConfigured(): boolean {
return SUPABASE_URL.length > 0 && SUPABASE_ANON_KEY.length > 0
}

View file

@ -0,0 +1,149 @@
// src/navigation/TabNavigator.tsx — Bottom tabs: DASH / HIST / REC / TALK / SET
import { View, StyleSheet, Pressable, Platform } from 'react-native'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { d3roNativePalette } from '@d3ro/ui-native'
import DashScreen from '../screens/DashScreen'
import HistoryScreen from '../screens/HistoryScreen'
import RecordScreen from '../screens/RecordScreen'
import TalkScreen from '../screens/TalkScreen'
import SettingsScreen from '../screens/SettingsScreen'
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace'
const Tab = createBottomTabNavigator()
function TabIcon({ type, color }: { type: string; color: string }): React.ReactElement {
if (type === 'dash') {
return (
<View style={[iconStyles.grid, { borderColor: color }]}>
{[0, 1, 2, 3].map((i) => (
<View key={i} style={[iconStyles.gridCell, { borderColor: color }]} />
))}
</View>
)
}
if (type === 'hist') {
return (
<View style={[iconStyles.circle, { borderColor: color }]}>
<View style={[iconStyles.hand, { backgroundColor: color }]} />
</View>
)
}
if (type === 'talk') {
return <View style={[iconStyles.chat, { borderColor: color }]} />
}
if (type === 'set') {
return <View style={[iconStyles.gear, { borderColor: color }]} />
}
return <View />
}
function RecordFAB(): React.ReactElement {
return (
<View style={fabStyles.fab}>
<View style={fabStyles.micIcon}>
<View style={fabStyles.micBody} />
<View style={fabStyles.micBase} />
</View>
</View>
)
}
export default function TabNavigator(): React.ReactElement {
return (
<Tab.Navigator
screenOptions={{
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,
fontSize: 9,
fontWeight: '500',
letterSpacing: 0.5,
},
headerShown: false,
}}
>
<Tab.Screen
name="Dash"
component={DashScreen}
options={{
title: 'DASH',
tabBarIcon: ({ color }) => <TabIcon type="dash" color={color} />,
}}
/>
<Tab.Screen
name="History"
component={HistoryScreen}
options={{
title: 'HIST',
tabBarIcon: ({ color }) => <TabIcon type="hist" color={color} />,
}}
/>
<Tab.Screen
name="Record"
component={RecordScreen}
options={{
title: '',
tabBarIcon: () => <RecordFAB />,
tabBarLabel: () => null,
}}
/>
<Tab.Screen
name="Talk"
component={TalkScreen}
options={{
title: 'TALK',
tabBarIcon: ({ color }) => <TabIcon type="talk" color={color} />,
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
title: 'SET',
tabBarIcon: ({ color }) => <TabIcon type="set" color={color} />,
}}
/>
</Tab.Navigator>
)
}
const iconStyles = StyleSheet.create({
grid: { width: 22, height: 22, flexDirection: 'row', flexWrap: 'wrap', gap: 2 },
gridCell: { width: 9, height: 9, borderWidth: 1.5, borderRadius: 2 },
circle: { width: 22, height: 22, borderRadius: 11, borderWidth: 1.5, justifyContent: 'center', alignItems: 'center' },
hand: { width: 1.5, height: 7, position: 'absolute', top: 3 },
chat: { width: 22, height: 18, borderWidth: 1.5, borderRadius: 4 },
gear: { width: 22, height: 22, borderWidth: 1.5, borderRadius: 11 },
})
const fabStyles = 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,
},
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 },
})

View file

@ -0,0 +1,198 @@
// apps/mobile-rn/src/screens/DashScreen.tsx
// Dashboard tab — session stats, usage, system status
// Converted from Expo → RN CLI
import React from 'react'
import { View, ScrollView, StyleSheet } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
MetalCard,
PhosphorText,
Led,
ScreenPanel,
AppStatusBar,
Header,
d3roNativePalette
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
export default function DashScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const { t } = useI18n()
return (
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 100 }]}
>
<Header title={t('mobile.dash.title')} showBorder={false} paddingTop={insets.top} />
{/* Session Overview — InsetPanel */}
<ScreenPanel style={styles.overviewPanel}>
<PhosphorText variant="small" color="muted" style={styles.sectionLabel}>
{t('mobile.dash.sessionOverview')}
</PhosphorText>
<View style={styles.bigNumber}>
<PhosphorText variant="hero" color="amber">0</PhosphorText>
<PhosphorText variant="body" color="muted" style={styles.todayLabel}>
{t('mobile.dash.today')}
</PhosphorText>
</View>
<PhosphorText variant="small" color="muted" style={styles.guideText}>
{t('mobile.dash.tapToRecord')}
</PhosphorText>
<View style={styles.overviewFooter}>
<View>
<PhosphorText variant="label" color="muted">{t('mobile.dash.words')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</View>
<View style={styles.alignEnd}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.streak')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</View>
</View>
</ScreenPanel>
{/* 2x2 Stats Grid */}
<View style={styles.statsGrid}>
<MetalCard style={styles.statCard}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.rec')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
<PhosphorText variant="label" color="muted">{t('mobile.dash.min')}</PhosphorText>
</MetalCard>
<MetalCard style={styles.statCard}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.words')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</MetalCard>
<MetalCard style={styles.statCard}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.today')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</MetalCard>
<MetalCard style={styles.statCard}>
<PhosphorText variant="label" color="muted">{t('mobile.dash.streak')}</PhosphorText>
<PhosphorText variant="value" color="amber">0</PhosphorText>
</MetalCard>
</View>
{/* Backend Info */}
<MetalCard style={styles.infoCard}>
<PhosphorText variant="small" color="muted">{t('mobile.dash.backend')}</PhosphorText>
<View style={styles.infoRow}>
<Led color="green" size={6} />
<PhosphorText variant="body" color="amber" style={styles.infoValue}>
CLOUD (CLAUDE)
</PhosphorText>
</View>
</MetalCard>
<MetalCard style={styles.infoCard}>
<PhosphorText variant="small" color="muted">{t('mobile.dash.tier')}</PhosphorText>
<View style={styles.infoRow}>
<Led color="amber" size={6} />
<PhosphorText variant="body" color="amber" style={styles.infoValue}>
{t('mobile.dash.free')}
</PhosphorText>
</View>
</MetalCard>
{/* Usage */}
<PhosphorText variant="label" color="muted" style={styles.usageTitle}>
{t('mobile.dash.usage')}
</PhosphorText>
<MetalCard style={styles.usageCard}>
<UsageRow label={t('mobile.dash.dictation')} value={t('mobile.dash.unlimited')} />
<View style={styles.usageDivider} />
<UsageRow label={t('mobile.dash.llmProcess')} value={t('mobile.dash.unlimited')} />
<View style={styles.usageDivider} />
<UsageRow label={t('mobile.dash.premiumQuota')} value="250 / 500" progress={0.5} />
</MetalCard>
<AppStatusBar />
</ScrollView>
)
}
function UsageRow({
label,
value,
progress
}: {
label: string
value: string
progress?: number
}): React.ReactElement {
return (
<View style={styles.usageRow}>
<PhosphorText variant="body" color="primary">{label}</PhosphorText>
<View style={styles.usageRight}>
<PhosphorText variant="small" color="amber">{value}</PhosphorText>
{progress != null && (
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
content: { paddingHorizontal: 20 },
overviewPanel: { marginBottom: 12 },
sectionLabel: { marginBottom: 16 },
bigNumber: { flexDirection: 'row', alignItems: 'baseline', marginBottom: 8 },
todayLabel: { marginLeft: 12 },
guideText: { marginBottom: 20 },
overviewFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
borderTopWidth: 1,
borderTopColor: d3roNativePalette.border.subtle,
paddingTop: 12
},
alignEnd: { alignItems: 'flex-end' },
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginBottom: 12
},
statCard: {
width: '47%' as unknown as number,
alignItems: 'center',
paddingVertical: 16,
gap: 4
},
infoCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
paddingVertical: 14
},
infoRow: { flexDirection: 'row', alignItems: 'center' },
infoValue: { marginLeft: 8 },
usageTitle: { letterSpacing: 3, marginBottom: 8, marginLeft: 4 },
usageCard: { marginBottom: 12, padding: 0, overflow: 'hidden' },
usageRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16
},
usageRight: { alignItems: 'flex-end', gap: 4 },
usageDivider: { height: 1, backgroundColor: d3roNativePalette.border.subtle },
progressBar: {
width: 80,
height: 4,
backgroundColor: d3roNativePalette.bg.inset,
borderRadius: 2,
overflow: 'hidden'
},
progressFill: {
height: '100%' as unknown as number,
backgroundColor: d3roNativePalette.accent.green,
borderRadius: 2
}
})

View file

@ -0,0 +1,257 @@
// apps/mobile-rn/src/screens/HistoryScreen.tsx
// History tab — transcription records list
// Converted from Expo → RN CLI
import React, { useEffect, useState, useCallback } from 'react'
import { View, FlatList, StyleSheet, ActivityIndicator, RefreshControl, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
MetalCard,
PhosphorText,
Led,
Header,
FilterChip,
AppStatusBar,
d3roNativePalette
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
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 { t, formatTime, formatRelativeDate } = useI18n()
const [entries, setEntries] = useState<HistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [filter, setFilter] = useState<FilterType>('all')
const load = useCallback(async (): 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()
}, [load])
// Group entries by date
const grouped = groupByDate(entries, formatRelativeDate)
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={d3roNativePalette.accent.amber} />
</View>
)
}
return (
<View style={styles.container}>
<Header
title={t('mobile.hist.title')}
rightContent={<Led color="green" size={6} />}
paddingTop={insets.top}
/>
{/* Filter Chips */}
<View style={styles.filters}>
<FilterChip
label={t('mobile.hist.all')}
active={filter === 'all'}
onPress={() => setFilter('all')}
/>
<FilterChip
label={t('mobile.hist.saved')}
active={filter === 'favorites'}
onPress={() => setFilter('favorites')}
/>
<FilterChip
label={t('mobile.hist.processing')}
active={filter === 'processing'}
onPress={() => setFilter('processing')}
/>
</View>
<FlatList
data={grouped}
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={styles.emptyText}>
{t('mobile.hist.empty')}
</PhosphorText>
}
renderItem={({ item }) => {
if (item.type === 'header') {
return (
<PhosphorText variant="label" color="muted" style={styles.dateHeader}>
{item.label}
</PhosphorText>
)
}
const entry = item.entry
const isOld = !isToday(entry.created_at)
return (
<Pressable>
<MetalCard style={[styles.card, isOld && styles.cardOld]}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderLeft}>
<Led
color={entry.status === 'completed' ? 'green' : 'amber'}
size={6}
on={!isOld}
/>
<PhosphorText variant="label" color="primary" style={styles.timeLabel}>
{formatTime(new Date(entry.created_at).getTime())}
</PhosphorText>
</View>
{entry.word_count != null && (
<View style={styles.wordBadge}>
<PhosphorText variant="label" color="amber">
{entry.word_count} W
</PhosphorText>
</View>
)}
</View>
<PhosphorText
variant="body"
color="primary"
style={styles.transcriptText}
numberOfLines={2}
>
{entry.polished_text ?? entry.original_text ?? '(empty)'}
</PhosphorText>
<View style={styles.cardMeta}>
<PhosphorText variant="label" color="muted">
{entry.stt_model?.toUpperCase() ?? 'LOCAL'}
</PhosphorText>
</View>
</MetalCard>
</Pressable>
)
}}
ListFooterComponent={<AppStatusBar />}
/>
</View>
)
}
// Helpers
interface GroupedItem {
id: string
type: 'header' | 'entry'
label?: string
entry: HistoryEntry
}
function isToday(dateStr: string): boolean {
const d = new Date(dateStr)
const now = new Date()
return d.toDateString() === now.toDateString()
}
function groupByDate(
entries: HistoryEntry[],
formatRelativeDate: (ts: number) => string
): GroupedItem[] {
const result: GroupedItem[] = []
let lastDate = ''
for (const entry of entries) {
const dateLabel = formatRelativeDate(new Date(entry.created_at).getTime())
if (dateLabel !== lastDate) {
result.push({
id: `header-${dateLabel}`,
type: 'header',
label: dateLabel,
entry
})
lastDate = dateLabel
}
result.push({ id: entry.id, type: 'entry', entry })
}
return result
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
backgroundColor: d3roNativePalette.bg.app
},
filters: {
flexDirection: 'row',
paddingHorizontal: 20,
paddingVertical: 12,
gap: 12,
borderBottomWidth: 1,
borderBottomColor: d3roNativePalette.border.subtle
},
listContent: { padding: 20, paddingBottom: 120 },
emptyText: { textAlign: 'center' },
dateHeader: {
letterSpacing: 3,
marginBottom: 8,
marginTop: 16
},
card: { marginBottom: 12 },
cardOld: { opacity: 0.7 },
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8
},
cardHeaderLeft: { flexDirection: 'row', alignItems: 'center' },
timeLabel: { marginLeft: 8 },
wordBadge: {
backgroundColor: d3roNativePalette.accent.amberDim,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4
},
transcriptText: { marginBottom: 8 },
cardMeta: { flexDirection: 'row', gap: 12 }
})

View file

@ -0,0 +1,334 @@
// apps/mobile-rn/src/screens/LoginScreen.tsx
// Login screen — D3RO styled
// Converted from Expo → RN CLI
// expo-web-browser → react-native-inappbrowser-reborn
// expo-linking → react-native Linking
// expo-router → @react-navigation/native useNavigation
import React, { useState } from 'react'
import { View, TextInput, StyleSheet, Alert, ScrollView, Pressable, Platform, Linking } from 'react-native'
import InAppBrowser from 'react-native-inappbrowser-reborn'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import {
d3roNativePalette,
d3roNativeFonts,
Led,
PhosphorText
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
import { supabase, isSupabaseConfigured } from '../lib/supabase'
import { useAuth } from '../lib/auth-context'
// Navigation type — matches RootStackParamList from navigation setup
type RootStackParamList = {
Login: undefined
Main: undefined
}
type LoginNavProp = NativeStackNavigationProp<RootStackParamList, 'Login'>
export default function LoginScreen(): React.ReactElement {
const { t } = useI18n()
const navigation = useNavigation<LoginNavProp>()
const [busy, setBusy] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const configured = isSupabaseConfigured()
const { devBypass } = useAuth()
function handleDevSkip(): void {
devBypass()
navigation.reset({ index: 0, routes: [{ name: 'Main' }] })
}
async function signInWithProvider(provider: 'google' | 'github' | 'apple'): Promise<void> {
if (!configured) {
Alert.alert('Not Configured', t('mobile.login.notConfigured'))
return
}
setBusy(true)
try {
const redirectTo = 'd3ro://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
}
if (await InAppBrowser.isAvailable()) {
const result = await InAppBrowser.openAuth(data.url, redirectTo, {
showTitle: false,
enableUrlBarHiding: true,
enableDefaultShare: false
})
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)
}
}
}
} else {
// Fallback: open in external browser
await Linking.openURL(data.url)
}
} finally {
setBusy(false)
}
}
async function signInWithEmail(): Promise<void> {
if (!configured) {
Alert.alert('Not Configured', t('mobile.login.notConfigured'))
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)
}
}
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>
<PhosphorText variant="title" color="primary" style={styles.titleText}>
{t('mobile.login.title')}
</PhosphorText>
<PhosphorText variant="label" color="muted" style={styles.subtitleText}>
{t('mobile.login.subtitle')}
</PhosphorText>
</View>
{/* OAuth Buttons */}
<View style={styles.oauthSection}>
<Pressable
style={({ pressed }) => [styles.oauthBtn, pressed && styles.oauthBtnPressed]}
onPress={() => void signInWithProvider('google')}
disabled={busy}
>
<PhosphorText variant="body" color="primary">G</PhosphorText>
<PhosphorText variant="body" color="primary" style={styles.oauthLabel}>
{t('mobile.login.google')}
</PhosphorText>
</Pressable>
<Pressable
style={({ pressed }) => [styles.oauthBtn, pressed && styles.oauthBtnPressed]}
onPress={() => void signInWithProvider('apple')}
disabled={busy}
>
<PhosphorText variant="body" color="primary" style={styles.appleIcon}>
{'\uF8FF'}
</PhosphorText>
<PhosphorText variant="body" color="primary" style={styles.oauthLabel}>
{t('mobile.login.apple')}
</PhosphorText>
</Pressable>
<Pressable
style={({ pressed }) => [styles.oauthBtn, pressed && styles.oauthBtnPressed]}
onPress={() => void signInWithProvider('github')}
disabled={busy}
>
<PhosphorText variant="body" color="primary">{'\u2318'}</PhosphorText>
<PhosphorText variant="body" color="primary" style={styles.oauthLabel}>
{t('mobile.login.github')}
</PhosphorText>
</Pressable>
</View>
{/* Divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<PhosphorText variant="label" color="muted" style={styles.dividerText}>
{t('mobile.login.or')}
</PhosphorText>
<View style={styles.dividerLine} />
</View>
{/* Email/Password Fields */}
<View style={styles.formSection}>
<View>
<PhosphorText variant="label" color="muted" style={styles.fieldLabel}>
{t('mobile.login.email')}
</PhosphorText>
<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>
<PhosphorText variant="label" color="muted" style={styles.fieldLabel}>
{t('mobile.login.password')}
</PhosphorText>
<TextInput
style={styles.input}
placeholder={'\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
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}
>
<PhosphorText variant="heading" style={styles.loginBtnText}>
{t('mobile.login.signIn')}
</PhosphorText>
</Pressable>
{/* Sign Up Link */}
<View style={styles.signupRow}>
<PhosphorText variant="small" color="muted">
{t('mobile.login.noAccount')}
</PhosphorText>
<Pressable>
<PhosphorText variant="small" color="amber">
{t('mobile.login.signUp')}
</PhosphorText>
</Pressable>
</View>
{/* DEV Skip */}
{__DEV__ && (
<Pressable style={styles.devSkipBtn} onPress={handleDevSkip}>
<PhosphorText variant="label" style={styles.devSkipText}>
{'\u26A1'} DEV SKIP LOGIN
</PhosphorText>
</Pressable>
)}
{/* Supabase Warning */}
{!configured && (
<View style={styles.warningBox}>
<PhosphorText variant="label" color="amber" style={styles.warningText}>
{'\u26A0'} {t('mobile.login.notConfigured')}
</PhosphorText>
</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: { alignItems: 'center', marginBottom: 40 },
leds: { flexDirection: 'row', gap: 10, marginBottom: 20 },
titleText: { letterSpacing: 5 },
subtitleText: { marginTop: 8, letterSpacing: 2 },
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)' },
oauthLabel: { fontFamily: d3roNativeFonts.sans },
appleIcon: { fontSize: 18 },
divider: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 24 },
dividerLine: { flex: 1, height: 1, backgroundColor: P.border.default },
dividerText: { letterSpacing: 2 },
formSection: { gap: 16, marginBottom: 24 },
fieldLabel: { 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'
},
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: { color: P.bg.app, letterSpacing: 1 },
signupRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 6,
marginBottom: 16
},
warningBox: {
borderWidth: 1,
borderColor: 'rgba(255,92,53,0.3)',
backgroundColor: P.accent.amberDim,
padding: 12,
borderRadius: 8,
marginTop: 8
},
warningText: { textAlign: 'center' },
devSkipBtn: {
borderWidth: 1,
borderColor: P.accent.green,
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
marginBottom: 12,
backgroundColor: 'rgba(74, 222, 128, 0.08)'
},
devSkipText: { color: P.accent.green, letterSpacing: 2 }
})

View file

@ -0,0 +1,303 @@
// apps/mobile-rn/src/screens/RecordScreen.tsx
// Recording tab — audio recording + STT pipeline
// Converted from Expo → RN CLI
// TODO: Replace expo-av with react-native-audio-recorder-player
import React, { useState, useRef } from 'react'
import { View, StyleSheet, ActivityIndicator, ScrollView } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
MetalCard,
PhosphorText,
PhysicalButton,
Led,
Header,
WaveBars,
AppStatusBar,
d3roNativePalette,
d3roNativeFonts
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
import { supabase, isSupabaseConfigured } from '../lib/supabase'
type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error'
// ----- Audio Recording Placeholder -----
// expo-av has been removed. The following interface stubs the recorder
// until react-native-audio-recorder-player is integrated.
interface AudioRecorderPlaceholder {
startRecording: () => Promise<void>
stopRecording: () => Promise<string | null>
cancelRecording: () => Promise<void>
requestPermission: () => Promise<boolean>
}
function useAudioRecorderPlaceholder(): AudioRecorderPlaceholder {
return {
requestPermission: async () => {
// TODO: implement PermissionsAndroid / native permission request
return true
},
startRecording: async () => {
// TODO: AudioRecorderPlayer.startRecorder()
},
stopRecording: async () => {
// TODO: AudioRecorderPlayer.stopRecorder() → returns uri
return null
},
cancelRecording: async () => {
// TODO: AudioRecorderPlayer.stopRecorder() + delete temp file
}
}
}
// ----- End Audio Recording Placeholder -----
export default function RecordScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const { t } = useI18n()
const [state, setState] = useState<RecordingState>('idle')
const [transcript, setTranscript] = useState<string>('')
const [error, setError] = useState<string | null>(null)
const [duration, setDuration] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const recorder = useAudioRecorderPlaceholder()
async function startRecording(): Promise<void> {
setError(null)
setTranscript('')
setDuration(0)
try {
const granted = await recorder.requestPermission()
if (!granted) {
setError(t('mobile.rec.micDenied'))
setState('error')
return
}
await recorder.startRecording()
setState('recording')
timerRef.current = setInterval(() => {
setDuration((d) => d + 1)
}, 1000)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
setState('error')
}
}
async function stopRecording(): Promise<void> {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
setState('processing')
try {
const uri = await recorder.stopRecording()
if (!uri) throw new Error('No recording URI')
if (!isSupabaseConfigured()) {
setError(t('mobile.rec.supabaseNotConfigured'))
setState('error')
return
}
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
setError(t('mobile.rec.loginRequired'))
setState('error')
return
}
const formData = new FormData()
formData.append('audio', {
uri,
name: 'recording.m4a',
type: 'audio/m4a'
} as unknown as Blob)
formData.append('language_code', 'ko-KR')
// supabase URL is managed in lib/supabase — get base URL from client
const supabaseUrl = (supabase as unknown as { supabaseUrl: string }).supabaseUrl ?? ''
const response = await fetch(`${supabaseUrl}/functions/v1/stt-proxy`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.access_token}` },
body: formData
})
if (!response.ok) throw new Error(`STT failed: ${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 (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
try { await recorder.cancelRecording() } catch { /* ignore */ }
setState('idle')
setTranscript('')
setError(null)
setDuration(0)
}
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')}`
}
const statusLabel =
state === 'recording' ? t('mobile.rec.recording')
: state === 'processing' ? t('mobile.rec.processing')
: state === 'done' ? t('mobile.rec.done')
: state === 'error' ? t('mobile.rec.error')
: t('mobile.rec.ready')
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Header
title={state === 'recording' ? t('mobile.rec.session') : t('mobile.rec.title')}
paddingTop={insets.top}
rightContent={
state === 'recording' ? (
<View style={styles.headerRight}>
<PhosphorText variant="label" color="amber">
{formatTime(duration)}
</PhosphorText>
<Led color="green" size={6} />
</View>
) : undefined
}
/>
{/* Wave Bars */}
<WaveBars active={state === 'recording'} />
{/* Transcript Area */}
<View style={styles.transcriptArea}>
{state === 'idle' && (
<PhosphorText
variant="small"
color="muted"
style={styles.initLog}
>
{t('mobile.rec.initLog')}
</PhosphorText>
)}
{state === 'processing' && (
<ActivityIndicator size="large" color={d3roNativePalette.accent.amber} />
)}
{transcript !== '' && state === 'done' && (
<View style={styles.transcriptBox}>
<PhosphorText variant="body" color="primary">
{transcript}
</PhosphorText>
</View>
)}
{error !== null && (
<View style={styles.errorBox}>
<PhosphorText variant="small" color="label">
{error}
</PhosphorText>
</View>
)}
</View>
{/* Status Line */}
{state === 'recording' && (
<View style={styles.listeningRow}>
<Led color="amber" size={6} />
<PhosphorText variant="label" color="amber" style={styles.listeningText}>
{t('mobile.rec.listening')}
</PhosphorText>
<PhosphorText variant="label" color="muted">
{t('mobile.rec.realtime')}
</PhosphorText>
</View>
)}
{/* Buttons */}
<View style={styles.buttons}>
{(state === 'idle' || state === 'done' || state === 'error') && (
<PhysicalButton
label={state === 'done' ? t('mobile.rec.newRecording') : t('mobile.rec.start')}
variant="primary"
onPress={() => void startRecording()}
/>
)}
{state === 'recording' && (
<>
<PhysicalButton
label={t('mobile.rec.stop')}
variant="danger"
onPress={() => void stopRecording()}
/>
<View style={styles.buttonSpacer} />
<PhysicalButton
label={t('mobile.rec.cancel')}
variant="secondary"
onPress={() => void cancelRecording()}
/>
</>
)}
</View>
<AppStatusBar />
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
content: { paddingBottom: 100 },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
transcriptArea: {
minHeight: 160,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 24
},
initLog: {
fontFamily: d3roNativeFonts.mono,
textAlign: 'center'
},
transcriptBox: {
backgroundColor: d3roNativePalette.bg.inset,
padding: 12,
borderRadius: 8,
width: '100%'
},
errorBox: {
borderWidth: 1,
borderColor: d3roNativePalette.accent.amber,
backgroundColor: d3roNativePalette.accent.amberDim,
padding: 10,
borderRadius: 6,
width: '100%'
},
listeningRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingBottom: 16
},
listeningText: { letterSpacing: 2 },
buttons: { paddingHorizontal: 20, paddingBottom: 16 },
buttonSpacer: { height: 12 }
})

View file

@ -0,0 +1,210 @@
// apps/mobile-rn/src/screens/SettingsScreen.tsx
// Settings tab — account, backend, preferences
// Converted from Expo → RN CLI
import React from 'react'
import { View, ScrollView, StyleSheet, Alert, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
MetalCard,
PhosphorText,
PhysicalButton,
Led,
AppStatusBar,
d3roNativePalette
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
import { useAuth } from '../lib/auth-context'
import { supabase } from '../lib/supabase'
export default function SettingsScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const { t } = useI18n()
const { user } = useAuth()
async function handleLogout(): Promise<void> {
Alert.alert(t('mobile.set.logout'), t('mobile.set.logoutConfirm'), [
{ text: t('mobile.rec.cancel'), style: 'cancel' },
{
text: t('mobile.set.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}>
<PhosphorText
variant="title"
color="primary"
style={[styles.title, { paddingTop: insets.top + 8 }]}
>
{t('mobile.set.title')}
</PhosphorText>
{/* Account Section */}
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
{t('mobile.set.accountPlan')}
</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 ?? '\u2014'}
</PhosphorText>
</View>
</View>
<View style={[styles.row, styles.rowInset]}>
<PhosphorText variant="small" color="muted">{t('mobile.dash.tier')}</PhosphorText>
<View style={styles.rowRight}>
<Led color="amber" size={6} />
<PhosphorText variant="body" color="amber" style={styles.rowValue}>
{t('mobile.dash.free')}
</PhosphorText>
</View>
</View>
</MetalCard>
{/* Backend Section */}
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
{t('mobile.set.backendConfig')}
</PhosphorText>
<MetalCard style={styles.section}>
<View style={styles.row}>
<View>
<PhosphorText variant="body" color="primary">{t('mobile.set.llmModel')}</PhosphorText>
<PhosphorText variant="label" color="muted">{t('mobile.set.llmDesc')}</PhosphorText>
</View>
<PhosphorText variant="body" color="amber">CLAUDE</PhosphorText>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View>
<PhosphorText variant="body" color="primary">{t('mobile.set.cloudStt')}</PhosphorText>
<PhosphorText variant="label" color="muted">{t('mobile.set.cloudSttDesc')}</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}>
{t('mobile.set.preferences')}
</PhosphorText>
<MetalCard style={styles.section}>
<View style={styles.row}>
<PhosphorText variant="body" color="primary">{t('mobile.set.language')}</PhosphorText>
<PhosphorText variant="body" color="muted">Korean</PhosphorText>
</View>
<View style={[styles.row, styles.rowBorder]}>
<PhosphorText variant="body" color="primary">{t('mobile.set.autoPolish')}</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">{t('mobile.set.haptic')}</PhosphorText>
<Switch
value={true}
trackColor={{
false: d3roNativePalette.bg.inset,
true: d3roNativePalette.accent.amber
}}
thumbColor="#ffffff"
/>
</View>
</MetalCard>
{/* Logout */}
<View style={styles.logoutWrap}>
<PhysicalButton
label={t('mobile.set.logout')}
variant="secondary"
onPress={() => void handleLogout()}
/>
</View>
{/* Status Footer */}
<AppStatusBar />
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
content: { paddingBottom: 120 },
title: {
paddingHorizontal: 20,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: d3roNativePalette.border.subtle
},
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: d3roNativePalette.border.subtle
},
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
},
rowInset: { backgroundColor: d3roNativePalette.bg.inset },
rowBorder: {
borderTopWidth: 1,
borderTopColor: d3roNativePalette.border.subtle
},
rowRight: { flexDirection: 'row', alignItems: 'center' },
rowValue: { marginLeft: 8 },
logoutWrap: { margin: 20 }
})

View file

@ -0,0 +1,224 @@
// apps/mobile-rn/src/screens/TalkScreen.tsx
// AI Chat tab — text+voice chat
// Converted from Expo → RN CLI
import React, { useState, useRef } from 'react'
import {
View,
ScrollView,
TextInput,
StyleSheet,
Pressable,
KeyboardAvoidingView,
Platform
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
PhosphorText,
Led,
Header,
AppStatusBar,
d3roNativePalette,
d3roNativeFonts
} from '@d3ro/ui-native'
import { useI18n } from '@d3ro/i18n'
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
}
export default function TalkScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const { t } = useI18n()
const scrollRef = useRef<ScrollView>(null)
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: '1',
role: 'assistant',
content: t('mobile.talk.greeting')
}
])
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: actual AI response
setTimeout(() => {
scrollRef.current?.scrollToEnd({ animated: true })
}, 100)
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={80}
>
<Header
title={t('mobile.talk.title')}
paddingTop={insets.top}
rightContent={
<View style={styles.headerRight}>
<PhosphorText variant="label" color="amber">{t('mobile.talk.live')}</PhosphorText>
<Led color="green" size={6} />
</View>
}
/>
{/* Chat Messages */}
<ScrollView
ref={scrollRef}
style={styles.chatArea}
contentContainerStyle={styles.chatContent}
>
{/* Date Badge */}
<View style={styles.dateBadge}>
<PhosphorText variant="label" color="muted">
{new Date().toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
}).toUpperCase()}
</PhosphorText>
</View>
{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={styles.bubbleText}
>
{msg.content}
</PhosphorText>
<PhosphorText variant="label" color="muted" style={styles.bubbleLabel}>
{msg.role === 'user' ? t('mobile.talk.you') : t('mobile.talk.claude')}
</PhosphorText>
</View>
))}
</ScrollView>
{/* Input Area */}
<View style={styles.inputArea}>
<View style={styles.inputRow}>
<TextInput
style={styles.textInput}
placeholder={t('mobile.talk.placeholder')}
placeholderTextColor={d3roNativePalette.text.muted}
value={input}
onChangeText={setInput}
onSubmitEditing={handleSend}
returnKeyType="send"
/>
<Pressable style={styles.sendBtn} onPress={handleSend}>
<View style={styles.sendArrow} />
</Pressable>
</View>
<AppStatusBar style={styles.inputStatus} />
</View>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
chatArea: { flex: 1 },
chatContent: { padding: 20, paddingBottom: 20, gap: 16 },
dateBadge: {
alignSelf: 'center',
backgroundColor: d3roNativePalette.bg.card,
borderWidth: 1,
borderColor: d3roNativePalette.border.default,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 4,
marginBottom: 8
},
bubble: {
maxWidth: '85%',
padding: 16,
borderRadius: 16,
marginBottom: 20
},
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
},
bubbleText: { fontFamily: d3roNativeFonts.sans },
bubbleLabel: {
position: 'absolute',
bottom: -16,
fontSize: 9,
letterSpacing: 1
},
inputArea: {
backgroundColor: 'rgba(25, 25, 27, 0.95)',
borderTopWidth: 1,
borderTopColor: d3roNativePalette.border.default,
paddingHorizontal: 12,
paddingTop: 12,
paddingBottom: 8
},
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,
fontFamily: d3roNativeFonts.sans
},
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
},
inputStatus: { paddingVertical: 4 }
})