diff --git a/com.twin.app.shoptime/assets/images/icons/ico_microphone.png b/com.twin.app.shoptime/assets/images/icons/ico_microphone.png new file mode 100644 index 00000000..55d94cc2 Binary files /dev/null and b/com.twin.app.shoptime/assets/images/icons/ico_microphone.png differ diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx index 8da4a057..aed8544f 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx @@ -1,48 +1,64 @@ // src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; +import React, { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import { + useDispatch, + useSelector, +} from 'react-redux'; + import Spotlight from '@enact/spotlight'; -import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator'; +import SpotlightContainerDecorator + from '@enact/spotlight/SpotlightContainerDecorator'; import Spottable from '@enact/spotlight/Spottable'; -import { useDispatch, useSelector } from 'react-redux'; -import TInput, { ICONS, KINDS } from '../../../components/TInput/TInput'; -import TFullPopup from '../../../components/TFullPopup/TFullPopup'; import micIcon from '../../../../assets/images/searchpanel/image-mic.png'; import { getShopperHouseSearch } from '../../../actions/searchActions'; -import css from './VoiceInputOverlay.module.less'; -import VoicePromptScreen from './modes/VoicePromptScreen'; +import TFullPopup from '../../../components/TFullPopup/TFullPopup'; +import TInput, { + ICONS, + KINDS, +} from '../../../components/TInput/TInput'; import VoiceListening from './modes/VoiceListening'; +import VoiceNotRecognized from './modes/VoiceNotRecognized'; +import VoiceNotRecognizedCircle from './modes/VoiceNotRecognizedCircle'; +import VoicePromptScreen from './modes/VoicePromptScreen'; +import css from './VoiceInputOverlay.module.less'; const OverlayContainer = SpotlightContainerDecorator( { - enterTo: 'default-element', - restrict: 'self-only', // 포커스를 overlay 내부로만 제한 + enterTo: "default-element", + restrict: "self-only", // 포커스를 overlay 내부로만 제한 }, - 'div' + "div" ); -const SpottableMicButton = Spottable('div'); +const SpottableMicButton = Spottable("div"); // Voice overlay 모드 상수 export const VOICE_MODES = { - PROMPT: 'prompt', // Try saying 화면 - LISTENING: 'listening', // 듣는 중 화면 - MODE_3: 'mode3', // 추후 추가 - MODE_4: 'mode4', // 추후 추가 + PROMPT: "prompt", // Try saying 화면 + LISTENING: "listening", // 듣는 중 화면 + MODE_3: "mode3", // 추후 추가 + MODE_4: "mode4", // 추후 추가 }; -const OVERLAY_SPOTLIGHT_ID = 'voice-input-overlay-container'; -const INPUT_SPOTLIGHT_ID = 'voice-overlay-input-box'; -const MIC_SPOTLIGHT_ID = 'voice-overlay-mic-button'; +const OVERLAY_SPOTLIGHT_ID = "voice-input-overlay-container"; +const INPUT_SPOTLIGHT_ID = "voice-overlay-input-box"; +const MIC_SPOTLIGHT_ID = "voice-overlay-mic-button"; const VoiceInputOverlay = ({ isVisible, onClose, mode = VOICE_MODES.PROMPT, suggestions = [], - searchQuery = '', + searchQuery = "", onSearchChange, onSearchSubmit, }) => { @@ -53,12 +69,17 @@ const VoiceInputOverlay = ({ const [currentMode, setCurrentMode] = useState(mode); // Redux에서 voice 상태 가져오기 - const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice); + const { isRegistered, lastSTTText, sttTimestamp } = useSelector( + (state) => state.voice + ); // STT 텍스트 수신 시 처리 useEffect(() => { if (lastSTTText && sttTimestamp && isVisible) { - console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText); + console.log( + "[VoiceInputOverlay] STT text received in overlay:", + lastSTTText + ); // 입력창에 텍스트 표시 (부모 컴포넌트로 전달) if (onSearchChange) { @@ -101,9 +122,9 @@ const VoiceInputOverlay = ({ // Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트만 설정 const handleSuggestionClick = useCallback( (suggestion) => { - console.log('[VoiceInputOverlay] Suggestion clicked:', suggestion); + console.log("[VoiceInputOverlay] Suggestion clicked:", suggestion); // 따옴표 제거 - const query = suggestion.replace(/^["']|["']$/g, '').trim(); + const query = suggestion.replace(/^["']|["']$/g, "").trim(); // Input 창에 텍스트 설정 if (onSearchChange) { onSearchChange({ value: query }); @@ -114,7 +135,7 @@ const VoiceInputOverlay = ({ // Input 창에서 API 호출 핸들러 (엔터키 또는 돋보기 아이콘 클릭) const handleSearchSubmit = useCallback(() => { - console.log('[VoiceInputOverlay] Search submit:', searchQuery); + console.log("[VoiceInputOverlay] Search submit:", searchQuery); if (searchQuery && searchQuery.trim()) { // ShopperHouse API 호출 dispatch(getShopperHouseSearch(searchQuery.trim())); @@ -129,7 +150,7 @@ const VoiceInputOverlay = ({ // Input 창에서 엔터키 핸들러 const handleInputKeyDown = useCallback( (e) => { - if (e.key === 'Enter' || e.keyCode === 13) { + if (e.key === "Enter" || e.keyCode === 13) { e.preventDefault(); handleSearchSubmit(); } @@ -142,19 +163,25 @@ const VoiceInputOverlay = ({ switch (currentMode) { case VOICE_MODES.PROMPT: return ( - + ); case VOICE_MODES.LISTENING: return ; case VOICE_MODES.MODE_3: // 추후 MODE_3 컴포넌트 추가 - return
Mode 3 (Coming soon)
; + return ; case VOICE_MODES.MODE_4: // 추후 MODE_4 컴포넌트 추가 - return
Mode 4 (Coming soon)
; + return ; default: return ( - + ); } }; @@ -171,7 +198,10 @@ const VoiceInputOverlay = ({ // 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close) const handleMicClick = useCallback( (e) => { - console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode); + console.log( + "[VoiceInputOverlay] handleMicClick called, currentMode:", + currentMode + ); // 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지 if (e && e.stopPropagation) { @@ -183,17 +213,17 @@ const VoiceInputOverlay = ({ if (currentMode === VOICE_MODES.PROMPT) { // prompt 모드에서 클릭 시 -> listening 모드로 전환 - console.log('[VoiceInputOverlay] Switching to LISTENING mode'); + console.log("[VoiceInputOverlay] Switching to LISTENING mode"); setCurrentMode(VOICE_MODES.LISTENING); // 이 시점에서 webOS Voice Framework가 자동으로 음성 인식 시작 // (이미 registerVoiceFramework()로 등록되어 있으므로) } else if (currentMode === VOICE_MODES.LISTENING) { // listening 모드에서 클릭 시 -> 종료 - console.log('[VoiceInputOverlay] Closing from LISTENING mode'); + console.log("[VoiceInputOverlay] Closing from LISTENING mode"); onClose(); } else { // 기타 모드에서는 바로 종료 - console.log('[VoiceInputOverlay] Closing from other mode'); + console.log("[VoiceInputOverlay] Closing from other mode"); onClose(); } }, @@ -203,7 +233,7 @@ const VoiceInputOverlay = ({ // dim 레이어 클릭 핸들러 (마이크 버튼과 분리) const handleDimClick = useCallback( (e) => { - console.log('[VoiceInputOverlay] dimBackground clicked'); + console.log("[VoiceInputOverlay] dimBackground clicked"); onClose(); }, [onClose] @@ -225,9 +255,17 @@ const VoiceInputOverlay = ({
{/* Voice 등록 상태 표시 (디버깅용) */} - {process.env.NODE_ENV === 'development' && ( -
- Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'} + {process.env.NODE_ENV === "development" && ( +
+ Voice: {isRegistered ? "✓ Ready" : "✗ Not Ready"}
)} @@ -238,7 +276,10 @@ const VoiceInputOverlay = ({ spotlightDisabled={!isVisible} > {/* 입력창과 마이크 버튼 - SearchPanel.inputContainer와 동일한 구조 */} -
e.stopPropagation()}> +
e.stopPropagation()} + >
{ - if (e.key === 'Enter') { + if (e.key === "Enter") { handleMicClick(e); } }} spotlightId={MIC_SPOTLIGHT_ID} >
- Microphone + Microphone
{currentMode === VOICE_MODES.LISTENING && ( @@ -307,7 +352,7 @@ VoiceInputOverlay.propTypes = { VoiceInputOverlay.defaultProps = { mode: VOICE_MODES.PROMPT, suggestions: [], - searchQuery: '', + searchQuery: "", onSearchChange: null, onSearchSubmit: null, }; diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less index 1912f895..57e51250 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less @@ -10,6 +10,7 @@ align-items: center; pointer-events: none; // 포커스 받지 않음 position: relative; + margin-top: -210px; } .listeningText { @@ -71,7 +72,7 @@ .bar2 { width: 510px; left: 0; - background: #FFB3B3; + background: #ffb3b3; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 1.4s; // 가장 큰 막대 - 마지막 opacity: 0; // 애니메이션으로 제어 @@ -80,7 +81,7 @@ .bar3 { width: 480px; left: 15px; - background: #FF8080; + background: #ff8080; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 1.2s; opacity: 0; @@ -89,16 +90,16 @@ .bar4 { width: 390px; left: 60px; - background: #FF6666; + background: #ff6666; animation: waveAppear 1.6s ease-in-out infinite; - animation-delay: 1.0s; + animation-delay: 1s; opacity: 0; } .bar5 { width: 350px; left: 80px; - background: #FF4D4D; + background: #ff4d4d; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.8s; opacity: 0; @@ -107,7 +108,7 @@ .bar6 { width: 320px; left: 95px; - background: #FF3333; + background: #ff3333; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.6s; opacity: 0; @@ -116,7 +117,7 @@ .bar7 { width: 260px; left: 125px; - background: #FF1A1A; + background: #ff1a1a; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.4s; opacity: 0; @@ -125,7 +126,7 @@ .bar8 { width: 200px; left: 155px; - background: #FF0000; + background: #ff0000; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.2s; opacity: 0; @@ -134,7 +135,7 @@ .bar9 { width: 150px; left: 180px; - background: #E00000; + background: #e00000; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.1s; opacity: 0; @@ -143,7 +144,7 @@ .bar10 { width: 100px; left: 205px; - background: #CC0000; + background: #cc0000; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0s; // 가장 작은 막대 - 처음 시작 opacity: 0; @@ -174,7 +175,8 @@ 0% { opacity: 1; } - 33%, 100% { + 33%, + 100% { opacity: 0; } } diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognized.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognized.jsx new file mode 100644 index 00000000..ec7a5b67 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognized.jsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import defaultMicImg + from '../../../../../assets/images/icons/ico_microphone.png'; +import CustomImage from '../../../../components/CustomImage/CustomImage'; +import css from './VoiceNotRecognized.module.less'; + +const VoiceNotRecognized = () => { + return ( +
+
+ + + Voice is not recognized. Try again . + +
+
+ ); +}; + +export default VoiceNotRecognized; diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognized.module.less b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognized.module.less new file mode 100644 index 00000000..8d107774 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognized.module.less @@ -0,0 +1,31 @@ +@import "../../../../style/CommonStyle.module.less"; + +.container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + pointer-events: none; // 포커스 받지 않음 + position: relative; + margin-top: -210px; + .micBox { + width: 634px; + height: 276px; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + text-align: center; + .microPhone { + height: 200px; + } + .infoText { + font-size: 42px; + font-weight: 700; + letter-spacing: -1px; + color: #fff; + } + } +} diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognizedCircle.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognizedCircle.jsx new file mode 100644 index 00000000..40ec8d29 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognizedCircle.jsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import css from './VoiceNotRecognizedCircle.module.less'; + +const VoiceNotRecognizedCircle = () => { + return ( +
+
+ + + + {/* 애니메이션 원 - 진행 상황 표시 */} + + + + Voice is not recognized. Try again . + +
+
+ ); +}; + +export default VoiceNotRecognizedCircle; diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognizedCircle.module.less b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognizedCircle.module.less new file mode 100644 index 00000000..de01b98e --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognizedCircle.module.less @@ -0,0 +1,53 @@ +@import "../../../../style/CommonStyle.module.less"; + +.container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + pointer-events: none; // 포커스 받지 않음 + position: relative; + margin-top: -210px; + padding-right: 120xp; + .micBox { + width: 634px; + height: 276px; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + text-align: center; + + .infoText { + font-size: 42px; + font-weight: 700; + letter-spacing: -1px; + color: #fff; + } + } +} + +.rippleCircleBackground { + stroke: rgba(199, 8, 80, 0.2); + stroke-width: 6; + opacity: 1; +} +.rippleCircle { + stroke-dasharray: 295.3; // 2 * PI * 47 (원의 둘레) + stroke-dashoffset: 295.3; // 초기값: 완전히 숨김 + transform-origin: center; + transform: rotate(-90deg); // 12시 방향에서 시작 + animation: drawCircleReco 2s ease-in-out infinite; + background-color: rgba(199, 8, 80, 0.2); +} + +@keyframes drawCircleReco { + 0% { + stroke-dashoffset: 295.3; // 점에서 시작 + } + 100% { + stroke-dashoffset: 0; // 원 완성 (계속 시계방향으로) + } +}