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:
parent
541f04d02b
commit
55eb62189a
19 changed files with 10435 additions and 1073 deletions
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue