diff --git a/packages/react-native/android/app/build.gradle b/packages/react-native/android/app/build.gradle index 351b67ad..aa39b6ba 100644 --- a/packages/react-native/android/app/build.gradle +++ b/packages/react-native/android/app/build.gradle @@ -85,8 +85,8 @@ android { applicationId "com.spotclient" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 2 - versionName "1.0.1" + versionCode 3 + versionName "1.1.0" manifestPlaceholders=[KAKAO_APP_KEY:KAKAO_APP_KEY] resValue "string", "KAKAO_APP_KEY", KAKAO_APP_KEY resValue "string", "CODEPUSH_DEPLOYMENT_KEY",CODEPUSH_DEPLOYMENT_KEY diff --git a/packages/react-native/codePush.sh b/packages/react-native/codePush.sh index 0d28f876..7c57b7ea 100644 --- a/packages/react-native/codePush.sh +++ b/packages/react-native/codePush.sh @@ -14,7 +14,7 @@ appcenter codepush release \ -a rlfehd2013/SPOT \ -c ./CodePush \ -d Production \ --t 1.0.1 \ +-t 1.1.0 \ `` rm -rf CodePush \ No newline at end of file diff --git a/packages/react-native/src/apis/queries/detail/useDetailQuery.ts b/packages/react-native/src/apis/queries/detail/useDetailQuery.ts index b9b3c337..5d6da2b5 100644 --- a/packages/react-native/src/apis/queries/detail/useDetailQuery.ts +++ b/packages/react-native/src/apis/queries/detail/useDetailQuery.ts @@ -34,6 +34,9 @@ export default function useDetailQuery({ const result = await authAxios.get>( `/api/spot/${id}?workId=${workId}`, ); + const originOverview = result.data.result.overview; + result.data.result.overview = originOverview.replace('
', ''); + result.data.result.overview = originOverview.replace('
', ''); return result.data.result; }; diff --git a/packages/react-native/src/apis/queries/mypage/useMySpotsQuery.ts b/packages/react-native/src/apis/queries/mypage/useMySpotsQuery.ts index e56092c6..1e2a0ac7 100644 --- a/packages/react-native/src/apis/queries/mypage/useMySpotsQuery.ts +++ b/packages/react-native/src/apis/queries/mypage/useMySpotsQuery.ts @@ -11,6 +11,7 @@ export interface MySpotResponse { region: Region; city: City; workId: number; + workName: string; posterUrl: string; isLiked: boolean; likeCount: number; diff --git a/packages/react-native/src/apis/queries/quiz/useQuizzesQuery.ts b/packages/react-native/src/apis/queries/quiz/useQuizzesQuery.ts index 07b8f73b..f01aeebe 100644 --- a/packages/react-native/src/apis/queries/quiz/useQuizzesQuery.ts +++ b/packages/react-native/src/apis/queries/quiz/useQuizzesQuery.ts @@ -16,6 +16,7 @@ export interface QuizzesResponse { region: Region; city: City; imageUrl: string; + filterImage: string; } interface UseQuizzesQueryParams { diff --git a/packages/react-native/src/assets/filters/dokkaebi1.png b/packages/react-native/src/assets/filters/dokkaebi1.png new file mode 100644 index 00000000..238ff477 Binary files /dev/null and b/packages/react-native/src/assets/filters/dokkaebi1.png differ diff --git a/packages/react-native/src/assets/filters/dokkaebi2.png b/packages/react-native/src/assets/filters/dokkaebi2.png new file mode 100644 index 00000000..d6dca306 Binary files /dev/null and b/packages/react-native/src/assets/filters/dokkaebi2.png differ diff --git a/packages/react-native/src/assets/filters/dongbaekkkotPilMuryeop1.png b/packages/react-native/src/assets/filters/dongbaekkkotPilMuryeop1.png new file mode 100644 index 00000000..08d9a640 Binary files /dev/null and b/packages/react-native/src/assets/filters/dongbaekkkotPilMuryeop1.png differ diff --git a/packages/react-native/src/assets/filters/dongbaekkkotPilMuryeop2.png b/packages/react-native/src/assets/filters/dongbaekkkotPilMuryeop2.png new file mode 100644 index 00000000..c3d0fc43 Binary files /dev/null and b/packages/react-native/src/assets/filters/dongbaekkkotPilMuryeop2.png differ diff --git a/packages/react-native/src/assets/filters/itaewonClass.png b/packages/react-native/src/assets/filters/itaewonClass.png new file mode 100644 index 00000000..78c5c2fe Binary files /dev/null and b/packages/react-native/src/assets/filters/itaewonClass.png differ diff --git a/packages/react-native/src/assets/filters/mrSunshine.png b/packages/react-native/src/assets/filters/mrSunshine.png new file mode 100644 index 00000000..4d5546aa Binary files /dev/null and b/packages/react-native/src/assets/filters/mrSunshine.png differ diff --git a/packages/react-native/src/assets/filters/saranguiBulsiChak1.png b/packages/react-native/src/assets/filters/saranguiBulsiChak1.png new file mode 100644 index 00000000..2f18ff0e Binary files /dev/null and b/packages/react-native/src/assets/filters/saranguiBulsiChak1.png differ diff --git a/packages/react-native/src/assets/filters/saranguiBulsiChak2.png b/packages/react-native/src/assets/filters/saranguiBulsiChak2.png new file mode 100644 index 00000000..92168b44 Binary files /dev/null and b/packages/react-native/src/assets/filters/saranguiBulsiChak2.png differ diff --git a/packages/react-native/src/assets/filters/seumuldaseotSeumulHana1.png b/packages/react-native/src/assets/filters/seumuldaseotSeumulHana1.png new file mode 100644 index 00000000..a81e8f45 Binary files /dev/null and b/packages/react-native/src/assets/filters/seumuldaseotSeumulHana1.png differ diff --git a/packages/react-native/src/assets/filters/seumuldaseotSeumulHana2.png b/packages/react-native/src/assets/filters/seumuldaseotSeumulHana2.png new file mode 100644 index 00000000..2a1d1e3f Binary files /dev/null and b/packages/react-native/src/assets/filters/seumuldaseotSeumulHana2.png differ diff --git a/packages/react-native/src/components/camera/CheckPhoto.tsx b/packages/react-native/src/components/camera/CheckPhoto.tsx new file mode 100644 index 00000000..fcc0b342 --- /dev/null +++ b/packages/react-native/src/components/camera/CheckPhoto.tsx @@ -0,0 +1,81 @@ +import { forwardRef } from 'react'; +import { PhotoFile } from 'react-native-vision-camera'; +import { + Dimensions, + Image, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import { Font } from 'design-system'; +import ViewShot from 'react-native-view-shot'; +import DownloadIcon from '@/assets/DownloadIcon'; + +const { width } = Dimensions.get('window'); + +interface CheckPhotoProps { + filterUrl: string; + savePhoto: () => Promise; + resetPhoto: () => void; + photo: PhotoFile; +} + +export default forwardRef(function CheckPhoto( + { filterUrl, savePhoto, resetPhoto, photo }, + captureRef, +) { + return ( + <> + + + + + + + + + + + + + + + + + 다시찍기 + + + + + + ); +}); diff --git a/packages/react-native/src/components/camera/FilterCarousel.tsx b/packages/react-native/src/components/camera/FilterCarousel.tsx new file mode 100644 index 00000000..b00aec33 --- /dev/null +++ b/packages/react-native/src/components/camera/FilterCarousel.tsx @@ -0,0 +1,137 @@ +import React, { useRef } from 'react'; +import { Image, Dimensions, TouchableOpacity } from 'react-native'; +import Carousel, { ICarouselInstance } from 'react-native-reanimated-carousel'; +import Animated, { + interpolate, + useAnimatedStyle, +} from 'react-native-reanimated'; +import FILTER_PATHS from '@/constants/FILTER_PATHS'; + +const { width } = Dimensions.get('window'); + +const ITEM_WIDTH = width / 5; // 화면에 5개의 요소가 보이게 설정 + +interface FilterCarouselProps { + filterIndex: number; + takePhoto: () => Promise; + handleSnap: (index: number) => void; +} + +interface FilterItemProps { + item: (typeof FILTER_PATHS)[number]; + animationValue: Animated.SharedValue; + onPress: () => void; +} + +function FilterItem({ item, animationValue, onPress }: FilterItemProps) { + const animatedStyle = useAnimatedStyle(() => { + const scale = interpolate( + animationValue.value, + [0, 1, 2, 3, 4], + [0.8, 0.8, 1.1, 0.8, 0.8], + ); + const translateX = interpolate( + animationValue.value, + [0, 1, 2, 3, 4], + [0, -ITEM_WIDTH * 0.1, 0, ITEM_WIDTH * 0.1, 0], + ); + + return { + transform: [{ scale }, { translateX }], + }; + }); + + return ( + + + + + + ); +} + +// Carousel은 실제 index보다 2가 작게 시작합니다. +// 따라서 handleSnap으로 실질 인덱스와 맞출 때는 2크게 설정되어야 합니다. + +function FilterCarousel({ + filterIndex, + takePhoto, + handleSnap, +}: FilterCarouselProps) { + const carouselRef = useRef(null); + const experienceFilterLength = FILTER_PATHS.length; + + const snapToIndex = (index: number) => { + const moveMent = Math.abs(filterIndex - index); + const isLoop = moveMent >= experienceFilterLength - 2; + const vector = filterIndex - index > 0 ? 1 : -1; + + if (carouselRef.current) { + carouselRef.current.scrollTo({ + count: isLoop + ? vector * (experienceFilterLength - moveMent) + : index - filterIndex, + animated: true, + }); + } + }; + + const handleClickImage = (index: number) => { + if (index === filterIndex) { + takePhoto(); + return; + } + snapToIndex(index); + }; + + return ( + = 0 + ? filterIndex - 2 + : experienceFilterLength - 2 + filterIndex + } + scrollAnimationDuration={500} + data={FILTER_PATHS} + onSnapToItem={(index) => { + handleSnap((index + 2) % experienceFilterLength); + }} + renderItem={({ item, animationValue, index }) => ( + handleClickImage(index)} + /> + )} + /> + ); +} + +export default FilterCarousel; diff --git a/packages/react-native/src/components/camera/SpotCamera.tsx b/packages/react-native/src/components/camera/SpotCamera.tsx new file mode 100644 index 00000000..92b5ad09 --- /dev/null +++ b/packages/react-native/src/components/camera/SpotCamera.tsx @@ -0,0 +1,77 @@ +import { + Dimensions, + Image, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import { Camera } from 'react-native-vision-camera'; +import { forwardRef } from 'react'; +import useCamera from '@/hooks/useCamera'; +import ChangeIcon from '@/assets/ChangeIcon'; + +const { width } = Dimensions.get('window'); +interface SpotCameraProps { + filterUrl: string; + takePhoto: () => Promise; + hideButton?: boolean; +} + +export default forwardRef(function SpotCamera( + { filterUrl, takePhoto, hideButton }, + cameraRef, +) { + const { device, hasPermission, changeCameraPosition } = useCamera(); + + if (!device || !hasPermission) return null; + return ( + <> + + + + + {!hideButton && ( + + + + + + + + + + + )} + + ); +}); diff --git a/packages/react-native/src/components/common/Card.tsx b/packages/react-native/src/components/common/Card.tsx index 1394785c..7b125675 100644 --- a/packages/react-native/src/components/common/Card.tsx +++ b/packages/react-native/src/components/common/Card.tsx @@ -30,6 +30,7 @@ function Default({ data, size = 260 }: CardProps) { contentId, id, workId, + workName, } = data; const [cardLike, setCardLike] = useState({ @@ -107,8 +108,8 @@ function Default({ data, size = 260 }: CardProps) { {name} - - {getDisplayRegion({ locationEnum: region, cityEnum: city })} + + {`${getDisplayRegion({ locationEnum: region, cityEnum: city })} • ${workName}`} diff --git a/packages/react-native/src/components/gamification/FilterExperienceModal.tsx b/packages/react-native/src/components/gamification/FilterExperienceModal.tsx new file mode 100644 index 00000000..25e435a8 --- /dev/null +++ b/packages/react-native/src/components/gamification/FilterExperienceModal.tsx @@ -0,0 +1,50 @@ +import { Modal, TouchableOpacity, View } from 'react-native'; +import { Font } from 'design-system'; +import CancelIcon from '@/assets/CancelIcon'; + +interface FilterExperienceModal { + visible: boolean; + closeModal: () => void; + modalAction: () => void; +} + +export default function FilterExperienceModal({ + visible, + closeModal, + modalAction, +}: FilterExperienceModal) { + return ( + + + + + + + + + + + + 현재 해당 촬영지의 필터가 없습니다. + + + 다른 Spot! 필터를 체험해보세요. + + + { + modalAction(); + closeModal(); + }} + > + + Spot! 필터 체험해보기 + + + + + + + ); +} diff --git a/packages/react-native/src/components/gamification/NoQuiz.tsx b/packages/react-native/src/components/gamification/NoQuiz.tsx index 5b609681..b9ca46cb 100644 --- a/packages/react-native/src/components/gamification/NoQuiz.tsx +++ b/packages/react-native/src/components/gamification/NoQuiz.tsx @@ -1,23 +1,38 @@ -import { View } from 'react-native'; +import { TouchableOpacity, View } from 'react-native'; import { Font } from 'design-system'; +import { useNavigation } from '@react-navigation/native'; import BackGroundGradient from '@/layouts/BackGroundGradient'; +import { StackNavigation } from '@/types/navigation'; export default function NoQuiz() { + const navigation = useNavigation>(); return ( - - 현재 위치 주변에서 - - - Spot!이 검색되지 않습니다. - - - 반경 20km 이내 촬영지만 - - - 검색이 가능합니다. - + + + 현재 위치 주변에서 + + + Spot!이 검색되지 않습니다. + + + 반경 20km 이내 촬영지만 + + + 검색이 가능합니다. + + + + navigation.navigate('Camera', {})} + > + + Spot! 필터 체험해보기 + + + ); diff --git a/packages/react-native/src/components/gamification/QuizCard.tsx b/packages/react-native/src/components/gamification/QuizCard.tsx index 85cddaa1..ef15be66 100644 --- a/packages/react-native/src/components/gamification/QuizCard.tsx +++ b/packages/react-native/src/components/gamification/QuizCard.tsx @@ -4,6 +4,8 @@ import { useNavigation } from '@react-navigation/native'; import { QuizzesResponse } from '@/apis/queries/quiz/useQuizzesQuery'; import { StackNavigation } from '@/types/navigation'; import { getDisplayRegion } from '@/utils/getDisplayRegionName'; +import useToggle from '@/hooks/useToggle'; +import FilterExperienceModal from './FilterExperienceModal'; interface QuizCardProps { quizData: QuizzesResponse; @@ -15,6 +17,7 @@ export const QUIZ_CARD_SIZE = (fullWidth * 80) / 100; export default function QuizCard({ quizData }: QuizCardProps) { const navigate = useNavigation>(); + const [experiencezModalShow, setExperienceModalShow] = useToggle(false); const handleClickQuizStart = () => { navigate.navigate('Gamification/Quiz', { quizId: quizData.quizId, @@ -22,6 +25,16 @@ export default function QuizCard({ quizData }: QuizCardProps) { }); }; + const handleClickFilter = () => { + if (quizData.filterImage) { + navigate.navigate('Camera', { + filterUrl: quizData.filterImage, + }); + return; + } + setExperienceModalShow(true); + }; + return ( + setExperienceModalShow(false)} + modalAction={() => navigate.navigate('Camera', {})} + /> { - navigate.navigate('Camera'); - }} + onPress={handleClickFilter} > Spot! 필터 diff --git a/packages/react-native/src/components/gamification/QuizResultModal.tsx b/packages/react-native/src/components/gamification/QuizResultModal.tsx index d15e4262..1bdfa101 100644 --- a/packages/react-native/src/components/gamification/QuizResultModal.tsx +++ b/packages/react-native/src/components/gamification/QuizResultModal.tsx @@ -111,7 +111,7 @@ export default function QuizResultModal({ return ( - + {renderModalHeader()} {renderModalContent()} diff --git a/packages/react-native/src/components/mypage/MySpotBlock.tsx b/packages/react-native/src/components/mypage/MySpotBlock.tsx index 2c89060f..bb1662ec 100644 --- a/packages/react-native/src/components/mypage/MySpotBlock.tsx +++ b/packages/react-native/src/components/mypage/MySpotBlock.tsx @@ -31,6 +31,7 @@ export default function MySpotBlock({ mySpot, width, gap }: MySpotBlockProps) { id, contentId, workId, + workName, } = mySpot; const [cardLike, setCardLike] = useState({ @@ -108,10 +109,10 @@ export default function MySpotBlock({ mySpot, width, gap }: MySpotBlockProps) { {name} - {getDisplayRegion({ + {`${getDisplayRegion({ locationEnum: region, cityEnum: city, - })} + })} • ${workName}`} diff --git a/packages/react-native/src/constants/BADGE_ACQUISITION.ts b/packages/react-native/src/constants/BADGE_ACQUISITION.ts new file mode 100644 index 00000000..7c154c84 --- /dev/null +++ b/packages/react-native/src/constants/BADGE_ACQUISITION.ts @@ -0,0 +1,6 @@ +/* eslint-disable no-shadow */ +export enum BadgeAcquisition { + BY_QUIZ, + BY_CAMERA_FILTER, + BY_RECORD, +} diff --git a/packages/react-native/src/constants/FILTER_PATHS.ts b/packages/react-native/src/constants/FILTER_PATHS.ts new file mode 100644 index 00000000..e13d2720 --- /dev/null +++ b/packages/react-native/src/constants/FILTER_PATHS.ts @@ -0,0 +1,16 @@ +/* eslint-disable global-require */ + +const FILTER_PATHS = [ + require('../assets/filters/dokkaebi1.png'), + require('../assets/filters/dokkaebi2.png'), + require('../assets/filters/dongbaekkkotPilMuryeop1.png'), + require('../assets/filters/dongbaekkkotPilMuryeop2.png'), + require('../assets/filters/mrSunshine.png'), + require('../assets/filters/saranguiBulsiChak1.png'), + require('../assets/filters/saranguiBulsiChak2.png'), + require('../assets/filters/seumuldaseotSeumulHana1.png'), + require('../assets/filters/seumuldaseotSeumulHana2.png'), + require('../assets/filters/itaewonClass.png'), +]; + +export default FILTER_PATHS; diff --git a/packages/react-native/src/pages/CameraPage.tsx b/packages/react-native/src/pages/CameraPage.tsx index 1e255e87..7bd331e0 100644 --- a/packages/react-native/src/pages/CameraPage.tsx +++ b/packages/react-native/src/pages/CameraPage.tsx @@ -1,29 +1,25 @@ import { useRef, useState } from 'react'; -import { - Alert, - Dimensions, - Image, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native'; -import { Font } from 'design-system'; +import { Alert, View } from 'react-native'; import ViewShot from 'react-native-view-shot'; import { Camera, PhotoFile } from 'react-native-vision-camera'; -import useCamera from '@/hooks/useCamera'; -import DownloadIcon from '@/assets/DownloadIcon'; +import { useRoute } from '@react-navigation/native'; import useGallery from '@/hooks/useGallery'; -import ChangeIcon from '@/assets/ChangeIcon'; - -const { width } = Dimensions.get('window'); +import CheckPhoto from '@/components/camera/CheckPhoto'; +import SpotCamera from '@/components/camera/SpotCamera'; +import { StackRouteProps } from '@/types/navigation'; +import FILTER_PATHS from '@/constants/FILTER_PATHS'; +import FilterCarousel from '@/components/camera/FilterCarousel'; export default function CameraPage() { const camera = useRef(null); const captureRef = useRef(null); - const { device, hasPermission, changeCameraPosition } = useCamera(); - const [Filter] = useState(); const [photo, setPhoto] = useState(null); const { savePhoto: savePicture } = useGallery(); + const [experienceFilterIndex, setExperienceFilterIndex] = useState(0); + + const route = useRoute>(); + const paramsFilterUrl = route.params?.filterUrl; + const isExperience = !paramsFilterUrl; const takePhoto = async () => { if (!camera.current) return; @@ -46,97 +42,32 @@ export default function CameraPage() { setPhoto(null); }; - if (!device || !hasPermission) return null; - return ( - - {photo && ( - <> - - - - {Filter} - - - - - - - - - - - - - 다시찍기 - - - - - - )} - {!photo && ( - <> - - + {photo ? ( + + ) : ( + + + + setExperienceFilterIndex(index)} /> - {Filter} - - - - - - - - - - - + )} ); diff --git a/packages/react-native/src/pages/Setting.tsx b/packages/react-native/src/pages/Setting.tsx index 5c880992..1593df07 100644 --- a/packages/react-native/src/pages/Setting.tsx +++ b/packages/react-native/src/pages/Setting.tsx @@ -55,7 +55,7 @@ export default function Setting() { 앱 버전 정보 - 1.0.1 + 1.1.0