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

@ -1,7 +1,7 @@
// src/renderer/components/AppLayout.tsx
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { Box, Typography, Tooltip } from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import HistoryIcon from '@mui/icons-material/History'
@ -14,6 +14,7 @@ import { HistoryPage } from '../pages/HistoryPage'
import { DictionaryPage } from '../pages/DictionaryPage'
import { CommandsPage } from '../pages/CommandsPage'
import { SettingsModal } from './SettingsModal'
import { OnboardingModal } from './OnboardingModal'
import { StatusBar } from './StatusBar'
import { d3roPalette, d3roFontMono } from '../theme'
@ -29,6 +30,19 @@ const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }
export function AppLayout(): React.ReactElement {
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
const [settingsOpen, setSettingsOpen] = useState(false)
const [onboardingOpen, setOnboardingOpen] = useState(false)
// 첫 실행 감지
useEffect(() => {
window.electronAPI.config.getAll().then((r) => {
if (r.success) {
const cfg = r.data as Record<string, unknown>
if (!cfg['onboardingCompleted']) {
setOnboardingOpen(true)
}
}
})
}, [])
return (
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
@ -161,6 +175,7 @@ export function AppLayout(): React.ReactElement {
</Box>
<StatusBar />
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
</Box>
)
}

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="받아쓰기 단축키 설정"
/>
</>
)
}

View file

@ -191,6 +191,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
// 오디오 디바이스
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('default')
const [micTesting, setMicTesting] = useState(false)
const [micLevel, setMicLevel] = useState(0)
// 설정 로드
useEffect(() => {
@ -500,6 +502,58 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
</Select>
</FormControl>
{/* 마이크 테스트 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant={micTesting ? 'contained' : 'outlined'}
size="small"
onClick={() => {
if (micTesting) {
setMicTesting(false)
setMicLevel(0)
} else {
setMicTesting(true)
// 3초 후 자동 종료
const unsub = window.electronAPI.voice.onAudioLevel((e) => {
setMicLevel(e.level)
})
setTimeout(() => {
setMicTesting(false)
setMicLevel(0)
unsub()
}, 5000)
}
}}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
minWidth: 80,
}}
>
{micTesting ? 'STOP' : 'TEST'}
</Button>
<Box
sx={{
flex: 1,
height: 8,
bgcolor: d3roPalette.bg.inset,
borderRadius: '4px',
overflow: 'hidden',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.4)',
}}
>
<Box
sx={{
width: `${Math.min(100, micLevel * 100)}%`,
height: '100%',
bgcolor: micLevel > 0.7 ? d3roPalette.tag.red : d3roPalette.accent.amber,
borderRadius: '4px',
transition: 'width 100ms ease-out',
}}
/>
</Box>
</Box>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>

View file

@ -0,0 +1,119 @@
// src/renderer/components/ds/MetalDial.tsx
// 시안 A: 메탈 다이얼 — 정밀기기 회전 노브, 동심원 그루브, LED 인디케이터
import { Box, Typography } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
interface MetalDialProps {
/** 0.0 ~ 1.0 값 (다이얼 위치) */
value?: number
/** 라벨 텍스트 */
label?: string
/** 크기 (px) */
size?: number
/** LED 인디케이터 색상 */
ledColor?: string
}
export function MetalDial({
value = 0,
label,
size = 120,
ledColor = d3roPalette.accent.amber,
}: MetalDialProps): React.ReactElement {
const rotation = value * 270 - 135 // -135° ~ +135° 범위
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
{/* 다이얼 외곽 */}
<Box
sx={{
width: size,
height: size,
borderRadius: '50%',
bgcolor: d3roPalette.bg.chassis,
boxShadow: `
0 4px 12px rgba(0,0,0,0.5),
inset 0 2px 4px rgba(255,255,255,0.08),
inset 0 -2px 4px rgba(0,0,0,0.3)
`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
{/* 동심원 그루브 (CSS로 시뮬레이션) */}
<Box
sx={{
width: size - 16,
height: size - 16,
borderRadius: '50%',
background: `
repeating-radial-gradient(
circle at center,
${d3roPalette.bg.chassis} 0px,
${d3roPalette.bg.card} 1px,
${d3roPalette.bg.chassis} 2px
)
`,
boxShadow: `
inset 0 1px 3px rgba(0,0,0,0.6),
0 1px 1px rgba(255,255,255,0.05)
`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: `rotate(${rotation}deg)`,
transition: 'transform 0.2s ease-out',
}}
>
{/* 포인터 인디케이터 */}
<Box
sx={{
position: 'absolute',
top: 8,
left: '50%',
transform: 'translateX(-50%)',
width: 4,
height: 4,
borderRadius: '50%',
bgcolor: ledColor,
boxShadow: `0 0 6px ${ledColor}`,
}}
/>
</Box>
{/* LED 인디케이터 (우측 상단) */}
<Box
sx={{
position: 'absolute',
top: 4,
right: size * 0.25,
width: 5,
height: 5,
borderRadius: '50%',
bgcolor: ledColor,
boxShadow: `0 0 4px ${ledColor}`,
}}
/>
</Box>
{/* 라벨 */}
{label && (
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
fontWeight: 700,
letterSpacing: '1.5px',
color: d3roPalette.text.inactive,
textTransform: 'uppercase',
}}
>
{label}
</Typography>
)}
</Box>
)
}

View file

@ -7,3 +7,4 @@ export { Led } from './Led'
export { PhysicalButton } from './PhysicalButton'
export { MetalCard } from './MetalCard'
export { PhosphorText } from './PhosphorText'
export { MetalDial } from './MetalDial'