diff --git a/public/assets/icons/ic_volumn.svg b/public/assets/icons/ic_volumn.svg new file mode 100644 index 0000000..9309d69 --- /dev/null +++ b/public/assets/icons/ic_volumn.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index 910006a..f1c6254 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -2,14 +2,22 @@ import Tabbar from "@/components/common/Tabbar"; import Meal from "@/components/home/Meal"; +import Notification from "@/components/home/Notification"; import Ready from "@/components/home/Ready"; +import Todo from "@/components/home/Todo"; +import { RootState } from "@/redux/store"; import { theme } from "@/styles/theme"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useSelector } from "react-redux"; import styled from "styled-components"; const Home = () => { const router = useRouter(); + const audio = useSelector((state: RootState) => state.audio.audio); + + useEffect(() => {}, [audio]); return ( <> @@ -27,7 +35,13 @@ const Home = () => { style={{ cursor: "pointer" }} /> - + + + {audio && 오늘 할 일을 들려드릴게요!} + + + + @@ -85,3 +99,34 @@ const Header = styled.div` color: ${theme.colors.white}; ${(props) => props.theme.fonts.body1_b}; `; + +const ReadyContainer = styled.div` + width: 100%; + display: flex; + flex-direction: column; + padding: 9px 0px; + gap: 31px; + z-index: 1000; + position: relative; +`; + +const TodoOverlay = styled.div<{ $audio: boolean }>` + position: fixed; + background-color: ${({ $audio }) => ($audio ? "#00000078" : "transparent")}; + width: 100%; + height: 100vh; + top: 0; + left: 0; + z-index: 2000; + pointer-events: none; +`; + +const AudioText = styled.div` + position: absolute; + top: 130px; + left: 50%; + transform: translateX(-50%); + color: ${theme.colors.white}; + ${(props) => props.theme.fonts.heading2_b}; + z-index: 2000; +`; diff --git a/src/components/home/Notification.tsx b/src/components/home/Notification.tsx index f808e89..8c1d152 100644 --- a/src/components/home/Notification.tsx +++ b/src/components/home/Notification.tsx @@ -63,6 +63,7 @@ const NotiContainer = styled.div` ${(props) => props.theme.fonts.body2_b}; color: ${theme.colors.b700}; letter-spacing: -0.28px; + z-index: -10; `; const Title = styled.div` diff --git a/src/components/home/Ready.tsx b/src/components/home/Ready.tsx index 6eec4d6..8a40e15 100644 --- a/src/components/home/Ready.tsx +++ b/src/components/home/Ready.tsx @@ -59,8 +59,8 @@ const Ready = () => { height={230} /> - - + {/* + */} ); }; diff --git a/src/components/home/Todo.tsx b/src/components/home/Todo.tsx index 95f1726..97ebe93 100644 --- a/src/components/home/Todo.tsx +++ b/src/components/home/Todo.tsx @@ -2,10 +2,15 @@ import styled from "styled-components"; import { theme } from "@/styles/theme"; import Image from "next/image"; import ListBox from "../common/ListBox"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import AddTodoPopup, { categories } from "./AddTodoPopup"; import Toast from "../common/Toast"; import Axios from "@/apis/axios"; +import { setAudio } from "@/redux/slices/audioSlice"; +import { useDispatch } from "react-redux"; +import { RootState } from "@/redux/store"; +import { useSelector } from "react-redux"; +import { getSpeech } from "@/utils/getSpeech"; interface Todo { todoId: number; @@ -17,6 +22,7 @@ interface Todo { } const Todo = () => { + const dispatch = useDispatch(); const [currentDate, setCurrentDate] = useState(new Date()); const [addTodo, setAddTodo] = useState(false); const [todoData, setTodoData] = useState([]); @@ -112,17 +118,57 @@ const Todo = () => { /* 날짜(일수) 차이 문자열 */ const getDateString = (date: any) => { if (isToday(date)) { - return 오늘; + return "오늘"; } else { const diff = getDayDifference(date); - return ( - - {diff > 0 ? `${diff}일 전` : `${-diff}일 후`} - - ); + return diff > 0 ? `${diff}일 전` : `${-diff}일 후`; } }; + /* 음성 변환 목소리 preload */ + useEffect(() => { + window.speechSynthesis.getVoices(); + }, []); + + let date = `${todayMonth}월 ${todayDate}일 ${dayOfWeek}요일`; + + const handleVoiceConversion = () => { + dispatch(setAudio(true)); + + const unassignedTodos = todoData + .filter((todo: Todo) => todo.status === "INCOMPLETE") + .map((todo: Todo) => todo.description + "."); + + const text = + date + + `. ${getDateString(currentDate)} ${getDateString(currentDate) === "오늘" ? "해야할" : "했어야 할"} 일은. ` + + unassignedTodos + + "입니다."; + + getSpeech(text, () => { + dispatch(setAudio(false)); + }); + }; + + const containerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + window.speechSynthesis.cancel(); + dispatch(setAudio(false)); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [dispatch]); + /* Toast 메세지 유무 */ const [showToast, setShowToast] = useState(false); @@ -135,84 +181,99 @@ const Todo = () => { }; return ( - - 오늘 할 일 잊지마세요! - - left - {todayMonth}월 {todayDate}일 {dayOfWeek}요일 - {getDateString(currentDate)} - right - - - {todoData && todoData.length > 0 ? ( - todoData.map((data, index) => ( - category.value === data.todoType) - ?.label || data.todoType - } - onClick={() => { - changeTodo(data.todoId); + <> + + 오늘 할 일 잊지마세요! + + + left + {todayMonth}월 {todayDate}일 {dayOfWeek}요일 + + {getDateString(currentDate)} + + right deleteTodo(data.todoId)} /> - )) - ) : ( - 할 일이 없어요! - )} - - - + add + + + {todoData && todoData.length > 0 ? ( + todoData.map((data, index) => ( + category.value === data.todoType + )?.label || data.todoType + } + onClick={() => { + changeTodo(data.todoId); + }} + text={data.description} + time={`${getDayOfWeek(data.deadline)}까지`} + checked={data.status === "COMPLETE"} + onDelete={() => deleteTodo(data.todoId)} + /> + )) + ) : ( + 할 일이 없어요! + )} + + + + add + 할 일 직접 추가하기 + + + {addTodo && ( + + )} + {showToast && ( + setShowToast(false)} /> - 할 일 직접 추가하기 - - - {addTodo && ( - - )} - {showToast && ( - setShowToast(false)} - /> - )} - + )} + + ); }; @@ -227,9 +288,14 @@ const TodoContainer = styled.div` box-shadow: 0px 0px 64px 0px rgba(30, 41, 59, 0.1); ${(props) => props.theme.fonts.body2_b}; color: ${theme.colors.b700}; - z-index: 10; + z-index: 2000; letter-spacing: -0.28px; - position: relative; +`; + +const Row = styled.div` + display: flex; + justify-content: space-between; + align-items: center; `; const DateLine = styled.div` diff --git a/src/redux/slices/audioSlice.ts b/src/redux/slices/audioSlice.ts new file mode 100644 index 0000000..50cb192 --- /dev/null +++ b/src/redux/slices/audioSlice.ts @@ -0,0 +1,16 @@ +import { createSlice } from "@reduxjs/toolkit"; + +export const audioSlice = createSlice({ + name: "audio", + initialState: { + audio: false, + }, + reducers: { + setAudio: (state, action) => { + state.audio = action.payload; + }, + }, +}); + +export const { setAudio } = audioSlice.actions; +export default audioSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index de59d3e..cedad87 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -2,12 +2,14 @@ import { configureStore } from "@reduxjs/toolkit"; import categoryReducer from "./slices/categorySlice"; import authReducer from "./slices/authSlice"; import userReducer from "./slices/userSlice"; +import audioReducer from "./slices/audioSlice"; export const store = configureStore({ reducer: { category: categoryReducer, auth: authReducer, user: userReducer, + audio: audioReducer, }, }); diff --git a/src/utils/getSpeech.tsx b/src/utils/getSpeech.tsx new file mode 100644 index 0000000..ed51915 --- /dev/null +++ b/src/utils/getSpeech.tsx @@ -0,0 +1,40 @@ +export const getSpeech = (text: string, onEndCallback?: () => void) => { + let voices: any[] = []; + + const setVoiceList = () => { + voices = window.speechSynthesis.getVoices(); + }; + + setVoiceList(); + + if (window.speechSynthesis.onvoiceschanged !== undefined) { + window.speechSynthesis.onvoiceschanged = setVoiceList; + } + + window.speechSynthesis.cancel(); + + const speech = (txt: string) => { + const lang = "ko-KR"; + const utterThis = new SpeechSynthesisUtterance(txt); + + utterThis.lang = lang; + + const kor_voice = voices.find( + (elem) => elem.lang === lang || elem.lang === lang.replace("-", "_") + ); + + if (kor_voice) { + utterThis.voice = kor_voice; + } else { + return; + } + + if (onEndCallback) { + utterThis.onend = onEndCallback; + } + + window.speechSynthesis.speak(utterThis); + }; + + speech(text); +};