Phase 9 완료: 전체 남은 기능 구현

1. RecordingTip: thinking 상태 전환 시 opacity 보장
2. 히스토리 오디오 경로: WAV 파일 경로를 DB에 기록
3. 마이크 테스트 UI: Settings Audio 탭에 TEST 버튼 + 레벨 바
4. 온보딩 플로우: 첫 실행 시 3단계 안내 (환영→마이크→핫키→완료)
5. MetalDial 컴포넌트: 동심원 그루브, 회전 포인터, LED 인디케이터
This commit is contained in:
Yun Chan 2026-04-05 12:16:39 +09:00
parent d7074e359d
commit c338a19c90
7 changed files with 431 additions and 4 deletions

View file

@ -0,0 +1,231 @@
// src/renderer/components/OnboardingModal.tsx
// 첫 실행 시 마이크 + 핫키 설정 안내
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
Box,
Typography,
Button,
Stack,
Chip,
} from '@mui/material'
import MicIcon from '@mui/icons-material/Mic'
import KeyboardIcon from '@mui/icons-material/Keyboard'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import { d3roPalette, d3roFontMono } from '../theme'
import { Led } from './ds'
import { HotkeyRecordModal } from './HotkeyRecordModal'
import type { HotkeyBinding, AudioDevice } from '@shared/types'
interface OnboardingModalProps {
open: boolean
onClose: () => void
}
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
const [step, setStep] = useState(0) // 0: 환영, 1: 마이크, 2: 핫키, 3: 완료
const [devices, setDevices] = useState<AudioDevice[]>([])
const [selectedDevice, setSelectedDevice] = useState('default')
const [hotkeyBinding, setHotkeyBinding] = useState<HotkeyBinding | null>(null)
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
useEffect(() => {
if (!open) return
setStep(0)
window.electronAPI.audio.getDevices().then((r) => {
if (r.success) setDevices(r.data)
})
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
if (r.success && r.data) setHotkeyBinding(r.data)
})
}, [open])
const handleFinish = () => {
// 온보딩 완료 플래그 저장
window.electronAPI.config.set({ key: 'onboardingCompleted' as keyof import('@shared/types').AppConfig, value: true as never })
onClose()
}
const handleHotkeySave = (binding: HotkeyBinding) => {
setHotkeyBinding(binding)
window.electronAPI.hotkey.setDictationShortcut({ binding })
window.electronAPI.hotkey.setEnabled({ enabled: true })
}
return (
<>
<Dialog
open={open}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.chassis,
backgroundImage: 'none',
border: `1px solid ${d3roPalette.border.subtle}`,
boxShadow: '0 40px 80px rgba(0,0,0,0.8)',
},
}}
>
<DialogContent sx={{ p: 4 }}>
{/* Step 0: 환영 */}
{step === 0 && (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Led color="amber" pulse size={16} />
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '24px',
fontWeight: 300,
color: d3roPalette.accent.amber,
mt: 3,
mb: 1,
}}
>
D3RO-VOICE
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
, . AI .
</Typography>
<Button variant="contained" onClick={() => setStep(1)} fullWidth>
</Button>
</Box>
)}
{/* Step 1: 마이크 */}
{step === 1 && (
<Box>
<Stack direction="row" alignItems="center" gap={1} mb={3}>
<MicIcon sx={{ color: d3roPalette.accent.amber }} />
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
. .
</Typography>
<Stack spacing={1} mb={3}>
{devices.map((d) => (
<Box
key={d.deviceId}
onClick={() => {
setSelectedDevice(d.deviceId)
window.electronAPI.audio.setSelectedDevice({ deviceId: d.deviceId })
}}
sx={{
p: 1.5,
borderRadius: '8px',
cursor: 'pointer',
bgcolor: selectedDevice === d.deviceId ? d3roPalette.accent.amberDim : d3roPalette.bg.inset,
border: selectedDevice === d.deviceId
? `1px solid ${d3roPalette.accent.amber}`
: `1px solid ${d3roPalette.border.subtle}`,
'&:hover': { bgcolor: d3roPalette.bg.cardHover },
}}
>
<Typography variant="body2" sx={{ fontSize: '13px' }}>
{d.label}{d.isDefault ? ' (기본)' : ''}
</Typography>
</Box>
))}
</Stack>
<Stack direction="row" justifyContent="space-between">
<Button onClick={() => setStep(0)} sx={{ color: d3roPalette.text.inactive }}></Button>
<Button variant="contained" onClick={() => setStep(2)}></Button>
</Stack>
</Box>
)}
{/* Step 2: 핫키 */}
{step === 2 && (
<Box>
<Stack direction="row" alignItems="center" gap={1} mb={3}>
<KeyboardIcon sx={{ color: d3roPalette.accent.amber }} />
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
. .
</Typography>
<Box
sx={{
p: 2,
borderRadius: '10px',
bgcolor: d3roPalette.bg.inset,
boxShadow: 'inset 0 2px 6px rgba(0,0,0,0.4)',
textAlign: 'center',
mb: 3,
}}
>
{hotkeyBinding ? (
<Stack direction="row" spacing={1} justifyContent="center" alignItems="center">
<Led color="green" size={8} />
{hotkeyBinding.displayLabel.split(' + ').map((key) => (
<Chip
key={key}
label={key}
sx={{
fontFamily: d3roFontMono,
fontWeight: 700,
bgcolor: d3roPalette.bg.chassis,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
}}
/>
))}
</Stack>
) : (
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '13px' }}>
</Typography>
)}
</Box>
<Button
variant="outlined"
fullWidth
onClick={() => setHotkeyModalOpen(true)}
sx={{ mb: 3, fontFamily: d3roFontMono }}
>
{hotkeyBinding ? '단축키 변경' : '단축키 설정'}
</Button>
<Stack direction="row" justifyContent="space-between">
<Button onClick={() => setStep(1)} sx={{ color: d3roPalette.text.inactive }}></Button>
<Button variant="contained" onClick={() => setStep(3)}></Button>
</Stack>
</Box>
)}
{/* Step 3: 완료 */}
{step === 3 && (
<Box sx={{ textAlign: 'center', py: 3 }}>
<CheckCircleIcon sx={{ fontSize: 48, color: d3roPalette.tag.green, mb: 2 }} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '18px', fontWeight: 700, mb: 1 }}>
!
</Typography>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
{hotkeyBinding
? `${hotkeyBinding.displayLabel} 키를 누르고 말하면 음성이 텍스트로 변환됩니다.`
: '설정에서 단축키를 지정하면 음성 받아쓰기를 시작할 수 있습니다.'}
</Typography>
<Button variant="contained" onClick={handleFinish} fullWidth>
</Button>
</Box>
)}
</DialogContent>
</Dialog>
<HotkeyRecordModal
open={hotkeyModalOpen}
onClose={() => setHotkeyModalOpen(false)}
onSave={handleHotkeySave}
currentBinding={hotkeyBinding}
title="받아쓰기 단축키 설정"
/>
</>
)
}