diff --git a/app/(tabs)/index.jsx b/app/(tabs)/index.jsx index 822307e..5784ead 100644 --- a/app/(tabs)/index.jsx +++ b/app/(tabs)/index.jsx @@ -2,7 +2,9 @@ import CategoryMainItem from '@/components/categoryView/CategoryMainItem'; import LoadingSpinner from '@/components/common/molecules/LoadingSpinner'; import CalendarBottomSheet from '@/components/todayView/dailyTodos/calendarBottomSheet/CalendarBottomSheet'; import DailyTodos from '@/components/todayView/dailyTodos/DailyTodos'; +import SubTodoGenerateBottomSheet from '@/components/todayView/dailyTodos/subtodoGenerateBottomSheet/SubTodoGenerateBottomSheet'; import WeeklyCalendar from '@/components/WeeklyCalendar'; +import AIBottomSheetProvider from '@/contexts/AIBottomSheetProvider'; import CalendarBottomSheetProvider from '@/contexts/CalendarBottomSheetProvider'; import CategoryProvider from '@/contexts/CategoryContext'; import DateProvider from '@/contexts/DateContext'; @@ -48,37 +50,40 @@ const TodayView = () => { }; const { data: categoriesData } = useCategoriesQuery(userId); const { data: todosData } = useTodosQuery(userId); - const { selectedTodo } = useTodoStore(); + const selectedTodo = useTodoStore(state => state.selectedTodo); return ( - - - - - - - } - > - item.id} - contentContainerStyle={styles.flatList} - /> - - - - + + + + + + + + } + > + item.id} + contentContainerStyle={styles.flatList} + /> + + + + + + diff --git a/components/inboxView/inboxTodos/inboxTodo/todoListItem/todoMoreMenu/TodoMoreMenu.tsx b/components/inboxView/inboxTodos/inboxTodo/todoListItem/todoMoreMenu/TodoMoreMenu.tsx index da25e6c..4231d4b 100644 --- a/components/inboxView/inboxTodos/inboxTodo/todoListItem/todoMoreMenu/TodoMoreMenu.tsx +++ b/components/inboxView/inboxTodos/inboxTodo/todoListItem/todoMoreMenu/TodoMoreMenu.tsx @@ -68,7 +68,7 @@ const TodoMoreMenu = ({ const [visible, setVisible] = useState(false); const { t } = useTranslation(); const { openBottomSheet } = useContext(CalendarBottomSheetContext); - const { setSelectedTodo } = useTodoStore(); + const setSelectedTodo = useTodoStore(state => state.setSelectedTodo); const toggleMenu = useCallback(() => { setVisible(true); @@ -121,6 +121,7 @@ const TodoMoreMenu = ({ titleText={t('components.todoMoreMenu.createSubTodoWithAi')} /> onPress={() => { + setSelectedTodo(item); handleGenerateSubTodoPress(); }} style={styles.middleMenuItem} diff --git a/components/todayView/dailyTodos/calendarBottomSheet/CalendarBottomSheet.tsx b/components/todayView/dailyTodos/calendarBottomSheet/CalendarBottomSheet.tsx index dd1c6ec..7c1638a 100644 --- a/components/todayView/dailyTodos/calendarBottomSheet/CalendarBottomSheet.tsx +++ b/components/todayView/dailyTodos/calendarBottomSheet/CalendarBottomSheet.tsx @@ -1,4 +1,4 @@ -import { View, Text } from 'react-native'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; import React, { useCallback, useContext, useMemo } from 'react'; import BottomSheet, { BottomSheetBackdrop, @@ -11,6 +11,7 @@ import { Calendar, I18nConfig, NativeDateService, + Icon, } from '@ui-kitten/components'; import { convertGmtToKst } from '@/utils/convertTimezone'; import { useTodoUpdateMutation } from '@/hooks/api/useTodoMutations'; @@ -18,11 +19,14 @@ import { useSubTodoUpdateMutation } from '@/hooks/api/useSubTodoMutations'; import { t } from 'i18next'; import { useTranslation } from 'react-i18next'; import { scale, verticalScale } from 'react-native-size-matters'; +import fontStyles from '@/theme/fontStyles'; const CalendarBottomSheet = ({ isTodo, item }) => { const { selectedDate } = useContext(DateContext); const [calendarDate, setCalendarDate] = React.useState(selectedDate.toDate()); - const { bottomSheetRef } = useContext(CalendarBottomSheetContext); + const { bottomSheetRef, closeBottomSheet } = useContext( + CalendarBottomSheetContext, + ); const { mutate: updateTodoDate } = useTodoUpdateMutation(); const { mutate: updateSubTodoDate } = useSubTodoUpdateMutation(); const snapPoints = useMemo(() => ['75%'], []); @@ -129,6 +133,14 @@ const CalendarBottomSheet = ({ isTodo, item }) => { backgroundColor: 'white', }} > + + + {t('components.todoMoreMenu.changeDate')} + + + + + { }; export default CalendarBottomSheet; + +const styles = StyleSheet.create({ + topContainer: { + backgroundColor: 'white', + flexDirection: 'row', + justifyContent: 'space-between', + marginVertical: verticalScale(15), + paddingHorizontal: scale(16), + }, + + icon: { + width: scale(20), + height: verticalScale(20), + }, + titleText: { + fontSize: fontStyles.Subtitle.S1.B_130.fontSize, + fontFamily: fontStyles.Subtitle.S1.B_130.fontFamily, + }, +}); diff --git a/components/todayView/dailyTodos/dailyTodo/todoListItem/todoMoreMenu/TodoMoreMenu.tsx b/components/todayView/dailyTodos/dailyTodo/todoListItem/todoMoreMenu/TodoMoreMenu.tsx index 0b4b19b..49fc2ce 100644 --- a/components/todayView/dailyTodos/dailyTodo/todoListItem/todoMoreMenu/TodoMoreMenu.tsx +++ b/components/todayView/dailyTodos/dailyTodo/todoListItem/todoMoreMenu/TodoMoreMenu.tsx @@ -21,6 +21,7 @@ import theme from '@/theme/theme.json'; import useTodoMoreMenu from './useTodoMoreMenu'; import { CalendarBottomSheetContext } from '@/contexts/CalendarBottomSheetProvider'; import useTodoStore from '@/contexts/TodoStore'; +import { AIBottomSheetContext } from '@/contexts/AIBottomSheetProvider'; const TodoText = ({ titleText, disabled = false }) => { return ( @@ -56,7 +57,6 @@ const TodoMoreMenu = ({ const { handleEditPress, handleDeletePress, - handleGenerateSubTodoPress, handleCreateSubTodoPress: handleAddSubTodoPress, handlePutTodoToInboxPress, } = useTodoMoreMenu({ @@ -68,7 +68,10 @@ const TodoMoreMenu = ({ const [visible, setVisible] = useState(false); const { t } = useTranslation(); const { openBottomSheet } = useContext(CalendarBottomSheetContext); - const { setSelectedTodo } = useTodoStore(); + const setSelectedTodo = useTodoStore(state => state.setSelectedTodo); + const { openBottomSheet: openAIBottomSheet } = + useContext(AIBottomSheetContext); + useContext(AIBottomSheetContext); const toggleMenu = useCallback(() => { setVisible(true); @@ -123,7 +126,8 @@ const TodoMoreMenu = ({ titleText={t('components.todoMoreMenu.createSubTodoWithAi')} /> onPress={() => { - handleGenerateSubTodoPress(); + setSelectedTodo(item); + openAIBottomSheet(); setVisible(false); }} style={styles.middleMenuItem} diff --git a/components/todayView/dailyTodos/subtodoGenerateBottomSheet/SubTodoGenerateBottomSheet.tsx b/components/todayView/dailyTodos/subtodoGenerateBottomSheet/SubTodoGenerateBottomSheet.tsx new file mode 100644 index 0000000..723cd94 --- /dev/null +++ b/components/todayView/dailyTodos/subtodoGenerateBottomSheet/SubTodoGenerateBottomSheet.tsx @@ -0,0 +1,324 @@ +import React, { + useCallback, + useContext, + useMemo, + useState, + useEffect, +} from 'react'; +import { View, StyleSheet, Pressable } from 'react-native'; +import BottomSheet, { + BottomSheetBackdrop, + BottomSheetView, +} from '@gorhom/bottom-sheet'; +import { LoginContext } from '@/contexts/LoginContext'; +import { + Button, + Text, + List, + ListItem, + useTheme, + Icon, +} from '@ui-kitten/components'; +import { useTranslation } from 'react-i18next'; +import { scale, verticalScale, moderateScale } from 'react-native-size-matters'; +import { IconButton } from '@/components/common/molecules/IconButton'; +import LoadingSpinner from '@/components/common/molecules/LoadingSpinner'; +import fontStyles from '@/theme/fontStyles'; +import { useSubTodoAddMutation } from '@/hooks/api/useSubTodoMutations'; +import axios from 'axios'; +import { API_PATH } from '@/utils/config'; +import * as Sentry from '@sentry/react-native'; +import { AIBottomSheetContext } from '@/contexts/AIBottomSheetProvider'; + +const SubTodoGenerateBottomSheet = ({ item }) => { + const { accessToken } = useContext(LoginContext); + const [isLoading, setIsLoading] = useState(false); + const [generatedSubTodos, setGeneratedSubTodos] = useState([]); + const [selectedIndexes, setSelectedIndexes] = useState([]); + const theme = useTheme(); + const { t } = useTranslation(); + const { mutate: addSubTodo } = useSubTodoAddMutation(); + + const snapPoints = useMemo(() => ['75%'], []); + + const { bottomSheetRef } = useContext(AIBottomSheetContext); + + const renderBackdrop = useCallback( + props => ( + + ), + [], + ); + + useEffect(() => { + if (generatedSubTodos.length > 0) { + setSelectedIndexes(generatedSubTodos.map((_, index) => index)); + bottomSheetRef.current?.snapToIndex(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [generatedSubTodos]); + + const handleClose = () => { + bottomSheetRef.current?.close(); + setGeneratedSubTodos([]); + setSelectedIndexes([]); + setIsLoading(false); + }; + + const handleGenerateSubTodos = () => { + setIsLoading(true); + axios + .get(`${API_PATH.recommend}?todo_id=${item.id}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(response => { + setIsLoading(false); + setGeneratedSubTodos(response.data.children); + }) + .catch(error => { + setIsLoading(false); + Sentry.captureException(error); + }); + }; + + const handleApplySelection = () => { + const newSubTodos = selectedIndexes.map(index => ({ + content: generatedSubTodos[index].content, + date: generatedSubTodos[index].date, + todoId: generatedSubTodos[index].todo, + })); + addSubTodo({ todoData: newSubTodos }); + handleClose(); + }; + + const handleToggleSelection = index => { + setSelectedIndexes(prev => + prev.includes(index) ? prev.filter(i => i !== index) : [...prev, index], + ); + }; + + const renderSubTodoItemTitle = (evaProps, subTodoItem) => ( + + {subTodoItem.content} + + ); + + const renderSubTodoItemAccessoryRight = (props, index) => ( + handleToggleSelection(index)} + fill={ + selectedIndexes.includes(index) + ? theme['color-primary-500'] + : theme['text-basic-color'] + } + iconName="done-all-outline" + {...props} + /> + ); + + const renderSubTodoItem = ({ subTodoItem, index }) => ( + renderSubTodoItemAccessoryRight(props, index)} + title={props => renderSubTodoItemTitle(props, subTodoItem)} + /> + ); + + const renderInitialView = () => ( + + + + {t('components.todoMoreMenu.createSubTodoWithAi')} + + + + + + + + + {item?.content} + + + {t('components.subTodoGenerateModal.askCreateSubTodo')} + + + + + + + + ); + + const renderGeneratedList = () => ( + + + + {t('components.todoMoreMenu.createSubTodoWithAi')} + + + + + + + + renderSubTodoItem({ subTodoItem: generatedTodoItem, index }) + } + style={styles.list} + /> + + + + ); + + const renderContent = () => { + if (isLoading) { + return ( + + + + ); + } + + return generatedSubTodos.length === 0 + ? renderInitialView() + : renderGeneratedList(); + }; + + return ( + + + {renderContent()} + + + ); +}; + +const styles = StyleSheet.create({ + contentContainer: { + flex: 1, + padding: scale(20), + borderTopLeftRadius: scale(20), + borderTopRightRadius: scale(20), + }, + initialContainer: { + flex: 1, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'space-between', + }, + textContainer: { + flex: 1, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + titleText: { + height: verticalScale(23), + textAlign: 'center', + }, + descriptionText: { + height: verticalScale(23), + textAlign: 'center', + }, + buttonContainer: { + height: verticalScale(52), + width: '100%', + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + gap: 12, + }, + button: { + flex: 1, + height: verticalScale(44), + alignItems: 'center', + justifyContent: 'center', + borderRadius: scale(12), + }, + loadingContainer: { + height: verticalScale(165), + justifyContent: 'center', + alignItems: 'center', + }, + listContainer: { + flex: 1, + height: '100%', + }, + listWrapper: { + flex: 1, + flexDirection: 'column', + }, + list: { + flex: 1, + backgroundColor: 'white', + }, + listItem: {}, + titleStyle: { + fontSize: moderateScale(13), + }, + applyButton: { + marginTop: 16, + height: verticalScale(44), + borderRadius: scale(12), + }, + topContainer: { + height: verticalScale(50), + width: '100%', + backgroundColor: 'white', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + icon: { + width: scale(20), + height: verticalScale(20), + }, + bottomSheetTitleText: { + fontSize: fontStyles.Subtitle.S1.B_130.fontSize, + fontFamily: fontStyles.Subtitle.S1.B_130.fontFamily, + }, + middleContainer: { + flex: 1, + display: 'flex', + justifyContent: 'space-between', + }, +}); + +export default SubTodoGenerateBottomSheet; diff --git a/contexts/AIBottomSheetProvider.js b/contexts/AIBottomSheetProvider.js new file mode 100644 index 0000000..83a3275 --- /dev/null +++ b/contexts/AIBottomSheetProvider.js @@ -0,0 +1,29 @@ +import { createContext, useRef } from 'react'; + +export const AIBottomSheetContext = createContext(null); + +const AIBottomSheetProvider = ({ children }) => { + const bottomSheetRef = useRef(null); + + const openBottomSheet = () => { + if (bottomSheetRef.current) { + bottomSheetRef.current.expand(); + } + }; + + const closeBottomSheet = () => { + if (bottomSheetRef.current) { + bottomSheetRef.current.close(); + } + }; + + return ( + + {children} + + ); +}; + +export default AIBottomSheetProvider; diff --git a/contexts/CalendarBottomSheetProvider.js b/contexts/CalendarBottomSheetProvider.js index 33a725b..09aeb5f 100644 --- a/contexts/CalendarBottomSheetProvider.js +++ b/contexts/CalendarBottomSheetProvider.js @@ -10,9 +10,16 @@ const CalendarBottomSheetProvider = ({ children }) => { bottomSheetRef.current.expand(); } }; + + const closeBottomSheet = () => { + if (bottomSheetRef.current) { + bottomSheetRef.current.close(); + } + }; + return ( {children} diff --git a/hooks/api/useApi.js b/hooks/api/useApi.js deleted file mode 100644 index 0c9e3db..0000000 --- a/hooks/api/useApi.js +++ /dev/null @@ -1,199 +0,0 @@ -import { useState, useEffect } from 'react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import axios from 'axios'; -import { router } from 'expo-router'; -import { API_PATH } from '@/utils/config'; -import * as Sentry from '@sentry/react-native'; - -const TOKEN_INVALID_OR_EXPIRED_MESSAGE = 'Token is invalid or expired'; -const TOKEN_INVALID_TYPE_MESSAGE = 'Given token not valid for any token type'; - -export function useApi() { - const [accessToken, setAccessToken] = useState(null); - useEffect(() => { - AsyncStorage.getItem('accessToken').then(token => { - setAccessToken(token); - }); - }, []); - - const metadata = () => { - return { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - }; - }; - - const handleRequest = async request => { - try { - const response = await request(); - return response.data; - } catch (err) { - if ( - (err.response?.status === 401 && - err.response?.data?.detail === TOKEN_INVALID_OR_EXPIRED_MESSAGE) || - err.response?.data?.detail === TOKEN_INVALID_TYPE_MESSAGE - ) { - try { - const refreshToken = await AsyncStorage.getItem('refreshToken'); - const responseData = await axios.post(API_PATH.renew, { - refresh: refreshToken, - access: accessToken, - }); - await AsyncStorage.setItem('accessToken', responseData.data.access); - setAccessToken(responseData.data.access); - const secondRequest = await request(); - return secondRequest.data; - } catch (refreshError) { - Sentry.captureException(refreshError); - - if (refreshError.response?.status === 401) { - await AsyncStorage.multiRemove([ - 'accessToken', - 'refreshToken', - 'userId', - ]); - router.replace(''); - } else { - throw refreshError; - } - } - } - Sentry.captureException(err); - throw err; - } - }; - - const fetchTodos = userId => { - return handleRequest(() => - axios.get(`${API_PATH.todos}?user_id=${userId}`, metadata()), - ); - }; - - const addTodo = todoData => { - return handleRequest(() => - axios.post(API_PATH.todos, todoData, metadata()), - ); - }; - - const deleteTodo = ({ todoId }) => { - return handleRequest(() => - axios.request({ - url: API_PATH.todos, - method: 'DELETE', - ...metadata(), - data: { todo_id: todoId }, - }), - ); - }; - - const updateTodo = ({ updateData }) => { - return handleRequest(() => - axios.patch(API_PATH.todos, updateData, metadata()), - ); - }; - - const verifyToken = token => { - return handleRequest(() => axios.post(API_PATH.verify, { token })); - }; - - const renewToken = refreshToken => { - return handleRequest(() => - axios.post(API_PATH.renew, { - refresh: refreshToken, - access: accessToken, - }), - ); - }; - - const googleLogin = tokenData => { - return handleRequest(() => axios.post(API_PATH.login, tokenData)); - }; - - const getUserInfo = () => { - return handleRequest(() => axios.get(API_PATH.user, metadata())); - }; - - const getCategory = userId => { - return handleRequest(() => - axios.get(`${API_PATH.categories}?user_id=${userId}`, metadata()), - ); - }; - - const addCategory = categoryData => { - return handleRequest(() => - axios.post(API_PATH.categories, categoryData, metadata()), - ); - }; - - const updateCategory = ({ updatedData }) => { - return handleRequest(() => - axios.patch(API_PATH.categories, updatedData, metadata()), - ); - }; - - const deleteCategory = ({ categoryId }) => { - return handleRequest(() => - axios.request({ - url: API_PATH.categories, - method: 'DELETE', - ...metadata(), - data: { category_id: categoryId }, - }), - ); - }; - - const addSubTodo = subTodoData => { - return handleRequest(() => - axios.post(API_PATH.subTodos, subTodoData, metadata()), - ); - }; - - const updateSubTodo = ({ updatedData }) => { - return handleRequest(() => - axios.patch(API_PATH.subTodos, updatedData, metadata()), - ); - }; - - const deleteSubTodo = ({ subTodoId }) => { - return handleRequest(() => - axios.request({ - url: API_PATH.subTodos, - method: 'DELETE', - ...metadata(), - data: { subtodoId: subTodoId }, - }), - ); - }; - - const getInboxTodo = userId => { - return handleRequest(() => - axios.get(`${API_PATH.inbox}?user_id=${userId}`, metadata()), - ); - }; - - const getAndroidClientId = () => { - return handleRequest(() => axios.get(API_PATH.android)); - }; - - return { - fetchTodos, - addTodo, - deleteTodo, - updateTodo, - verifyToken, - renewToken, - googleLogin, - getUserInfo, - getCategory, - addCategory, - updateCategory, - deleteCategory, - addSubTodo, - updateSubTodo, - deleteSubTodo, - getInboxTodo, - getAndroidClientId, - }; -} diff --git a/utils/api.js b/utils/api.js index b431bb0..59d3525 100644 --- a/utils/api.js +++ b/utils/api.js @@ -113,8 +113,10 @@ class Api { } try { + const { method, data, ...restOptions } = options; + const headers = { - ...options, + ...restOptions, 'Content-Type': 'application/json', }; if (this.accessToken) { @@ -122,9 +124,9 @@ class Api { } const response = await this.axiosInstance.request({ url, - method: options.method, + method, headers, - data: options.data, + data, }); return response.data; } catch (e) {