feat(mobile): Phase M-2 D3RO 디자인 시스템 모바일 적용

DS 컴포넌트 9개 (ScreenPanel, WaveBars, AppStatusBar, Header, FilterChip 신규),
5개 탭 + 로그인 화면 D3RO 인스트루먼트 미학 적용,
i18n 연결 (I18nProvider + 모바일 전용 키 80+개),
모노레포 workspaces에 apps/mobile 추가.
This commit is contained in:
yunchan8804 2026-04-13 17:32:31 +09:00
parent 541f04d02b
commit 55eb62189a
19 changed files with 10435 additions and 1073 deletions

View file

@ -1,6 +1,6 @@
// apps/mobile/app/(tabs)/record.tsx
// 녹음 탭 — expo-av 기반 녹음 + STT 파이프라인
// Phase M-2에서 디자인(docs/v3/designs/recording.html) 정밀 적용
// 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'
@ -12,14 +12,20 @@ import {
PhosphorText,
PhysicalButton,
Led,
d3roNativePalette
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<RecordingState>('idle')
const [transcript, setTranscript] = useState<string>('')
const [error, setError] = useState<string | null>(null)
@ -35,7 +41,7 @@ export default function RecordScreen(): React.ReactElement {
try {
const perm = await Audio.requestPermissionsAsync()
if (perm.status !== 'granted') {
setError('Microphone permission denied')
setError(t('mobile.rec.micDenied'))
setState('error')
return
}
@ -73,19 +79,17 @@ export default function RecordScreen(): React.ReactElement {
const uri = recordingRef.current.getURI()
recordingRef.current = null
if (!uri) {
throw new Error('No recording URI')
}
if (!uri) throw new Error('No recording URI')
if (!isSupabaseConfigured()) {
setError('Supabase not configured')
setError(t('mobile.rec.supabaseNotConfigured'))
setState('error')
return
}
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
setError('Login required')
setError(t('mobile.rec.loginRequired'))
setState('error')
return
}
@ -105,9 +109,7 @@ export default function RecordScreen(): React.ReactElement {
body: formData
})
if (!response.ok) {
throw new Error(`STT failed: ${response.status}`)
}
if (!response.ok) throw new Error(`STT failed: ${response.status}`)
const result = (await response.json()) as { transcript: string }
setTranscript(result.transcript)
@ -139,106 +141,106 @@ export default function RecordScreen(): React.ReactElement {
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 */}
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
<View style={styles.headerLeft}>
<Led color="amber" size={8} on={state === 'recording'} />
<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="label"
color={state === 'recording' ? 'amber' : 'muted'}
style={{ letterSpacing: 3 }}
variant="small"
color="muted"
style={styles.initLog}
>
{state === 'recording' ? 'REC_SESSION' : 'RECORDER'}
</PhosphorText>
</View>
{state === 'recording' && (
<PhosphorText variant="label" color="amber">
{formatTime(duration)}
{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>
{/* Wave Bars Placeholder */}
{/* Status Line */}
{state === 'recording' && (
<View style={styles.waveContainer}>
{Array.from({ length: 11 }).map((_, i) => (
<View
key={i}
style={[
styles.waveBar,
{ height: 8 + Math.random() * 24 }
]}
/>
))}
<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>
)}
<MetalCard style={styles.card}>
<View style={styles.statusRow}>
<Led
color={
state === 'recording' ? 'red'
: state === 'processing' ? 'orange'
: state === 'done' ? 'green'
: 'amber'
}
size={10}
on={state !== 'idle'}
{/* 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()}
/>
<PhosphorText variant="label" color="label" style={styles.statusLabel}>
{state === 'recording' ? 'RECORDING'
: state === 'processing' ? 'PROCESSING'
: state === 'done' ? 'DONE'
: state === 'error' ? 'ERROR'
: 'READY'}
</PhosphorText>
</View>
<View style={styles.center}>
{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 && (
<View style={styles.errorBox}>
<PhosphorText variant="small" color="label">
{error}
</PhosphorText>
</View>
)}
</View>
<View style={styles.buttons}>
{(state === 'idle' || state === 'done' || state === 'error') && (
)}
{state === 'recording' && (
<>
<PhysicalButton
label={state === 'done' ? 'NEW RECORDING' : 'START RECORDING'}
variant="primary"
onPress={() => void startRecording()}
label={t('mobile.rec.stop')}
variant="danger"
onPress={() => void stopRecording()}
/>
)}
{state === 'recording' && (
<>
<PhysicalButton label="STOP" variant="danger" onPress={() => void stopRecording()} />
<View style={{ height: 12 }} />
<PhysicalButton
label="CANCEL"
variant="secondary"
onPress={() => void cancelRecording()}
/>
</>
)}
</View>
</MetalCard>
<View style={styles.buttonSpacer} />
<PhysicalButton
label={t('mobile.rec.cancel')}
variant="secondary"
onPress={() => void cancelRecording()}
/>
</>
)}
</View>
<AppStatusBar />
</ScrollView>
)
}
@ -246,49 +248,40 @@ export default function RecordScreen(): React.ReactElement {
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: d3roNativePalette.bg.app },
content: { paddingBottom: 100 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
transcriptArea: {
minHeight: 160,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(46, 46, 50, 0.5)'
paddingVertical: 24
},
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)'
initLog: {
fontFamily: d3roNativeFonts.mono,
textAlign: 'center'
},
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 },
transcriptBox: {
backgroundColor: d3roNativePalette.bg.inset,
padding: 12,
borderRadius: 8,
width: '100%',
marginTop: 8
width: '100%'
},
errorBox: {
borderWidth: 1,
borderColor: d3roNativePalette.tag.red,
borderColor: d3roNativePalette.accent.amber,
backgroundColor: d3roNativePalette.accent.amberDim,
padding: 10,
borderRadius: 6,
width: '100%',
marginTop: 8
width: '100%'
},
buttons: { marginTop: 16 }
listeningRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingBottom: 16
},
listeningText: { letterSpacing: 2 },
buttons: { paddingHorizontal: 20, paddingBottom: 16 },
buttonSpacer: { height: 12 }
})