// apps/mobile/app/(tabs)/record.tsx // Recording tab — expo-av + STT pipeline // Design ref: 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 { 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' export default function RecordScreen(): React.ReactElement { const insets = useSafeAreaInsets() const { t } = useI18n() const [state, setState] = useState('idle') const [transcript, setTranscript] = useState('') const [error, setError] = useState(null) const [duration, setDuration] = useState(0) const recordingRef = useRef(null) const timerRef = useRef | null>(null) async function startRecording(): Promise { setError(null) setTranscript('') setDuration(0) try { const perm = await Audio.requestPermissionsAsync() if (perm.status !== 'granted') { setError(t('mobile.rec.micDenied')) 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') 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 { if (!recordingRef.current) return if (timerRef.current) { clearInterval(timerRef.current) timerRef.current = null } setState('processing') try { await recordingRef.current.stopAndUnloadAsync() const uri = recordingRef.current.getURI() recordingRef.current = null 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') 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 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 { if (timerRef.current) { clearInterval(timerRef.current) timerRef.current = null } if (recordingRef.current) { try { await recordingRef.current.stopAndUnloadAsync() } catch { /* ignore */ } recordingRef.current = null } 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 (
{formatTime(duration)} ) : undefined } /> {/* Wave Bars */} {/* Transcript Area */} {state === 'idle' && ( {t('mobile.rec.initLog')} )} {state === 'processing' && ( )} {transcript !== '' && state === 'done' && ( {transcript} )} {error !== null && ( {error} )} {/* Status Line */} {state === 'recording' && ( {t('mobile.rec.listening')} {t('mobile.rec.realtime')} )} {/* Buttons */} {(state === 'idle' || state === 'done' || state === 'error') && ( void startRecording()} /> )} {state === 'recording' && ( <> void stopRecording()} /> void cancelRecording()} /> )} ) } 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 } })