diff --git a/packages/react-native/package.json b/packages/react-native/package.json index dbcb62a3..37c70263 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -39,6 +39,7 @@ "react-native-dotenv": "^3.4.11", "react-native-element-dropdown": "^2.12.1", "react-native-gesture-handler": "^2.17.1", + "react-native-image-crop-picker": "^0.41.2", "react-native-image-picker": "^7.1.2", "react-native-linear-gradient": "^2.8.3", "react-native-mail": "^6.1.1", diff --git a/packages/react-native/src/App.tsx b/packages/react-native/src/App.tsx index bb7fb94e..b1ce33cf 100644 --- a/packages/react-native/src/App.tsx +++ b/packages/react-native/src/App.tsx @@ -2,6 +2,7 @@ import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { NavigationContainer } from '@react-navigation/native'; import StackNavigator from '@routes/StackNavigator'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Alert } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; const queryClient = new QueryClient({ @@ -10,6 +11,11 @@ const queryClient = new QueryClient({ retry: false, throwOnError: true, }, + mutations: { + onError: () => { + Alert.alert('오류가 발생했어요', '잠시뒤에 시도해보세요.'); + }, + }, }, }); export default function App() { diff --git a/packages/react-native/src/apis/mutations/useRecordRepresentativeMutation.ts b/packages/react-native/src/apis/mutations/useRecordRepresentativeMutation.ts index 81cafd7e..eee8689e 100644 --- a/packages/react-native/src/apis/mutations/useRecordRepresentativeMutation.ts +++ b/packages/react-native/src/apis/mutations/useRecordRepresentativeMutation.ts @@ -1,6 +1,6 @@ import { Alert } from 'react-native'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Asset } from 'react-native-image-picker'; +import { Image } from 'react-native-image-crop-picker'; import { REGION_MAPPER } from '@/constants/CITY'; import { KoreaLocationName } from '@/types/map'; import { AppStorage } from '@/utils/storage'; @@ -10,7 +10,7 @@ import QUERY_KEYS from '@/constants/QUERY_KEYS'; interface MutationRequestParams { region: KoreaLocationName; - image: Asset; + image: Image; } export default function useRecordRepresentativeMutation() { diff --git a/packages/react-native/src/apis/queries/mypage/useMyBadgeQuery.ts b/packages/react-native/src/apis/queries/mypage/useMyBadgeQuery.ts new file mode 100644 index 00000000..40941697 --- /dev/null +++ b/packages/react-native/src/apis/queries/mypage/useMyBadgeQuery.ts @@ -0,0 +1,65 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import useAuthAxios from '@/apis/useAuthAxios'; +import { badgePath } from '@/components/common/Badge'; +import { Region } from '@/constants/CITY'; +import QUERY_KEYS from '@/constants/QUERY_KEYS'; +import { ServerResponse } from '@/types/response'; + +interface BadgeResponse { + region: Region; + count: number; +} + +const badgeMapper = (badge: BadgeResponse): keyof typeof badgePath => { + switch (badge.region) { + case Region.SEOUL: + return '서울'; + case Region.GYEONGGI: + return '경기'; + case Region.INCHEON: + return '인천'; + case Region.GANGWON: + return '강원'; + case Region.SEJONG: + return '세종'; + case Region.DAEJEON: + return '대전'; + case Region.GWANGJU: + return '광주'; + case Region.DAEGU: + return '대구'; + case Region.ULSAN: + return '울산'; + case Region.BUSAN: + return '부산'; + case Region.CHUNGBUK || Region.CHUNGNAM: + return '충청'; + case Region.GYEONGBUK || Region.GYEONGNAM: + return '경상'; + case Region.JEONBUK || Region.JEONNAM: + return '전라'; + case Region.JEJU: + default: + return '제주'; + } +}; + +export default function useMyBadgeQuery() { + const authAxios = useAuthAxios(); + const getBadges = async () => { + const result = + await authAxios.get>('/api/user/badge'); + + const badges = result.data.result.map((badge) => ({ + count: badge.count, + badgeRegion: badgeMapper(badge), + })); + + return badges; + }; + + return useSuspenseQuery({ + queryKey: [QUERY_KEYS.MY_BADGES], + queryFn: getBadges, + }); +} diff --git a/packages/react-native/src/constants/QUERY_KEYS.ts b/packages/react-native/src/constants/QUERY_KEYS.ts index 0113fac3..5e2a94e0 100644 --- a/packages/react-native/src/constants/QUERY_KEYS.ts +++ b/packages/react-native/src/constants/QUERY_KEYS.ts @@ -14,6 +14,7 @@ const QUERY_KEYS = { SPOT_DETAIL: 'spotDetail', SEARCH: 'search', MY_SPOTS: 'mySpots', + MY_BADGES: 'myBadges', }; export default QUERY_KEYS; diff --git a/packages/react-native/src/hooks/useGallery.ts b/packages/react-native/src/hooks/useGallery.ts index 57ed7c3d..07ee2887 100644 --- a/packages/react-native/src/hooks/useGallery.ts +++ b/packages/react-native/src/hooks/useGallery.ts @@ -1,5 +1,6 @@ import { CameraRoll } from '@react-native-camera-roll/camera-roll'; import { Alert, Linking, Platform } from 'react-native'; +import ImageCropPicker from 'react-native-image-crop-picker'; import { Asset, launchImageLibrary } from 'react-native-image-picker'; import { check, @@ -126,5 +127,21 @@ export default function useGallery() { return response.assets[0].uri as GetPhotoReturnType; }; - return { savePhoto, getPhoto }; + const getCropPhoto = async () => { + const hasPermission = await hasGalleryPermission('read'); + if (!hasPermission) return Promise.reject(); + try { + const result = await ImageCropPicker.openPicker({ + width: 300, + height: 300, + cropping: true, + mediaType: 'photo', + }); + return result; + } catch (err) { + return null; + } + }; + + return { savePhoto, getPhoto, getCropPhoto }; } diff --git a/packages/react-native/src/pages/Maps/Maps.tsx b/packages/react-native/src/pages/Maps/Maps.tsx index 3b101373..217e6278 100644 --- a/packages/react-native/src/pages/Maps/Maps.tsx +++ b/packages/react-native/src/pages/Maps/Maps.tsx @@ -1,5 +1,12 @@ import { useRef, useState } from 'react'; -import { Dimensions, View, TouchableOpacity, Alert } from 'react-native'; +import { + Dimensions, + View, + TouchableOpacity, + Alert, + ScrollView, + Image as RNImage, +} from 'react-native'; import { Font } from 'design-system'; import { geoPath, geoMercator } from 'd3-geo'; import { Svg, G, Path, Image, Defs, Pattern } from 'react-native-svg'; @@ -29,28 +36,28 @@ const REGION_PATTERN_SIZE: Record< KoreaLocationName, { size: number; x: number; y: number } > = { - 강원도: { size: 0.47, x: -20, y: 30 }, + 강원도: { size: 0.5, x: -60, y: 0 }, 경기도: { size: 0.4, x: -80, y: 60 }, - 경상남도: { size: 0.3, x: 80, y: 120 }, - 경상북도: { size: 0.4, x: 40, y: -140 }, + 경상남도: { size: 0.3, x: 80, y: 100 }, + 경상북도: { size: 0.45, x: 20, y: -180 }, 광주광역시: { size: 0.08, x: 4, y: 4 }, 대구광역시: { size: 0.2, x: -10, y: 8 }, 대전광역시: { size: 0.1, x: -6, y: -8 }, 부산광역시: { size: 0.15, x: -10, y: 60 }, - 서울특별시: { size: 0.1, x: 0, y: 20 }, + 서울특별시: { size: 0.15, x: -10, y: 30 }, 세종특별자치시: { size: 0.1, x: 15, y: 50 }, 울산광역시: { size: 0.2, x: -20, y: 40 }, 인천광역시: { size: 0.2, x: -20, y: -15 }, - 전라남도: { size: 0.35, x: -80, y: 20 }, - 전라북도: { size: 0.25, x: 0, y: 130 }, + 전라남도: { size: 0.35, x: -80, y: 85 }, + 전라북도: { size: 0.25, x: 0, y: 120 }, 제주특별자치도: { size: 0.3, x: 40, y: 80 }, - 충청남도: { size: 0.3, x: -50, y: -20 }, - 충청북도: { size: 0.27, x: -50, y: 0 }, + 충청남도: { size: 0.3, x: -50, y: -30 }, + 충청북도: { size: 0.4, x: -40, y: 160 }, }; export default withSuspense(function Maps({ navigation }: MapsMainProps) { const [region, setRegion] = useState(); - const { getPhoto, savePhoto } = useGallery(); + const { savePhoto, getCropPhoto } = useGallery(); const [isButtonClicked, setButtonClicked] = useState(false); const [showBottomSheet, toggleBottomSheet] = useToggle(); const ref = useRef(null); @@ -68,7 +75,7 @@ export default withSuspense(function Maps({ navigation }: MapsMainProps) { const handleAddRegionImage = async (regionName: KoreaLocationName) => { toggleBottomSheet(); - const photo = await getPhoto({ fullObject: true }); + const photo = await getCropPhoto(); if (!photo) { Alert.alert('이미지가 선택되지 않았습니다!'); @@ -93,6 +100,20 @@ export default withSuspense(function Maps({ navigation }: MapsMainProps) { return ( + + {regionImage && + Object.values(regionImage).map( + (value, index) => + value && ( + + ), + )} +
diff --git a/packages/react-native/src/pages/MyPage/EditProfile.tsx b/packages/react-native/src/pages/MyPage/EditProfile.tsx index b9cacbdd..e8822ba4 100644 --- a/packages/react-native/src/pages/MyPage/EditProfile.tsx +++ b/packages/react-native/src/pages/MyPage/EditProfile.tsx @@ -9,6 +9,7 @@ import { StackNavigation, StackRouteProps } from '@/types/navigation'; import useNicknameMutation from '@/apis/mutations/useNicknameMutation'; import MutationLoadingModal from '@/components/common/MutationLoadingModal'; import useProfileImageMutation from '@/apis/mutations/useProfileImageMutation'; +import { AppStorage } from '@/utils/storage'; interface EditProfileProps { navigation: StackNavigation<'MyPage/EditProfile'>; @@ -76,9 +77,11 @@ export default function EditProfile({ navigation }: EditProfileProps) { 닉네임으로 프로필 사진 설정하기 - {/* FIXME: 로그아웃 기능 추가 */} Alert.alert('로그아웃')} + onPress={async () => { + await AppStorage.deleteData('token'); + navigation.navigate('Login'); + }} className="opacity-50" > diff --git a/packages/react-native/src/pages/MyPage/MyBadge.tsx b/packages/react-native/src/pages/MyPage/MyBadge.tsx index 48fceeb5..74b8f688 100644 --- a/packages/react-native/src/pages/MyPage/MyBadge.tsx +++ b/packages/react-native/src/pages/MyPage/MyBadge.tsx @@ -1,42 +1,58 @@ import { useState } from 'react'; import { FlatList, View } from 'react-native'; -import Badge, { badgePath } from '@/components/common/Badge'; +import { Font } from 'design-system'; +import Badge from '@/components/common/Badge'; import Spacing from '@/components/common/Spacing'; -import BadgeListBottomSheet from '@/components/mypage/BadgeListBottomSheet'; +import useMyBadgeQuery from '@/apis/queries/mypage/useMyBadgeQuery'; +import withSuspense from '@/components/HOC/withSuspense'; -export default function MyBadge() { - const [containerWidth, setContainerWidth] = useState(0); - const [selectedBadge, setSelectedBadge] = useState(); +export default withSuspense( + function MyBadge() { + const { data: badges } = useMyBadgeQuery(); + const [containerWidth, setContainerWidth] = useState(0); + // const [selectedBadge, setSelectedBadge] = useState(); - const locationList = Object.keys(badgePath) as (keyof typeof badgePath)[]; - const numColumns = 3; - const paddingHorizontal = 8; + const numColumns = 3; + const paddingHorizontal = 8; - return ( - <> - setContainerWidth(e.nativeEvent.layout.width)} - style={{ flex: 1, backgroundColor: 'black', paddingHorizontal }} - renderItem={({ item, index }) => ( - - setSelectedBadge(item)} - count={index % 4} - /> - - - )} - keyExtractor={(item) => item} - numColumns={numColumns} - /> - + setContainerWidth(e.nativeEvent.layout.width)} + style={{ flex: 1, backgroundColor: 'black', paddingHorizontal }} + renderItem={({ item, index }) => ( + + setSelectedBadge(item.badgeRegion)} + count={item.count} + /> + + + )} + keyExtractor={(item) => item.badgeRegion} + numColumns={numColumns} + /> + {/* setSelectedBadge(undefined)} - /> - - ); -} + /> */} + + ); + }, + { + fallback: ( + + + 잠시만 + + + 기다려주세요 + + + ), + }, +); diff --git a/packages/react-native/src/pages/MyPage/MySpot.tsx b/packages/react-native/src/pages/MyPage/MySpot.tsx index c199e50f..5b1a53fb 100644 --- a/packages/react-native/src/pages/MyPage/MySpot.tsx +++ b/packages/react-native/src/pages/MyPage/MySpot.tsx @@ -9,48 +9,62 @@ import { StackNavigation } from '@/types/navigation'; const { width } = Dimensions.get('window'); -export default withSuspense(function MySpot() { - const { data: mySpots } = useMySpotsQuery(); - const navigation = useNavigation>(); - const numColumns = 2; - const paddingHorizontal = 8; - const gap = 16; +export default withSuspense( + function MySpot() { + const { data: mySpots } = useMySpotsQuery(); + const navigation = useNavigation>(); + const numColumns = 2; + const paddingHorizontal = 8; + const gap = 16; + + if (mySpots.length === 0) { + return ( + + + 좋아요한 SPOT이 없어요 + + + ); + } - if (mySpots.length === 0) { return ( - - - 좋아요한 SPOT이 없어요 + ( + + navigation.navigate('MyPage/Detail', { + contentId: item.contentId, + id: item.id, + workId: item.workId, + }) + } + /> + )} + keyExtractor={(item) => item.name + item.id + item.contentId} + numColumns={numColumns} + /> + ); + }, + { + fallback: ( + + + 잠시만 + + + 기다려주세요 - ); - } - - return ( - ( - - navigation.navigate('MyPage/Detail', { - contentId: item.contentId, - id: item.id, - workId: item.workId, - }) - } - /> - )} - keyExtractor={(item) => item.name + item.id + item.contentId} - numColumns={numColumns} - /> - ); -}); + ), + }, +); diff --git a/packages/react-native/src/routes/MyPageTabNavigator.tsx b/packages/react-native/src/routes/MyPageTabNavigator.tsx index 3d9195b0..34b9d338 100644 --- a/packages/react-native/src/routes/MyPageTabNavigator.tsx +++ b/packages/react-native/src/routes/MyPageTabNavigator.tsx @@ -41,11 +41,6 @@ export default function MyPageTabNavigator() { component={MyBadge} options={{ tabBarLabel: 'Bagde Collection' }} /> - {/* */} ); } diff --git a/packages/react-native/src/utils/CustomForm.ts b/packages/react-native/src/utils/CustomForm.ts index 6b82f32c..dfb142bc 100644 --- a/packages/react-native/src/utils/CustomForm.ts +++ b/packages/react-native/src/utils/CustomForm.ts @@ -1,5 +1,6 @@ import { Platform } from 'react-native'; import { Asset } from 'react-native-image-picker'; +import { Image } from 'react-native-image-crop-picker'; import { getDateString, normalizeDate } from './date'; class CustomForm { @@ -13,12 +14,28 @@ class CustomForm { this.#form.append(key, value); } - appendImage(key: string, image: Asset) { + appendImage(key: string, image: Asset | Image) { + if ((image as Image).mime) { + const cropedImage = image as Image; + this.#form.append(key, { + name: `${getDateString(normalizeDate())}`, + type: cropedImage.mime, + uri: + Platform.OS === 'ios' + ? cropedImage.path?.replace('file://', '') + : cropedImage.path, + }); + return; + } + + const normalImage = image as Asset; this.#form.append(key, { - name: `${getDateString(normalizeDate())}_${image.fileName}`, - type: image.type, + name: `${getDateString(normalizeDate())}_${normalImage.fileName}`, + type: normalImage.type, uri: - Platform.OS === 'ios' ? image.uri?.replace('file://', '') : image.uri, + Platform.OS === 'ios' + ? normalImage.uri?.replace('file://', '') + : normalImage.uri, }); } diff --git a/packages/react-native/src/utils/storage.ts b/packages/react-native/src/utils/storage.ts index 337b086d..e2c5b152 100644 --- a/packages/react-native/src/utils/storage.ts +++ b/packages/react-native/src/utils/storage.ts @@ -21,7 +21,12 @@ const saveData = async ({ key, value }: SaveDataParams) => { await AsyncStorage.setItem(key, stringifiedObject); }; +const deleteData = async (key: K) => { + AsyncStorage.setItem(key, ''); +}; + export const AppStorage = { getData, saveData, + deleteData, }; diff --git a/yarn.lock b/yarn.lock index 03f20a90..f296b182 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14740,6 +14740,15 @@ __metadata: languageName: node linkType: hard +"react-native-image-crop-picker@npm:^0.41.2": + version: 0.41.2 + resolution: "react-native-image-crop-picker@npm:0.41.2" + peerDependencies: + react-native: ">=0.40.0" + checksum: c1333b3b761e948bd0bc856773f68dcc859c7ce39dda3e14d2c32c17d55927735bfc5cbbb7ac5e8bac21feda87c93399f2a596aca7d5b0611cc1e8203d97ad2e + languageName: node + linkType: hard + "react-native-image-picker@npm:^7.1.2": version: 7.1.2 resolution: "react-native-image-picker@npm:7.1.2" @@ -15001,6 +15010,7 @@ __metadata: react-native-dotenv: ^3.4.11 react-native-element-dropdown: ^2.12.1 react-native-gesture-handler: ^2.17.1 + react-native-image-crop-picker: ^0.41.2 react-native-image-picker: ^7.1.2 react-native-linear-gradient: ^2.8.3 react-native-mail: ^6.1.1