Skip to content

Commit ad44c06

Browse files
committed
refactor: VoiceControlMenuButton 로직 분리
1 parent 646bcdb commit ad44c06

File tree

2 files changed

+114
-107
lines changed

2 files changed

+114
-107
lines changed

Hippo/Features/Vision/UI/Immersive/Components/Molecules/VoiceControlMenuButton.swift

Lines changed: 48 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,9 @@ import SwiftUI
1010

1111
/// Voice-enabled Menu Toggle Button
1212
///
13-
/// Integrates menu toggle functionality with voice control, providing
14-
/// dual interaction modes and real-time visual feedback.
15-
///
1613
/// **Interaction Modes:**
17-
/// - Tap → Toggle menu + activate voice control (test mode)
18-
/// - Hover → Voice control only (hands-free)
19-
///
20-
/// **Visual Feedback:**
21-
/// - Button opacity changes based on menu and voice state
22-
/// - Real-time STT transcription display
23-
/// - Color-coded success/error messages
14+
/// - Tap → Toggle menu + activate voice control
15+
/// - Hover → Activate voice control (hands-free mode)
2416
struct VoiceControlMenuButton: View {
2517

2618
// MARK: - Constants
@@ -34,24 +26,15 @@ struct VoiceControlMenuButton: View {
3426

3527
@Environment(ImmersiveViewModel.self) private var immersiveViewModel
3628

37-
// MARK: - State
38-
39-
@State private var voiceControlVM: VoiceControlViewModel
40-
4129
// MARK: - Properties
4230

43-
/// Action to perform when button is tapped (menu toggle)
31+
@Bindable var viewModel: VoiceControlViewModel
4432
let action: () -> Void
4533

4634
// MARK: - Initialization
4735

48-
/// Initialize with shared VoiceControlViewModel
49-
///
50-
/// - Parameters:
51-
/// - viewModel: VoiceControlViewModel instance (shared with overlay)
52-
/// - action: Menu toggle action
5336
init(viewModel: VoiceControlViewModel, action: @escaping () -> Void) {
54-
_voiceControlVM = State(initialValue: viewModel)
37+
self.viewModel = viewModel
5538
self.action = action
5639
}
5740

@@ -61,22 +44,26 @@ struct VoiceControlMenuButton: View {
6144
VStack(spacing: 12) {
6245
buttonImage
6346

64-
// Real-time STT transcription
65-
if let partialText = voiceControlVM.uiState.partialTranscription, !partialText.isEmpty {
47+
// ㅁReal-time STT transcription
48+
if let partialText = viewModel.uiState.partialTranscription, !partialText.isEmpty {
6649
transcriptionText(partialText)
6750
}
6851

6952
// Command result feedback
70-
if shouldShowFeedback, let feedback = voiceControlVM.uiState.feedbackMessage {
53+
if viewModel.uiState.shouldShowFeedback, let feedback = viewModel.uiState.feedbackMessage {
7154
feedbackText(feedback)
7255
}
7356

7457
// Status instruction message
75-
if let message = statusMessage {
58+
if let message = resolvedStatusMessage {
7659
statusText(message)
7760
}
7861
}
7962
.onTapGesture(perform: handleTap)
63+
.hoverEffect()
64+
.onContinuousHover { phase in
65+
handleHover(phase)
66+
}
8067
}
8168

8269
// MARK: - Subviews
@@ -90,8 +77,7 @@ struct VoiceControlMenuButton: View {
9077
.frame(width: Constants.buttonSize, height: Constants.buttonSize)
9178
.opacity(buttonOpacity)
9279

93-
// Loading indicator
94-
if voiceControlVM.uiState.isProcessing {
80+
if viewModel.uiState.isProcessing {
9581
ProgressView()
9682
.progressViewStyle(.circular)
9783
.scaleEffect(Constants.progressScale)
@@ -100,8 +86,6 @@ struct VoiceControlMenuButton: View {
10086
}
10187
}
10288

103-
// MARK: - Text Components
104-
10589
/// Real-time transcription text view
10690
@ViewBuilder
10791
private func transcriptionText(_ text: String) -> some View {
@@ -144,19 +128,16 @@ struct VoiceControlMenuButton: View {
144128
.cornerRadius(8)
145129
}
146130

147-
// MARK: - Computed Properties - State
131+
// MARK: - Computed Properties
148132

149-
/// Current voice control state
150133
private var voiceState: VoiceControlState {
151-
voiceControlVM.uiState.state
134+
viewModel.uiState.state
152135
}
153136

154-
/// Whether menu is currently open
155137
private var isMenuOpen: Bool {
156138
immersiveViewModel.isMenuActive
157139
}
158140

159-
/// Button opacity (voice state > menu state priority)
160141
private var buttonOpacity: Double {
161142
switch voiceState {
162143
case .idle: isMenuOpen ? 1.0 : 0.25
@@ -166,86 +147,63 @@ struct VoiceControlMenuButton: View {
166147
}
167148
}
168149

169-
// MARK: - Computed Properties - Messages
150+
// MARK: - UI Mapping (Abstract → SwiftUI)
170151

171-
/// Status instruction message based on voice state
172-
private var statusMessage: String? {
173-
switch voiceState {
174-
case .idle:
175-
nil
176-
case .standby:
177-
"'Hippo'라고 말하세요"
178-
case .listening:
179-
if voiceControlVM.uiState.isProcessing {
180-
"처리 중..."
181-
} else if voiceControlVM.uiState.feedbackType == .success {
182-
nil // Hide when showing success feedback
183-
} else {
184-
"명령을 말씀해주세요"
185-
}
186-
case .retry:
187-
voiceControlVM.uiState.lastErrorMessage ?? "다시 시도해주세요"
152+
/// Resolve abstract message key to actual text
153+
private var resolvedStatusMessage: String? {
154+
switch viewModel.uiState.statusMessageKey {
155+
case .none: return nil
156+
case .standbyGuide: return "'Hippo'라고 말하세요"
157+
case .listening: return "명령을 말씀해주세요"
158+
case .processing: return "처리 중..."
159+
case .retry(let errorMessage): return errorMessage ?? "다시 시도해주세요"
188160
}
189161
}
190162

191-
/// Whether to show feedback message
192-
private var shouldShowFeedback: Bool {
193-
voiceState == .listening && !voiceControlVM.uiState.isProcessing
194-
}
195-
196-
/// Feedback text color based on feedback type
163+
/// Map color key to SwiftUI Color
197164
private var feedbackColor: Color {
198-
switch voiceControlVM.uiState.feedbackType {
199-
case .success: .green
200-
case .error: .orange
201-
case .info: .blue
202-
}
165+
mapColorKey(viewModel.uiState.feedbackColorKey)
203166
}
204167

205-
/// Feedback background color based on feedback type
206-
private var feedbackBackground: some ShapeStyle {
207-
switch voiceControlVM.uiState.feedbackType {
208-
case .success: AnyShapeStyle(Color.green.opacity(0.15))
209-
case .error: AnyShapeStyle(Color.orange.opacity(0.15))
210-
case .info: AnyShapeStyle(Color.blue.opacity(0.15))
211-
}
168+
private var feedbackBackground: Color {
169+
mapColorKey(viewModel.uiState.feedbackColorKey).opacity(0.15)
212170
}
213171

214-
/// Status text color based on voice state
215172
private var statusColor: Color {
216173
switch voiceState {
217-
case .idle: .secondary
218-
case .standby: .blue
219-
case .listening: .green
220-
case .retry: .orange
174+
case .idle: return .secondary
175+
default: return mapColorKey(viewModel.uiState.statusColorKey)
221176
}
222177
}
223178

224-
/// Status background color based on voice state
225-
private var statusBackground: some ShapeStyle {
179+
private var statusBackground: Color {
226180
switch voiceState {
227-
case .idle: AnyShapeStyle(Color.clear)
228-
case .standby: AnyShapeStyle(Color.blue.opacity(0.15))
229-
case .listening: AnyShapeStyle(Color.green.opacity(0.15))
230-
case .retry: AnyShapeStyle(Color.orange.opacity(0.15))
181+
case .idle: return .clear
182+
default: return mapColorKey(viewModel.uiState.statusColorKey).opacity(0.15)
183+
}
184+
}
185+
186+
private func mapColorKey(_ key: VoiceFeedbackColor) -> Color {
187+
switch key {
188+
case .success: return .green
189+
case .error: return .orange
190+
case .info: return .blue
231191
}
232192
}
233193

234194
// MARK: - Actions
235195

236-
/// Handle button tap - toggle menu and activate voice control
237196
private func handleTap() {
238197
action()
239-
voiceControlVM.onWakeWordDetected()
198+
viewModel.onWakeWordDetected()
240199
}
241200

242-
/// Handle hover phase changes (disabled - using tap only)
243201
private func handleHover(_ phase: HoverPhase) {
244202
switch phase {
245203
case .active:
246-
voiceControlVM.onHoverBegan()
204+
viewModel.onHoverBegan()
247205
case .ended:
248-
voiceControlVM.onHoverEnded()
206+
viewModel.onHoverEnded()
249207
}
250208
}
251209
}
@@ -256,9 +214,8 @@ struct VoiceControlMenuButton: View {
256214
PreviewContainer()
257215
}
258216

259-
// MARK: - Preview Helpers
260-
261-
private struct PreviewContainer: View {
217+
#if DEBUG
218+
fileprivate struct PreviewContainer: View {
262219
@State private var immersiveVM = ImmersiveViewModel()
263220
@State private var voiceControlVM = VoiceControlViewModel(
264221
commandExecutor: MockVoiceControlManager()
@@ -273,9 +230,9 @@ private struct PreviewContainer: View {
273230
}
274231
}
275232

276-
/// Mock VoiceControlManager for previews and testing
277-
internal struct MockVoiceControlManager: VoiceCommandExecutor {
233+
fileprivate struct MockVoiceControlManager: VoiceCommandExecutor {
278234
func execute(_ intent: VoiceCommandIntent) async throws {
279235
print("Mock execute: \(intent)")
280236
}
281237
}
238+
#endif

Hippo/Features/Vision/UI/VoiceControl/ViewModels/VoiceControlUIState.swift

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1-
//
2-
// VoiceControlUIState.swift
3-
// Hippo
4-
//
5-
// UI-specific state for Voice Control
6-
// Wraps domain state (VoiceControlState) with UI-specific properties
7-
//
8-
91
import Foundation
102

11-
/// UI state for Voice Control
12-
///
13-
/// This struct combines domain state with UI-specific properties:
14-
/// - Domain state: VoiceControlState (idle, standby, listening, retry)
15-
/// - UI properties: feedback messages, transcription, errors
16-
///
17-
/// The feedback message is automatically computed based on current state.
18-
/// All UI-related logic (error messages, feedback) is encapsulated here.
3+
// MARK: - UI State Enums
4+
5+
public enum VoiceFeedbackColor: Equatable {
6+
case success
7+
case error
8+
case info
9+
}
10+
11+
public enum VoiceStatusMessageKey: Equatable {
12+
case none
13+
case standbyGuide
14+
case listening
15+
case processing
16+
case retry(errorMessage: String?)
17+
}
18+
19+
// MARK: - UI State
20+
1921
public struct VoiceControlUIState: Equatable {
2022

2123
// MARK: - Properties - State
@@ -189,3 +191,51 @@ extension VoiceControlUIState {
189191
}
190192
}
191193
}
194+
195+
// MARK: - UI Helpers (Pure Swift)
196+
197+
extension VoiceControlUIState {
198+
199+
/// Status message key (View resolves to actual text)
200+
public var statusMessageKey: VoiceStatusMessageKey {
201+
switch state {
202+
case .idle:
203+
return .none
204+
case .standby:
205+
return .standbyGuide
206+
case .listening:
207+
if isProcessing {
208+
return .processing
209+
} else if feedbackType == .success {
210+
return .none
211+
} else {
212+
return .listening
213+
}
214+
case .retry:
215+
return .retry(errorMessage: lastErrorMessage)
216+
}
217+
}
218+
219+
/// Whether to show feedback message
220+
public var shouldShowFeedback: Bool {
221+
state == .listening && !isProcessing
222+
}
223+
224+
/// Feedback color key (View maps to SwiftUI Color)
225+
public var feedbackColorKey: VoiceFeedbackColor {
226+
switch feedbackType {
227+
case .success: return .success
228+
case .error: return .error
229+
case .info: return .info
230+
}
231+
}
232+
233+
/// Status color key (View maps to SwiftUI Color)
234+
public var statusColorKey: VoiceFeedbackColor {
235+
switch state {
236+
case .idle, .standby: return .info
237+
case .listening: return .success
238+
case .retry: return .error
239+
}
240+
}
241+
}

0 commit comments

Comments
 (0)