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:
parent
ffca07d120
commit
211673bc6c
30 changed files with 15010 additions and 525 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue