From 8c018ee7a73d0c125e276773a2bef61b92c6af50 Mon Sep 17 00:00:00 2001 From: sooloin Date: Sun, 16 Aug 2026 21:54:15 +0900 Subject: [PATCH 01/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=ED=95=98=EB=8B=A8=20?= =?UTF-8?q?=ED=83=AD=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B0=8F=20=ED=99=88=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (tabs)/_layout.tsx: 웹 헤더(로고+nav+프로필)를 홈/산책/커뮤니티/마이도도 4개 하단 탭바로 재설계 - (tabs)/index.tsx: 홈 화면 구현 (AI 건강 레포트 카드, 펫 정보 카드, 공지사항 리스트) — 백엔드 연동 전이라 mock 데이터 사용 - (tabs)/walk.tsx, community.tsx, mypage.tsx: 나머지 탭 플레이스홀더 추가 - constants/theme.ts: DoDo 디자인 토큰(brand, background, border 등) 추가 - (tabs)/explore.tsx: Expo 기본 템플릿 화면 제거 - RN Web 탭 라벨 텍스트가 한글 받침에서 잘리는 flex-shrink 이슈 수정 --- app/(tabs)/_layout.tsx | 57 +++++-- app/(tabs)/community.tsx | 24 +++ app/(tabs)/explore.tsx | 111 ------------- app/(tabs)/index.tsx | 332 +++++++++++++++++++++++++++++++-------- app/(tabs)/mypage.tsx | 24 +++ app/(tabs)/walk.tsx | 24 +++ constants/theme.ts | 16 ++ 7 files changed, 400 insertions(+), 188 deletions(-) create mode 100644 app/(tabs)/community.tsx delete mode 100644 app/(tabs)/explore.tsx create mode 100644 app/(tabs)/mypage.tsx create mode 100644 app/(tabs)/walk.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 495ca3a..86082e9 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,34 +1,69 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; import { Tabs } from 'expo-router'; import React from 'react'; import { HapticTab } from '@/components/haptic-tab'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; +import { DodoColors } from '@/constants/theme'; export default function TabLayout() { - const colorScheme = useColorScheme(); - return ( , + title: '홈', + tabBarIcon: ({ color, focused }) => ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), }} /> , + title: '마이도도', + tabBarIcon: ({ color, focused }) => ( + + ), }} /> diff --git a/app/(tabs)/community.tsx b/app/(tabs)/community.tsx new file mode 100644 index 0000000..1bcfdbd --- /dev/null +++ b/app/(tabs)/community.tsx @@ -0,0 +1,24 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export default function CommunityScreen() { + return ( + + 커뮤니티 화면 준비 중 + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: DodoColors.background, + }, + text: { + color: DodoColors.textSecondary, + fontSize: 15, + }, +}); diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx deleted file mode 100644 index 9570c8e..0000000 --- a/app/(tabs)/explore.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; - -import { Collapsible } from '@/components/ui/collapsible'; -import { ExternalLink } from '@/components/external-link'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Fonts } from '@/constants/theme'; - -export default function TabTwoScreen() { - return ( - - } - > - - - Explore - - - This app includes example code to help you get started. - - - This app has two screens: app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx sets up the tab - navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for different screen densities - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect what the user's - current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses the powerful{' '} - - react-native-reanimated - {' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx component provides a - parallax effect for the header image. - - ), - })} - - - ); -} - -const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', - }, - titleContainer: { - flexDirection: 'row', - gap: 8, - }, -}); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index acfe4dd..6e21f9b 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,82 +1,282 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { HelloWave } from '@/components/hello-wave'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Link } from 'expo-router'; +import { DodoColors } from '@/constants/theme'; + +// TODO(이슈4): GET /main 연동 후 mock 제거 +const MOCK_PET = { + name: '푸치', + ageLabel: '만 0세', + breed: '말티즈', + weight: '6 kg', +}; + +const MOCK_PET_PHOTOS = [0, 1, 2, 3, 4]; + +const MOCK_NOTICES: { tag: '안내' | '긴급'; title: string }[] = [ + { tag: '안내', title: '겨울 시즌 산책 챌린지 오픈!' }, + { tag: '긴급', title: '일부 알림 지연 현상 안내' }, + { tag: '안내', title: '산책 중 위험 지역 사용자 제보 요청' }, + { tag: '안내', title: '반려동물 프로필 개선 업데이트' }, +]; export default function HomeScreen() { return ( - } - > - - Welcome! - - - - Step 1: Try it - - Edit app/(tabs)/index.tsx to see changes. Press{' '} - - {Platform.select({ - ios: 'cmd + d', - android: 'cmd + m', - web: 'F12', - })} - {' '} - to open developer tools. - - - - - - Step 2: Explore - - - - alert('Action pressed')} /> - alert('Share pressed')} /> - - alert('Delete pressed')} /> - - - + + {/* AI 건강 레포트 */} + + + + AI 건강 레포트 + + + + + + + {MOCK_PET.name}의 건강 데이터를 분석 중이에요. + + {MOCK_PET.name}의 첫 건강 레포트를 만들 수 있도록 산책과 건강 기록을 조금 더 쌓아보세요. + + + + 레포트 준비 중 + + + + + {/* 펫 카드 */} + + + + + + + {MOCK_PET.name} + + + {MOCK_PET.ageLabel} + + - {`Tap the Explore tab to learn more about what's included in this starter app.`} - - - Step 3: Get a fresh start - - {`When you're ready, run `} - npm run reset-project to get a fresh{' '} - app directory. This will move the current{' '} - app to{' '} - app-example. - - - + + + 품종 + {MOCK_PET.breed} + + + 체중 + {MOCK_PET.weight} + + + + + {MOCK_PET_PHOTOS.map((id, index) => ( + + + + ))} + + + + {/* 공지사항 */} + + + 공지사항 + + + + + + {MOCK_NOTICES.map((notice, index) => ( + + + + {notice.tag} + + + + {notice.title} + + + ))} + + ); } const styles = StyleSheet.create({ - titleContainer: { + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + gap: 12, + paddingBottom: 32, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + }, + cardHeaderRow: { flexDirection: 'row', alignItems: 'center', - gap: 8, + gap: 6, }, - stepContainer: { - gap: 8, + cardHeaderText: { + fontSize: 15, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + reportIllustration: { + alignSelf: 'center', + width: 96, + height: 96, + borderRadius: 48, + backgroundColor: DodoColors.background, + alignItems: 'center', + justifyContent: 'center', + marginVertical: 16, + }, + reportTitle: { + fontSize: 17, + fontWeight: '700', + color: DodoColors.textPrimary, + marginBottom: 6, + }, + reportDescription: { + fontSize: 13, + lineHeight: 19, + color: DodoColors.textSecondary, + }, + reportLinkRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 2, + marginTop: 12, + }, + reportLinkText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + petHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + petAvatar: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + petNameCol: { + flex: 1, + }, + petName: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + ageBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 999, + backgroundColor: DodoColors.background, + borderWidth: 1, + borderColor: DodoColors.border, + }, + ageBadgeText: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + petStatsRow: { + flexDirection: 'row', + gap: 24, + marginTop: 16, + }, + petStatItem: { + gap: 4, + }, + petStatLabel: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + petStatValue: { + fontSize: 15, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + thumbnailRow: { + marginTop: 16, + }, + thumbnail: { + width: 48, + height: 48, + borderRadius: 12, + backgroundColor: DodoColors.background, + borderWidth: 1, + borderColor: DodoColors.border, + alignItems: 'center', + justifyContent: 'center', + marginRight: 8, + }, + thumbnailSelected: { + borderColor: DodoColors.secondary, + borderWidth: 2, + }, + noticeHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', marginBottom: 8, }, - reactLogo: { - height: 178, - width: 290, - bottom: 0, - left: 0, - position: 'absolute', + noticeHeaderText: { + fontSize: 16, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + noticeRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: DodoColors.background, + }, + noticeRowLast: { + borderBottomWidth: 0, + }, + noticeTag: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + backgroundColor: '#dcfce7', + }, + noticeTagUrgent: { + backgroundColor: '#fee2e2', + }, + noticeTagText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.fenceActiveLabel, + }, + noticeTagTextUrgent: { + color: DodoColors.fenceOutside, + }, + noticeTitle: { + flex: 1, + fontSize: 14, + color: DodoColors.textPrimary, }, }); diff --git a/app/(tabs)/mypage.tsx b/app/(tabs)/mypage.tsx new file mode 100644 index 0000000..5b570b8 --- /dev/null +++ b/app/(tabs)/mypage.tsx @@ -0,0 +1,24 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export default function MyPageScreen() { + return ( + + 마이도도 화면 준비 중 + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: DodoColors.background, + }, + text: { + color: DodoColors.textSecondary, + fontSize: 15, + }, +}); diff --git a/app/(tabs)/walk.tsx b/app/(tabs)/walk.tsx new file mode 100644 index 0000000..8ffecbc --- /dev/null +++ b/app/(tabs)/walk.tsx @@ -0,0 +1,24 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export default function WalkScreen() { + return ( + + 산책 화면 준비 중 + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: DodoColors.background, + }, + text: { + color: DodoColors.textSecondary, + fontSize: 15, + }, +}); diff --git a/constants/theme.ts b/constants/theme.ts index f06facd..027b1d9 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -27,6 +27,22 @@ export const Colors = { }, }; +export const DodoColors = { + brand: '#e56c31', + brandForeground: '#fefefe', + secondary: '#f6b93b', + secondaryForeground: '#fefefe', + background: '#fafafa', + surface: '#ffffff', + border: '#e5e5e5', + textPrimary: '#171717', + textSecondary: '#404040', + fenceInside: '#22c55e', + fenceOutside: '#ef4444', + fenceActiveLabel: '#16a34a', + fenceIdleLabel: '#6b7280', +}; + export const Fonts = Platform.select({ ios: { /** iOS `UIFontDescriptorSystemDesignDefault` */ From df6959f20885604efff155c4141f2aa4ea1e791b Mon Sep 17 00:00:00 2001 From: sooloin Date: Sun, 16 Aug 2026 22:21:32 +0900 Subject: [PATCH 02/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8/=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (tabs)/community/index.tsx: 인기 게시물/최근 게시물 리스트, 상단 "내 활동" 링크, 우측 하단 글쓰기 FAB — PC 사이드바(글쓰기/내 활동)를 앱 패턴으로 재배치 - (tabs)/community/[boardId].tsx: 게시물 상세 (작성자/사진/본문/좋아요·싫어요/ 댓글) 한 화면으로 통합, boardId 기준 mock 데이터 조회 - (tabs)/community/_layout.tsx: 리스트→상세 이동용 자체 Stack 네비게이터, 커스텀 뒤로가기 버튼 헤더에 연결 - (tabs)/community/new.tsx, my.tsx: 글쓰기/내 활동 화면 스텁 추가 - 백엔드 연동 전이라 mock 데이터 사용 --- app/(tabs)/community/[boardId].tsx | 293 ++++++++++++++++++ app/(tabs)/community/_layout.tsx | 40 +++ app/(tabs)/community/index.tsx | 293 ++++++++++++++++++ .../{community.tsx => community/my.tsx} | 4 +- app/(tabs)/community/new.tsx | 24 ++ 5 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 app/(tabs)/community/[boardId].tsx create mode 100644 app/(tabs)/community/_layout.tsx create mode 100644 app/(tabs)/community/index.tsx rename app/(tabs)/{community.tsx => community/my.tsx} (78%) create mode 100644 app/(tabs)/community/new.tsx diff --git a/app/(tabs)/community/[boardId].tsx b/app/(tabs)/community/[boardId].tsx new file mode 100644 index 0000000..ca2c10b --- /dev/null +++ b/app/(tabs)/community/[boardId].tsx @@ -0,0 +1,293 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useLocalSearchParams } from 'expo-router'; +import { useState } from 'react'; +import { + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +// TODO(이슈4): GET /boards/{boardId}, GET /comments/{boardId} 연동 후 mock 제거 +const MOCK_POSTS_BY_ID: Record< + string, + { + author: string; + date: string; + viewCount: number; + title: string; + body: string; + likeCount: number; + dislikeCount: number; + isOwner: boolean; + } +> = { + '1': { + author: '조펭이', + date: '2026. 06. 12. 오후 07:33', + viewCount: 5, + title: '안녕', + body: 'ㅎㅇ', + likeCount: 4, + dislikeCount: 2, + isOwner: true, + }, + '2': { + author: '조펭이', + date: '2026. 06. 12. 오후 07:40', + viewCount: 2, + title: '마루는 강쥐', + body: '귀여움', + likeCount: 3, + dislikeCount: 0, + isOwner: true, + }, + '3': { + author: '꾸우', + date: '2026. 06. 15. 오후 03:12', + viewCount: 15, + title: '안녕하세요', + body: '저는 야르릉입니다!!!', + likeCount: 2, + dislikeCount: 0, + isOwner: false, + }, +}; + +export default function BoardDetailScreen() { + const { boardId } = useLocalSearchParams<{ boardId: string }>(); + const [comment, setComment] = useState(''); + const post = MOCK_POSTS_BY_ID[boardId] ?? MOCK_POSTS_BY_ID['1']; + + return ( + + + + + + + + + {post.author} + + {post.date} + | + + 조회 {post.viewCount} + + + + + {post.isOwner && ( + + + 수정 + + + 삭제 + + + )} + + + + {post.title} + + + + + + {post.body} + + + + + + + 좋아요 {post.likeCount} + + + + 싫어요 {post.dislikeCount} + + + + 댓글 0 + + 아직 댓글이 없어요. 첫 댓글을 남겨보세요. + + + + + + + 등록 + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + flex: { + flex: 1, + }, + content: { + padding: 16, + paddingBottom: 24, + gap: 12, + }, + authorRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + avatar: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + authorTextCol: { + gap: 2, + }, + authorName: { + fontSize: 15, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + authorMetaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 3, + }, + authorMetaText: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + postMetaDivider: { + fontSize: 11, + color: DodoColors.border, + marginHorizontal: 4, + }, + editRow: { + flexDirection: 'row', + gap: 12, + }, + editLink: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + divider: { + height: 1, + backgroundColor: DodoColors.border, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + photo: { + width: '100%', + height: 220, + borderRadius: 16, + backgroundColor: DodoColors.surface, + borderWidth: 1, + borderColor: DodoColors.border, + alignItems: 'center', + justifyContent: 'center', + }, + body: { + fontSize: 14, + lineHeight: 21, + color: DodoColors.textPrimary, + }, + reactionRow: { + flexDirection: 'row', + gap: 10, + }, + reactionButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.surface, + }, + reactionText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + commentHeader: { + fontSize: 15, + fontWeight: '700', + color: DodoColors.textPrimary, + marginTop: 8, + }, + commentEmpty: { + paddingVertical: 24, + alignItems: 'center', + }, + commentEmptyText: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + }, + commentInputRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + padding: 12, + borderTopWidth: 1, + borderTopColor: DodoColors.border, + backgroundColor: DodoColors.surface, + }, + commentInput: { + flex: 1, + height: 40, + borderRadius: 999, + backgroundColor: DodoColors.background, + paddingHorizontal: 16, + fontSize: 13, + color: DodoColors.textPrimary, + }, + commentSubmit: { + paddingHorizontal: 16, + height: 40, + borderRadius: 999, + backgroundColor: DodoColors.textPrimary, + alignItems: 'center', + justifyContent: 'center', + }, + commentSubmitText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, +}); diff --git a/app/(tabs)/community/_layout.tsx b/app/(tabs)/community/_layout.tsx new file mode 100644 index 0000000..015f94f --- /dev/null +++ b/app/(tabs)/community/_layout.tsx @@ -0,0 +1,40 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Stack, useRouter } from 'expo-router'; +import { TouchableOpacity } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +function BackButton() { + const router = useRouter(); + return ( + router.back()} hitSlop={8}> + + + ); +} + +export default function CommunityStackLayout() { + return ( + + + , + }} + /> + + + + ); +} diff --git a/app/(tabs)/community/index.tsx b/app/(tabs)/community/index.tsx new file mode 100644 index 0000000..b3b6ac2 --- /dev/null +++ b/app/(tabs)/community/index.tsx @@ -0,0 +1,293 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Link } from 'expo-router'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +// TODO(이슈4): GET /boards, /boards?sort=popular 연동 후 mock 제거 +type BoardPost = { + id: string; + title: string; + preview: string; + author: string; + date: string; + likeCount: number; + commentCount: number; + viewCount: number; +}; + +const MOCK_POPULAR: BoardPost[] = [ + { + id: '1', + title: '안녕', + preview: 'ㅎㅇ', + author: '조펭이', + date: '6월 12일', + likeCount: 4, + commentCount: 0, + viewCount: 5, + }, + { + id: '2', + title: '마루는 강쥐', + preview: '귀여움', + author: '조펭이', + date: '6월 12일', + likeCount: 3, + commentCount: 0, + viewCount: 2, + }, + { + id: '3', + title: '안녕하세요', + preview: '저는 야르릉입니다!!!', + author: '꾸우', + date: '6월 15일', + likeCount: 2, + commentCount: 1, + viewCount: 15, + }, +]; + +const MOCK_RECENT: BoardPost[] = [ + { + id: '3', + title: '안녕하세요', + preview: '저는 야르릉입니다!!!', + author: '꾸우', + date: '6월 15일', + likeCount: 2, + commentCount: 1, + viewCount: 15, + }, + { + id: '1', + title: '안녕', + preview: 'ㅎㅇ', + author: '조펭이', + date: '6월 12일', + likeCount: 4, + commentCount: 0, + viewCount: 5, + }, +]; + +function PostRow({ post }: { post: BoardPost }) { + return ( + + + + + {post.title} + + + {post.preview} + + + + {post.likeCount} + + {post.commentCount} + | + {post.date} + | + {post.author} + | + 조회 {post.viewCount} + + + + + + + + ); +} + +export default function CommunityListScreen() { + return ( + + + + 커뮤니티 + + + + 내 활동 + + + + + + POPULAR PICKS + 인기 게시물 + 지금 커뮤니티에서 반응이 좋은 이야기를 먼저 만나보세요. + + + {MOCK_POPULAR.map((post, index) => ( + + + {index < MOCK_POPULAR.length - 1 && } + + ))} + + + + + COMMUNITY BOARD + 최근 게시물 + 반려생활 속 소소한 기록부터 유용한 팁까지 한눈에 둘러보세요. + + + {MOCK_RECENT.map((post, index) => ( + + + {index < MOCK_RECENT.length - 1 && } + + ))} + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 96, + gap: 20, + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + screenTitle: { + fontSize: 20, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + myActivityLink: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: DodoColors.surface, + borderWidth: 1, + borderColor: DodoColors.border, + }, + myActivityText: { + fontSize: 12, + color: DodoColors.textSecondary, + fontWeight: '600', + }, + section: { + gap: 4, + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + sectionSubtitle: { + fontSize: 12, + color: DodoColors.textSecondary, + marginBottom: 12, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + paddingHorizontal: 16, + }, + postRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + gap: 12, + }, + postTextCol: { + flex: 1, + gap: 4, + }, + postTitle: { + fontSize: 15, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + postPreview: { + fontSize: 13, + color: DodoColors.textSecondary, + }, + postMetaRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 2, + }, + postMetaText: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + marginLeft: 3, + }, + postMetaDivider: { + fontSize: 11, + color: DodoColors.border, + marginHorizontal: 6, + }, + metaIconGap: { + marginLeft: 8, + }, + postThumbnail: { + width: 48, + height: 48, + borderRadius: 10, + backgroundColor: DodoColors.background, + alignItems: 'center', + justifyContent: 'center', + }, + postDivider: { + height: 1, + backgroundColor: DodoColors.background, + }, + fab: { + position: 'absolute', + right: 20, + bottom: 20, + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 4, + }, +}); diff --git a/app/(tabs)/community.tsx b/app/(tabs)/community/my.tsx similarity index 78% rename from app/(tabs)/community.tsx rename to app/(tabs)/community/my.tsx index 1bcfdbd..f97b1df 100644 --- a/app/(tabs)/community.tsx +++ b/app/(tabs)/community/my.tsx @@ -2,10 +2,10 @@ import { StyleSheet, Text, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; -export default function CommunityScreen() { +export default function MyActivityScreen() { return ( - 커뮤니티 화면 준비 중 + 내 활동 화면 준비 중 ); } diff --git a/app/(tabs)/community/new.tsx b/app/(tabs)/community/new.tsx new file mode 100644 index 0000000..0294312 --- /dev/null +++ b/app/(tabs)/community/new.tsx @@ -0,0 +1,24 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export default function NewPostScreen() { + return ( + + 글쓰기 화면 준비 중 + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: DodoColors.background, + }, + text: { + color: DodoColors.textSecondary, + fontSize: 15, + }, +}); From 990f7207c7d3a4b06cf0b34dacaf906091bb7c6c Mon Sep 17 00:00:00 2001 From: sooloin Date: Sun, 16 Aug 2026 22:36:34 +0900 Subject: [PATCH 03/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1/=EB=82=B4=20=ED=99=9C=EB=8F=99=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (tabs)/community/new.tsx: 게시글 작성 폼 (제목/내용/이미지 첨부) + 하단 고정 액션바(취소/임시저장/게시하기), 제목·내용 입력 시에만 게시하기 활성화 - (tabs)/community/my.tsx: 내 활동 화면 — 내 게시글/내 댓글 탭 전환, 페이지네이션 UI. PC 사이드바(프로필/글쓰기/내활동)는 중복이라 제외 - components/community/post-row.tsx: 게시물 리스트 행을 공용 컴포넌트로 분리해 커뮤니티 목록·내 활동 화면에서 재사용 - (tabs)/community/_layout.tsx: new/my 화면 헤더(뒤로가기) 옵션 추가 - 이미지 첨부는 UI만 구현, 실제 업로드는 이슈7(expo-image-picker) 이후 연동 --- app/(tabs)/community/_layout.tsx | 23 +++- app/(tabs)/community/index.tsx | 125 +------------------ app/(tabs)/community/my.tsx | 172 ++++++++++++++++++++++++-- app/(tabs)/community/new.tsx | 193 ++++++++++++++++++++++++++++-- components/community/post-row.tsx | 125 +++++++++++++++++++ 5 files changed, 499 insertions(+), 139 deletions(-) create mode 100644 components/community/post-row.tsx diff --git a/app/(tabs)/community/_layout.tsx b/app/(tabs)/community/_layout.tsx index 015f94f..fe28a41 100644 --- a/app/(tabs)/community/_layout.tsx +++ b/app/(tabs)/community/_layout.tsx @@ -33,8 +33,27 @@ export default function CommunityStackLayout() { headerLeft: () => , }} /> - - + , + }} + /> + , + }} + /> ); } diff --git a/app/(tabs)/community/index.tsx b/app/(tabs)/community/index.tsx index b3b6ac2..1dfa332 100644 --- a/app/(tabs)/community/index.tsx +++ b/app/(tabs)/community/index.tsx @@ -2,20 +2,10 @@ import Ionicons from '@expo/vector-icons/Ionicons'; import { Link } from 'expo-router'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { BoardPost, PostListCard } from '@/components/community/post-row'; import { DodoColors } from '@/constants/theme'; // TODO(이슈4): GET /boards, /boards?sort=popular 연동 후 mock 제거 -type BoardPost = { - id: string; - title: string; - preview: string; - author: string; - date: string; - likeCount: number; - commentCount: number; - viewCount: number; -}; - const MOCK_POPULAR: BoardPost[] = [ { id: '1', @@ -72,43 +62,6 @@ const MOCK_RECENT: BoardPost[] = [ }, ]; -function PostRow({ post }: { post: BoardPost }) { - return ( - - - - - {post.title} - - - {post.preview} - - - - {post.likeCount} - - {post.commentCount} - | - {post.date} - | - {post.author} - | - 조회 {post.viewCount} - - - - - - - - ); -} - export default function CommunityListScreen() { return ( @@ -127,30 +80,14 @@ export default function CommunityListScreen() { POPULAR PICKS 인기 게시물 지금 커뮤니티에서 반응이 좋은 이야기를 먼저 만나보세요. - - - {MOCK_POPULAR.map((post, index) => ( - - - {index < MOCK_POPULAR.length - 1 && } - - ))} - + COMMUNITY BOARD 최근 게시물 반려생활 속 소소한 기록부터 유용한 팁까지 한눈에 둘러보세요. - - - {MOCK_RECENT.map((post, index) => ( - - - {index < MOCK_RECENT.length - 1 && } - - ))} - + @@ -218,62 +155,6 @@ const styles = StyleSheet.create({ color: DodoColors.textSecondary, marginBottom: 12, }, - card: { - backgroundColor: DodoColors.surface, - borderRadius: 16, - borderWidth: 1, - borderColor: DodoColors.border, - paddingHorizontal: 16, - }, - postRow: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 14, - gap: 12, - }, - postTextCol: { - flex: 1, - gap: 4, - }, - postTitle: { - fontSize: 15, - fontWeight: '700', - color: DodoColors.textPrimary, - }, - postPreview: { - fontSize: 13, - color: DodoColors.textSecondary, - }, - postMetaRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 2, - }, - postMetaText: { - fontSize: 11, - color: DodoColors.fenceIdleLabel, - marginLeft: 3, - }, - postMetaDivider: { - fontSize: 11, - color: DodoColors.border, - marginHorizontal: 6, - }, - metaIconGap: { - marginLeft: 8, - }, - postThumbnail: { - width: 48, - height: 48, - borderRadius: 10, - backgroundColor: DodoColors.background, - alignItems: 'center', - justifyContent: 'center', - }, - postDivider: { - height: 1, - backgroundColor: DodoColors.background, - }, fab: { position: 'absolute', right: 20, diff --git a/app/(tabs)/community/my.tsx b/app/(tabs)/community/my.tsx index f97b1df..287349e 100644 --- a/app/(tabs)/community/my.tsx +++ b/app/(tabs)/community/my.tsx @@ -1,24 +1,180 @@ -import { StyleSheet, Text, View } from 'react-native'; +import { useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { BoardPost, PostListCard } from '@/components/community/post-row'; import { DodoColors } from '@/constants/theme'; +// TODO(이슈4): GET /boards/me, GET /comments/me 연동 후 mock 제거 +const MOCK_MY_POSTS: BoardPost[] = [ + { + id: '4', + title: '오늘 산책하다 만난 귀여운 친구들', + preview: '귀여운 아기들을 봤어요~~', + author: '조펭이', + date: '6월 12일', + likeCount: 1, + commentCount: 0, + viewCount: 4, + }, + { + id: '2', + title: '마루는 강쥐', + preview: '귀여움', + author: '조펭이', + date: '6월 12일', + likeCount: 3, + commentCount: 0, + viewCount: 2, + }, + { + id: '1', + title: '안녕', + preview: 'ㅎㅇ', + author: '조펭이', + date: '6월 12일', + likeCount: 4, + commentCount: 0, + viewCount: 5, + }, +]; + +type TabKey = 'posts' | 'comments'; + export default function MyActivityScreen() { + const [activeTab, setActiveTab] = useState('posts'); + return ( - - 내 활동 화면 준비 중 - + + MY COMMUNITY + 내 활동 + 내가 남긴 게시글과 댓글 활동을 한곳에서 확인해보세요. + + + setActiveTab('posts')}> + 내 게시글 + {activeTab === 'posts' && } + + setActiveTab('comments')}> + 내 댓글 + {activeTab === 'comments' && } + + + + {activeTab === 'posts' ? ( + + ) : ( + + 댓글 목록 준비 중 + + )} + + + + 이전 + + + 1 + + + 다음 + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - alignItems: 'center', - justifyContent: 'center', backgroundColor: DodoColors.background, }, - text: { + content: { + padding: 16, + paddingBottom: 32, + gap: 4, + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + subtitle: { + fontSize: 12, color: DodoColors.textSecondary, - fontSize: 15, + marginBottom: 16, + }, + tabBar: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: DodoColors.border, + marginBottom: 12, + }, + tabItem: { + paddingVertical: 10, + marginRight: 24, + alignItems: 'center', + }, + tabLabel: { + fontSize: 14, + fontWeight: '600', + color: DodoColors.fenceIdleLabel, + }, + tabLabelActive: { + color: DodoColors.textPrimary, + }, + tabIndicator: { + marginTop: 8, + height: 2, + width: '100%', + backgroundColor: DodoColors.brand, + }, + commentsEmptyCard: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + paddingVertical: 40, + alignItems: 'center', + }, + commentsEmptyText: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + }, + pagination: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + marginTop: 20, + }, + pageButton: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + pageButtonTextDisabled: { + fontSize: 12, + color: DodoColors.border, + }, + pageNumberActive: { + width: 32, + height: 32, + borderRadius: 8, + backgroundColor: DodoColors.textPrimary, + alignItems: 'center', + justifyContent: 'center', + }, + pageNumberActiveText: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.brandForeground, }, }); diff --git a/app/(tabs)/community/new.tsx b/app/(tabs)/community/new.tsx index 0294312..b6d411b 100644 --- a/app/(tabs)/community/new.tsx +++ b/app/(tabs)/community/new.tsx @@ -1,24 +1,203 @@ -import { StyleSheet, Text, View } from 'react-native'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import { DodoColors } from '@/constants/theme'; export default function NewPostScreen() { + const router = useRouter(); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const canSubmit = title.trim().length > 0 && content.trim().length > 0; + return ( - - 글쓰기 화면 준비 중 - + + + + 게시글 제목 * + + + + + 게시글 내용 * + + + + + 이미지 첨부 + PNG, JPG 형식 / 최대 10MB + {/* TODO(이슈7): expo-image-picker 설치 후 실제 첨부 연동 */} + + + 이미지 추가 + + + 아직 첨부된 이미지가 없어요. + + + + * 필수 입력 항목입니다. + + + + router.back()}> + 취소 + + + 임시 저장 + + + 게시하기 + + + ); } const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 20, + }, + field: { + gap: 8, + }, + label: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + hint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + marginTop: -4, + }, + input: { + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.surface, + paddingHorizontal: 12, + fontSize: 14, + color: DodoColors.textPrimary, + }, + textarea: { + height: 160, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.surface, + padding: 12, + fontSize: 14, + color: DodoColors.textPrimary, + }, + addImageButton: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: 6, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.surface, + }, + addImageText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + imageEmpty: { + borderRadius: 10, + borderWidth: 1, + borderStyle: 'dashed', + borderColor: DodoColors.border, + paddingVertical: 20, + alignItems: 'center', + justifyContent: 'center', + }, + imageEmptyText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + requiredHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + actionBar: { + flexDirection: 'row', + gap: 8, + padding: 12, + borderTopWidth: 1, + borderTopColor: DodoColors.border, + backgroundColor: DodoColors.surface, + }, + secondaryButton: { + flex: 1, + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, alignItems: 'center', justifyContent: 'center', - backgroundColor: DodoColors.background, }, - text: { + secondaryButtonText: { + fontSize: 13, + fontWeight: '600', color: DodoColors.textSecondary, - fontSize: 15, + }, + primaryButton: { + flex: 1.4, + height: 44, + borderRadius: 10, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonDisabled: { + opacity: 0.4, + }, + primaryButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, }, }); diff --git a/components/community/post-row.tsx b/components/community/post-row.tsx new file mode 100644 index 0000000..be58379 --- /dev/null +++ b/components/community/post-row.tsx @@ -0,0 +1,125 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Link } from 'expo-router'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export type BoardPost = { + id: string; + title: string; + preview: string; + author: string; + date: string; + likeCount: number; + commentCount: number; + viewCount: number; +}; + +export function PostRow({ post }: { post: BoardPost }) { + return ( + + + + + {post.title} + + + {post.preview} + + + + {post.likeCount} + + {post.commentCount} + | + {post.date} + | + {post.author} + | + 조회 {post.viewCount} + + + + + + + + ); +} + +export function PostListCard({ posts }: { posts: BoardPost[] }) { + return ( + + {posts.map((post, index) => ( + + + {index < posts.length - 1 && } + + ))} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + paddingHorizontal: 16, + }, + postRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + gap: 12, + }, + postTextCol: { + flex: 1, + gap: 4, + }, + postTitle: { + fontSize: 15, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + postPreview: { + fontSize: 13, + color: DodoColors.textSecondary, + }, + postMetaRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 2, + }, + postMetaText: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + marginLeft: 3, + }, + postMetaDivider: { + fontSize: 11, + color: DodoColors.border, + marginHorizontal: 6, + }, + metaIconGap: { + marginLeft: 8, + }, + postThumbnail: { + width: 48, + height: 48, + borderRadius: 10, + backgroundColor: DodoColors.background, + alignItems: 'center', + justifyContent: 'center', + }, + postDivider: { + height: 1, + backgroundColor: DodoColors.background, + }, +}); From 68051d160a13cf4d141ee85a0c0566e8ff4a7aa3 Mon Sep 17 00:00:00 2001 From: sooloin Date: Sun, 16 Aug 2026 22:51:13 +0900 Subject: [PATCH 04/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=82=B0=EC=B1=85=20?= =?UTF-8?q?=ED=83=AD=20=EC=9A=B8=ED=83=80=EB=A6=AC=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (tabs)/walk.tsx: 반려동물 선택 칩(5마리) + 울타리 정보(반경/사용상태) + 수정 버튼 UI 구현, 칩 선택에 따라 하단 정보 전환 - 지도는 실제 렌더링 대신 플레이스홀더로 표시 — @mj-studio/react-native-naver-map은 네이티브 모듈이라 이슈1(앱 식별자 확정 + 네이버 지도 콘솔 등록) 완료 후 설치 예정 - 산책(실시간 위치 추적) 화면은 이번 범위에서 제외, 울타리 설정만 우선 구현 --- app/(tabs)/walk.tsx | 207 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 201 insertions(+), 6 deletions(-) diff --git a/app/(tabs)/walk.tsx b/app/(tabs)/walk.tsx index 8ffecbc..f12ace7 100644 --- a/app/(tabs)/walk.tsx +++ b/app/(tabs)/walk.tsx @@ -1,24 +1,219 @@ -import { StyleSheet, Text, View } from 'react-native'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; +// TODO(이슈4/§6): GET /pets/list, GET /fence/boundaries 연동 후 mock 제거 +// TODO(이슈1 완료 후): @mj-studio/react-native-naver-map 설치 + 실제 지도 렌더링으로 교체 +type PetFence = { + petId: string; + petName: string; + radiusMeters: number; + active: boolean; +}; + +const MOCK_FENCES: PetFence[] = [ + { petId: 'sundubu', petName: '순두부', radiusMeters: 200, active: true }, + { petId: 'godeungeo', petName: '고등어', radiusMeters: 400, active: true }, + { petId: 'maltese', petName: '말티즈', radiusMeters: 250, active: true }, + { petId: 'pold', petName: '폴드', radiusMeters: 180, active: false }, + { petId: 'puchi', petName: '푸치', radiusMeters: 300, active: true }, +]; + export default function WalkScreen() { + const [selectedPetId, setSelectedPetId] = useState(MOCK_FENCES[1].petId); + const selectedFence = MOCK_FENCES.find((f) => f.petId === selectedPetId) ?? MOCK_FENCES[0]; + return ( - - 산책 화면 준비 중 - + + 산책 + 반려동물 안전 울타리를 설정하세요. + + + 울타리 설정 + + 반려동물 선택 + + {MOCK_FENCES.map((fence) => { + const selected = fence.petId === selectedPetId; + return ( + setSelectedPetId(fence.petId)} + > + {fence.petName} + + ); + })} + + + + + 네이버 지도 연동 예정 + 앱 식별자·콘솔 등록(이슈1) 완료 후 실제 지도로 교체됩니다 + + + + + + {selectedFence.petName} + + + {selectedFence.active ? '사용 중' : '미사용'} + + + + 반경 {selectedFence.radiusMeters}m + + + 울타리 수정 + + + + ); } const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + subtitle: { + fontSize: 12, + color: DodoColors.textSecondary, + marginTop: 2, + marginBottom: 16, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + color: DodoColors.textPrimary, + marginBottom: 16, + }, + label: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + marginBottom: 8, + }, + chipRow: { + marginBottom: 16, + }, + chip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + marginRight: 8, + }, + chipSelected: { + borderColor: DodoColors.brand, + backgroundColor: DodoColors.brand, + }, + chipText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + chipTextSelected: { + color: DodoColors.brandForeground, + }, + mapPlaceholder: { + height: 260, + borderRadius: 12, + backgroundColor: DodoColors.background, + borderWidth: 1, + borderColor: DodoColors.border, alignItems: 'center', justifyContent: 'center', + gap: 6, + marginBottom: 16, + }, + mapPlaceholderText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + mapPlaceholderHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + paddingHorizontal: 32, + textAlign: 'center', + }, + fenceInfoRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + fenceInfoTextCol: { + gap: 4, + }, + fenceInfoNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + fenceInfoName: { + fontSize: 16, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + }, + statusBadgeActive: { + backgroundColor: '#dcfce7', + }, + statusBadgeIdle: { backgroundColor: DodoColors.background, }, - text: { + statusBadgeText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.fenceIdleLabel, + }, + statusBadgeTextActive: { + color: DodoColors.fenceActiveLabel, + }, + fenceInfoRadius: { + fontSize: 13, + color: DodoColors.textSecondary, + }, + editButton: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + editButtonText: { + fontSize: 12, + fontWeight: '600', color: DodoColors.textSecondary, - fontSize: 15, }, }); From 158d14a43df25d1d517da56e7eed022f5cd97e39 Mon Sep 17 00:00:00 2001 From: sooloin Date: Thu, 27 Aug 2026 20:50:51 +0900 Subject: [PATCH 05/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=A7=80=EB=8F=84=20=EC=97=B0=EB=8F=99=20=EB=B0=8F?= =?UTF-8?q?=20OAuth=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.json: 앱 식별자(com.dodo.app) 확정, @mj-studio/react-native-naver-map config plugin 등록(Client ID 포함) - (tabs)/walk.tsx + components/walk/fence-map.tsx: 산책 울타리 화면에 실제 네이버 지도(NaverMapView, NaverMapCircleOverlay) 연동 - components/walk/fence-map.web.tsx: 네이버 지도가 네이티브 전용 모듈이라 react-native-web에서 렌더링이 안 되는 문제 대응 — 지도 렌더링 부분만 컴포넌트 단위로 분리해 웹 프리뷰에서는 안내 플레이스홀더로 대체 (route 파일 단위 분리는 expo-router에서 동작하지 않아 컴포넌트 레벨로 해결) - shared/lib/auth/tokenStorage.ts: accessToken/refreshToken은 expo-secure-store, 비민감 프로필 정보는 AsyncStorage에 저장. 웹은 SecureStore 미지원이라 AsyncStorage로 폴백 처리 - shared/lib/auth/authStore.ts: Zustand 기반 인증 상태 스토어 (웹의 'dodo:auth-state-change' 커스텀 이벤트를 대체) - shared/lib/auth/oauth.ts: expo-auth-session/expo-web-browser 기반 소셜 로그인 플로우, CSRF state 검증 포함 - shared/lib/auth/refreshAccessToken.ts: 동시 요청 시 중복 리프레시를 막는 in-flight promise 공유 처리 - shared/api/axios.ts: 요청 시 accessToken 자동 주입(+ 만료 임박 시 사전 갱신), 401 응답 시 리프레시 후 재시도 인터셉터 추가 - app/_layout.tsx: react-native-url-polyfill 적용, 앱 시작 시 세션 hydrate - OAuth 시작/리프레시 엔드포인트는 백엔드 스펙 미확정 상태라 Spring Security 기본 컨벤션을 가정한 TODO로 표시해 둔 상태 --- app.json | 11 +- app/(tabs)/walk.tsx | 219 +--------------------- app/_layout.tsx | 9 + components/walk/fence-map.tsx | 36 ++++ components/walk/fence-map.web.tsx | 46 +++++ components/walk/fence-settings-screen.tsx | 212 +++++++++++++++++++++ package.json | 6 + shared/api/axios.ts | 34 +++- shared/lib/auth/authStore.ts | 53 ++++++ shared/lib/auth/oauth.ts | 65 +++++++ shared/lib/auth/refreshAccessToken.ts | 52 +++++ shared/lib/auth/tokenStorage.ts | 100 ++++++++++ yarn.lock | 71 ++++++- 13 files changed, 693 insertions(+), 221 deletions(-) create mode 100644 components/walk/fence-map.tsx create mode 100644 components/walk/fence-map.web.tsx create mode 100644 components/walk/fence-settings-screen.tsx create mode 100644 shared/lib/auth/authStore.ts create mode 100644 shared/lib/auth/oauth.ts create mode 100644 shared/lib/auth/refreshAccessToken.ts create mode 100644 shared/lib/auth/tokenStorage.ts diff --git a/app.json b/app.json index 690818b..5dad73a 100644 --- a/app.json +++ b/app.json @@ -9,9 +9,11 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { + "bundleIdentifier": "com.dodo.app", "supportsTablet": true }, "android": { + "package": "com.dodo.app", "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", @@ -38,7 +40,14 @@ "backgroundColor": "#000000" } } - ] + ], + [ + "@mj-studio/react-native-naver-map", + { + "client_id": "furxnmcb69" + } + ], + "expo-secure-store" ], "experiments": { "typedRoutes": true, diff --git a/app/(tabs)/walk.tsx b/app/(tabs)/walk.tsx index f12ace7..7e91646 100644 --- a/app/(tabs)/walk.tsx +++ b/app/(tabs)/walk.tsx @@ -1,219 +1,10 @@ -import Ionicons from '@expo/vector-icons/Ionicons'; -import { useState } from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; - -import { DodoColors } from '@/constants/theme'; - -// TODO(이슈4/§6): GET /pets/list, GET /fence/boundaries 연동 후 mock 제거 -// TODO(이슈1 완료 후): @mj-studio/react-native-naver-map 설치 + 실제 지도 렌더링으로 교체 -type PetFence = { - petId: string; - petName: string; - radiusMeters: number; - active: boolean; -}; - -const MOCK_FENCES: PetFence[] = [ - { petId: 'sundubu', petName: '순두부', radiusMeters: 200, active: true }, - { petId: 'godeungeo', petName: '고등어', radiusMeters: 400, active: true }, - { petId: 'maltese', petName: '말티즈', radiusMeters: 250, active: true }, - { petId: 'pold', petName: '폴드', radiusMeters: 180, active: false }, - { petId: 'puchi', petName: '푸치', radiusMeters: 300, active: true }, -]; +import { FenceMap } from '@/components/walk/fence-map'; +import { FenceSettingsScreen } from '@/components/walk/fence-settings-screen'; export default function WalkScreen() { - const [selectedPetId, setSelectedPetId] = useState(MOCK_FENCES[1].petId); - const selectedFence = MOCK_FENCES.find((f) => f.petId === selectedPetId) ?? MOCK_FENCES[0]; - return ( - - 산책 - 반려동물 안전 울타리를 설정하세요. - - - 울타리 설정 - - 반려동물 선택 - - {MOCK_FENCES.map((fence) => { - const selected = fence.petId === selectedPetId; - return ( - setSelectedPetId(fence.petId)} - > - {fence.petName} - - ); - })} - - - - - 네이버 지도 연동 예정 - 앱 식별자·콘솔 등록(이슈1) 완료 후 실제 지도로 교체됩니다 - - - - - - {selectedFence.petName} - - - {selectedFence.active ? '사용 중' : '미사용'} - - - - 반경 {selectedFence.radiusMeters}m - - - 울타리 수정 - - - - + } + /> ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: DodoColors.background, - }, - content: { - padding: 16, - paddingBottom: 32, - }, - title: { - fontSize: 20, - fontWeight: '700', - color: DodoColors.textPrimary, - }, - subtitle: { - fontSize: 12, - color: DodoColors.textSecondary, - marginTop: 2, - marginBottom: 16, - }, - card: { - backgroundColor: DodoColors.surface, - borderRadius: 16, - borderWidth: 1, - borderColor: DodoColors.border, - padding: 16, - }, - sectionTitle: { - fontSize: 16, - fontWeight: '700', - color: DodoColors.textPrimary, - marginBottom: 16, - }, - label: { - fontSize: 12, - fontWeight: '600', - color: DodoColors.textSecondary, - marginBottom: 8, - }, - chipRow: { - marginBottom: 16, - }, - chip: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 999, - borderWidth: 1, - borderColor: DodoColors.border, - backgroundColor: DodoColors.background, - marginRight: 8, - }, - chipSelected: { - borderColor: DodoColors.brand, - backgroundColor: DodoColors.brand, - }, - chipText: { - fontSize: 13, - fontWeight: '600', - color: DodoColors.textSecondary, - }, - chipTextSelected: { - color: DodoColors.brandForeground, - }, - mapPlaceholder: { - height: 260, - borderRadius: 12, - backgroundColor: DodoColors.background, - borderWidth: 1, - borderColor: DodoColors.border, - alignItems: 'center', - justifyContent: 'center', - gap: 6, - marginBottom: 16, - }, - mapPlaceholderText: { - fontSize: 13, - fontWeight: '600', - color: DodoColors.textSecondary, - }, - mapPlaceholderHint: { - fontSize: 11, - color: DodoColors.fenceIdleLabel, - paddingHorizontal: 32, - textAlign: 'center', - }, - fenceInfoRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - fenceInfoTextCol: { - gap: 4, - }, - fenceInfoNameRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - fenceInfoName: { - fontSize: 16, - fontWeight: '700', - color: DodoColors.textPrimary, - }, - statusBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 999, - }, - statusBadgeActive: { - backgroundColor: '#dcfce7', - }, - statusBadgeIdle: { - backgroundColor: DodoColors.background, - }, - statusBadgeText: { - fontSize: 11, - fontWeight: '600', - color: DodoColors.fenceIdleLabel, - }, - statusBadgeTextActive: { - color: DodoColors.fenceActiveLabel, - }, - fenceInfoRadius: { - fontSize: 13, - color: DodoColors.textSecondary, - }, - editButton: { - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 8, - borderWidth: 1, - borderColor: DodoColors.border, - }, - editButtonText: { - fontSize: 12, - fontWeight: '600', - color: DodoColors.textSecondary, - }, -}); diff --git a/app/_layout.tsx b/app/_layout.tsx index 7e3ff3f..6475b1e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,10 +1,14 @@ +import 'react-native-url-polyfill/auto'; + import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { QueryClientProvider } from '@tanstack/react-query'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; +import { useEffect } from 'react'; import 'react-native-reanimated'; import { useColorScheme } from '@/hooks/use-color-scheme'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; import { queryClient } from '@/shared/lib/react-query/queryClient'; export const unstable_settings = { @@ -13,6 +17,11 @@ export const unstable_settings = { export default function RootLayout() { const colorScheme = useColorScheme(); + const hydrate = useAuthStore((state) => state.hydrate); + + useEffect(() => { + hydrate(); + }, [hydrate]); return ( diff --git a/components/walk/fence-map.tsx b/components/walk/fence-map.tsx new file mode 100644 index 0000000..b8246a7 --- /dev/null +++ b/components/walk/fence-map.tsx @@ -0,0 +1,36 @@ +import { NaverMapCircleOverlay, NaverMapView } from '@mj-studio/react-native-naver-map'; + +import { DodoColors } from '@/constants/theme'; + +import type { PetFence } from './fence-settings-screen'; +import { styles } from './fence-settings-screen'; + +type Props = { + selectedFence: PetFence; + fences: PetFence[]; +}; + +export function FenceMap({ selectedFence, fences }: Props) { + return ( + + {fences.map((fence) => { + const selected = fence.petId === selectedFence.petId; + return ( + + ); + })} + + ); +} diff --git a/components/walk/fence-map.web.tsx b/components/walk/fence-map.web.tsx new file mode 100644 index 0000000..d6200a8 --- /dev/null +++ b/components/walk/fence-map.web.tsx @@ -0,0 +1,46 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +import type { PetFence } from './fence-settings-screen'; +import { styles as screenStyles } from './fence-settings-screen'; + +type Props = { + selectedFence: PetFence; + fences: PetFence[]; +}; + +// 네이버 지도 SDK는 네이티브 전용 모듈이라 react-native-web에서 렌더링이 불가능하다. +// 웹 프리뷰에서는 안내 플레이스홀더만 보여주고, 실제 지도는 Android/iOS 빌드에서 확인한다. +export function FenceMap({ selectedFence }: Props) { + return ( + + + 네이버 지도는 웹 프리뷰 미지원 + + {selectedFence.petName}의 울타리는 Android 에뮬레이터/실기기 빌드에서 확인하세요 + + + ); +} + +const styles = StyleSheet.create({ + placeholder: { + backgroundColor: DodoColors.background, + alignItems: 'center', + justifyContent: 'center', + gap: 6, + }, + placeholderText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + placeholderHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + paddingHorizontal: 32, + textAlign: 'center', + }, +}); diff --git a/components/walk/fence-settings-screen.tsx b/components/walk/fence-settings-screen.tsx new file mode 100644 index 0000000..34d6e69 --- /dev/null +++ b/components/walk/fence-settings-screen.tsx @@ -0,0 +1,212 @@ +import { ReactNode, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +// TODO(이슈4/§6): GET /pets/list, GET /fence/boundaries 연동 후 mock 제거 +export type PetFence = { + petId: string; + petName: string; + radiusMeters: number; + active: boolean; + latitude: number; + longitude: number; +}; + +export const MOCK_FENCES: PetFence[] = [ + { petId: 'sundubu', petName: '순두부', radiusMeters: 200, active: true, latitude: 37.5665, longitude: 126.978 }, + { petId: 'godeungeo', petName: '고등어', radiusMeters: 400, active: true, latitude: 37.5796, longitude: 126.977 }, + { petId: 'maltese', petName: '말티즈', radiusMeters: 250, active: true, latitude: 37.5636, longitude: 126.9834 }, + { petId: 'pold', petName: '폴드', radiusMeters: 180, active: false, latitude: 37.5711, longitude: 126.988 }, + { petId: 'puchi', petName: '푸치', radiusMeters: 300, active: true, latitude: 37.5704, longitude: 127.001 }, +]; + +type RenderMapParams = { + selectedFence: PetFence; + fences: PetFence[]; +}; + +type Props = { + /** + * 네이버 지도는 네이티브 전용 모듈이라 walk.tsx(native)/walk.web.tsx(web)로 + * 화면을 분리하고, 그 차이만 이 render prop으로 주입한다. + */ + renderMap: (params: RenderMapParams) => ReactNode; +}; + +export function FenceSettingsScreen({ renderMap }: Props) { + const [selectedPetId, setSelectedPetId] = useState(MOCK_FENCES[1].petId); + const selectedFence = MOCK_FENCES.find((f) => f.petId === selectedPetId) ?? MOCK_FENCES[0]; + + return ( + + 산책 + 반려동물 안전 울타리를 설정하세요. + + + 울타리 설정 + + 반려동물 선택 + + {MOCK_FENCES.map((fence) => { + const selected = fence.petId === selectedPetId; + return ( + setSelectedPetId(fence.petId)} + > + {fence.petName} + + ); + })} + + + {renderMap({ selectedFence, fences: MOCK_FENCES })} + + + + + {selectedFence.petName} + + + {selectedFence.active ? '사용 중' : '미사용'} + + + + 반경 {selectedFence.radiusMeters}m + + + 울타리 수정 + + + + + ); +} + +export const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + subtitle: { + fontSize: 12, + color: DodoColors.textSecondary, + marginTop: 2, + marginBottom: 16, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + color: DodoColors.textPrimary, + marginBottom: 16, + }, + label: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + marginBottom: 8, + }, + chipRow: { + marginBottom: 16, + }, + chip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + marginRight: 8, + }, + chipSelected: { + borderColor: DodoColors.brand, + backgroundColor: DodoColors.brand, + }, + chipText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + chipTextSelected: { + color: DodoColors.brandForeground, + }, + map: { + height: 260, + borderRadius: 12, + overflow: 'hidden', + marginBottom: 16, + }, + fenceInfoRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + fenceInfoTextCol: { + gap: 4, + }, + fenceInfoNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + fenceInfoName: { + fontSize: 16, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + }, + statusBadgeActive: { + backgroundColor: '#dcfce7', + }, + statusBadgeIdle: { + backgroundColor: DodoColors.background, + }, + statusBadgeText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.fenceIdleLabel, + }, + statusBadgeTextActive: { + color: DodoColors.fenceActiveLabel, + }, + fenceInfoRadius: { + fontSize: 13, + color: DodoColors.textSecondary, + }, + editButton: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + editButtonText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, +}); diff --git a/package.json b/package.json index 4f9b953..7f5ef38 100644 --- a/package.json +++ b/package.json @@ -17,18 +17,23 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@mj-studio/react-native-naver-map": "^2.9.0", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@tanstack/react-query": "^5.100.9", "axios": "^1.16.0", "expo": "~54.0.33", + "expo-auth-session": "~7.0.11", "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.9", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-linking": "~8.0.11", "expo-router": "~6.0.23", + "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", @@ -41,6 +46,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-url-polyfill": "^4.0.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "zustand": "^5.0.13" diff --git a/shared/api/axios.ts b/shared/api/axios.ts index 4a5cbdd..dd0f9fb 100644 --- a/shared/api/axios.ts +++ b/shared/api/axios.ts @@ -1,6 +1,10 @@ -import axios from 'axios'; +import axios, { type InternalAxiosRequestConfig } from 'axios'; import { apiConfig } from '@/shared/config'; +import { refreshAccessToken } from '@/shared/lib/auth/refreshAccessToken'; +import * as tokenStorage from '@/shared/lib/auth/tokenStorage'; + +type RetryableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean }; export const apiClient = axios.create({ baseURL: apiConfig.baseURL, @@ -11,7 +15,17 @@ export const apiClient = axios.create({ }); apiClient.interceptors.request.use( - (config) => { + async (config) => { + let accessToken = await tokenStorage.getAccessToken(); + + // 만료 60초 전이면 요청 전에 미리 갱신 (웹의 ACCESS_TOKEN_REFRESH_BUFFER_MS와 동일한 전략) + if (accessToken && (await tokenStorage.isAccessTokenExpiringSoon())) { + accessToken = (await refreshAccessToken()) ?? accessToken; + } + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } return config; }, (error) => Promise.reject(error), @@ -19,5 +33,19 @@ apiClient.interceptors.request.use( apiClient.interceptors.response.use( (response) => response, - (error) => Promise.reject(error), + async (error) => { + const originalRequest = error.config as RetryableRequestConfig | undefined; + + // 사전 갱신을 놓친 경우(서버 측 강제 만료 등)를 대비한 사후 처리 + if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { + originalRequest._retry = true; + const newAccessToken = await refreshAccessToken(); + if (newAccessToken) { + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return apiClient(originalRequest); + } + } + + return Promise.reject(error); + }, ); diff --git a/shared/lib/auth/authStore.ts b/shared/lib/auth/authStore.ts new file mode 100644 index 0000000..76d6e3e --- /dev/null +++ b/shared/lib/auth/authStore.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand'; + +import * as tokenStorage from './tokenStorage'; +import type { AuthProfile, TokenPair } from './tokenStorage'; + +type AuthState = { + /** 앱 시작 시 SecureStore/AsyncStorage에서 세션 복원이 끝났는지 여부 */ + isHydrated: boolean; + isAuthenticated: boolean; + profile: AuthProfile; + hydrate: () => Promise; + setSession: (params: { tokens: TokenPair; profile: AuthProfile }) => Promise; + updateProfile: (profile: Partial) => Promise; + clearSession: () => Promise; +}; + +const EMPTY_PROFILE: AuthProfile = { + profileUrl: null, + nickname: null, + region: null, + notificationEnabled: false, +}; + +// 웹의 window.dispatchEvent(new Event('dodo:auth-state-change'))를 대체. +// 이 store를 구독하는 컴포넌트는 setSession/clearSession 호출 시 자동으로 리렌더된다. +export const useAuthStore = create((set) => ({ + isHydrated: false, + isAuthenticated: false, + profile: EMPTY_PROFILE, + + hydrate: async () => { + const [accessToken, profile] = await Promise.all([tokenStorage.getAccessToken(), tokenStorage.getProfile()]); + set({ isHydrated: true, isAuthenticated: !!accessToken, profile }); + }, + + setSession: async ({ tokens, profile }) => { + await Promise.all([tokenStorage.saveTokens(tokens), tokenStorage.saveProfile(profile)]); + set({ isAuthenticated: true, profile }); + }, + + updateProfile: async (partial) => { + set((state) => { + const nextProfile = { ...state.profile, ...partial }; + tokenStorage.saveProfile(nextProfile); + return { profile: nextProfile }; + }); + }, + + clearSession: async () => { + await Promise.all([tokenStorage.clearTokens(), tokenStorage.clearProfile()]); + set({ isAuthenticated: false, profile: EMPTY_PROFILE }); + }, +})); diff --git a/shared/lib/auth/oauth.ts b/shared/lib/auth/oauth.ts new file mode 100644 index 0000000..632ee2d --- /dev/null +++ b/shared/lib/auth/oauth.ts @@ -0,0 +1,65 @@ +import * as Crypto from 'expo-crypto'; +import * as Linking from 'expo-linking'; +import * as WebBrowser from 'expo-web-browser'; + +import { apiConfig } from '@/shared/config'; + +import type { TokenPair } from './tokenStorage'; + +export type SocialProvider = 'google' | 'naver'; + +export type SocialLoginResult = + | { success: true; tokens: TokenPair } + | { success: false; reason: 'cancelled' | 'error' }; + +/** + * TODO(백엔드 협의 필요): 정확한 OAuth 시작 엔드포인트/파라미터명 미확정. + * 아래는 Spring Security OAuth2 Client 기본 컨벤션(`/oauth2/authorization/{provider}`)을 + * 가정한 값이며, 콜백에 accessToken/refreshToken/accessTokenTtlMs를 쿼리 파라미터로 + * 실어 돌려준다고 가정한다. 웹의 buildSocialAuthUrl()과 실제 스펙을 맞춰야 한다. + * 딥링크(dodoapp://auth/callback/{provider})는 Google/Naver 콘솔 + 백엔드 양쪽에 + * 등록되어 있어야 정상 동작한다 (docs/DODO_RN_PORTING_SPEC.md §5 참고). + */ +function buildAuthorizeUrl(provider: SocialProvider, redirectUri: string, state: string): string { + const url = new URL(`${apiConfig.baseURL}/oauth2/authorization/${provider}`); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('state', state); + return url.toString(); +} + +export async function startSocialLogin(provider: SocialProvider): Promise { + const redirectUri = Linking.createURL(`auth/callback/${provider}`); + // 웹의 CSRF state 검증(sessionStorage에 저장 후 콜백에서 비교)과 동일한 목적 + const state = Crypto.randomUUID(); + const authorizeUrl = buildAuthorizeUrl(provider, redirectUri, state); + + const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, redirectUri); + + if (result.type !== 'success' || !result.url) { + return { success: false, reason: result.type === 'cancel' || result.type === 'dismiss' ? 'cancelled' : 'error' }; + } + + const callbackUrl = new URL(result.url); + const returnedState = callbackUrl.searchParams.get('state'); + if (returnedState !== state) { + throw new Error('OAuth state mismatch — 위조된 콜백일 수 있어 로그인을 중단합니다.'); + } + + const accessToken = callbackUrl.searchParams.get('accessToken'); + const refreshToken = callbackUrl.searchParams.get('refreshToken'); + const accessTokenTtlMs = Number(callbackUrl.searchParams.get('accessTokenTtlMs') ?? 0); + + if (!accessToken || !refreshToken) { + throw new Error('로그인 콜백에 토큰이 없습니다. 백엔드 redirect_uri 파라미터 스펙을 확인하세요.'); + } + + return { + success: true, + tokens: { + accessToken, + refreshToken, + accessTokenTtlMs, + accessTokenExpiresAt: Date.now() + accessTokenTtlMs, + }, + }; +} diff --git a/shared/lib/auth/refreshAccessToken.ts b/shared/lib/auth/refreshAccessToken.ts new file mode 100644 index 0000000..309ac2d --- /dev/null +++ b/shared/lib/auth/refreshAccessToken.ts @@ -0,0 +1,52 @@ +import axios from 'axios'; + +import { apiConfig } from '@/shared/config'; + +import { useAuthStore } from './authStore'; +import * as tokenStorage from './tokenStorage'; + +/** + * TODO(백엔드 협의 필요): 정확한 리프레시 엔드포인트/요청·응답 필드명 미확정. + * 아래는 { refreshToken } 요청 → { accessToken, refreshToken, accessTokenTtlMs } 응답을 + * 가정한 값이며, 실제 스펙 확인 후 수정해야 한다. + */ +async function requestNewAccessToken(refreshToken: string) { + const { data } = await axios.post<{ accessToken: string; refreshToken?: string; accessTokenTtlMs: number }>( + `${apiConfig.baseURL}/auth/refresh`, + { refreshToken }, + ); + return data; +} + +// 동시에 여러 요청이 401을 받아도 리프레시 호출은 한 번만 나가도록 in-flight promise를 공유한다. +let refreshPromise: Promise | null = null; + +export async function refreshAccessToken(): Promise { + if (!refreshPromise) { + refreshPromise = performRefresh().finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} + +async function performRefresh(): Promise { + const refreshToken = await tokenStorage.getRefreshToken(); + if (!refreshToken) return null; + + try { + const result = await requestNewAccessToken(refreshToken); + const accessTokenTtlMs = result.accessTokenTtlMs; + await tokenStorage.saveTokens({ + accessToken: result.accessToken, + refreshToken: result.refreshToken ?? refreshToken, + accessTokenTtlMs, + accessTokenExpiresAt: Date.now() + accessTokenTtlMs, + }); + return result.accessToken; + } catch { + // 리프레시 실패 = 재로그인 필요. 세션을 지워서 화면단에서 로그인 상태로 전환되게 한다. + await useAuthStore.getState().clearSession(); + return null; + } +} diff --git a/shared/lib/auth/tokenStorage.ts b/shared/lib/auth/tokenStorage.ts new file mode 100644 index 0000000..6c5a5d8 --- /dev/null +++ b/shared/lib/auth/tokenStorage.ts @@ -0,0 +1,100 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as SecureStore from 'expo-secure-store'; +import { Platform } from 'react-native'; + +// 웹(dodo-frontend)의 localStorage/sessionStorage 키 이름을 그대로 따름 (shared/lib/auth/token.ts 참고) +const SECURE_KEYS = { + accessToken: 'accessToken', + refreshToken: 'dodo.refreshToken', +} as const; + +// expo-secure-store는 웹에서 빈 구현체(export default {})라 호출 시 즉시 에러가 난다. +// 웹은 화면 미리보기 용도일 뿐 실제 로그인 세션이 필요 없으므로, 웹에서만 +// AsyncStorage(localStorage)로 대체한다. 실제 보안이 필요한 네이티브에서는 그대로 SecureStore 사용. +const secureStorage = { + getItem: (key: string) => (Platform.OS === 'web' ? AsyncStorage.getItem(key) : SecureStore.getItemAsync(key)), + setItem: (key: string, value: string) => + Platform.OS === 'web' ? AsyncStorage.setItem(key, value) : SecureStore.setItemAsync(key, value), + removeItem: (key: string) => + Platform.OS === 'web' ? AsyncStorage.removeItem(key) : SecureStore.deleteItemAsync(key), +}; + +const PROFILE_KEYS = { + profileUrl: 'profileUrl', + nickname: 'nickname', + region: 'region', + notificationEnabled: 'notificationEnabled', +} as const; + +export type TokenPair = { + accessToken: string; + refreshToken: string; + /** epoch ms */ + accessTokenExpiresAt: number; + accessTokenTtlMs: number; +}; + +export type AuthProfile = { + profileUrl: string | null; + nickname: string | null; + region: string | null; + notificationEnabled: boolean; +}; + +// 웹은 accessToken 만료 60초 전에 갱신한다 (ACCESS_TOKEN_REFRESH_BUFFER_MS) +export const ACCESS_TOKEN_REFRESH_BUFFER_MS = 60_000; + +export async function saveTokens(tokens: TokenPair): Promise { + await Promise.all([ + secureStorage.setItem(SECURE_KEYS.accessToken, tokens.accessToken), + secureStorage.setItem(SECURE_KEYS.refreshToken, tokens.refreshToken), + AsyncStorage.setItem('accessTokenExpiresAt', String(tokens.accessTokenExpiresAt)), + AsyncStorage.setItem('accessTokenTtlMs', String(tokens.accessTokenTtlMs)), + ]); +} + +export async function getAccessToken(): Promise { + return secureStorage.getItem(SECURE_KEYS.accessToken); +} + +export async function getRefreshToken(): Promise { + return secureStorage.getItem(SECURE_KEYS.refreshToken); +} + +export async function isAccessTokenExpiringSoon(): Promise { + const expiresAt = await AsyncStorage.getItem('accessTokenExpiresAt'); + if (!expiresAt) return true; + return Number(expiresAt) - Date.now() <= ACCESS_TOKEN_REFRESH_BUFFER_MS; +} + +export async function clearTokens(): Promise { + await Promise.all([ + secureStorage.removeItem(SECURE_KEYS.accessToken), + secureStorage.removeItem(SECURE_KEYS.refreshToken), + AsyncStorage.multiRemove(['accessTokenExpiresAt', 'accessTokenTtlMs']), + ]); +} + +export async function saveProfile(profile: AuthProfile): Promise { + await AsyncStorage.multiSet([ + [PROFILE_KEYS.profileUrl, profile.profileUrl ?? ''], + [PROFILE_KEYS.nickname, profile.nickname ?? ''], + [PROFILE_KEYS.region, profile.region ?? ''], + [PROFILE_KEYS.notificationEnabled, String(profile.notificationEnabled)], + ]); +} + +export async function getProfile(): Promise { + const entries = await AsyncStorage.multiGet(Object.values(PROFILE_KEYS)); + const values = Object.fromEntries(entries); + return { + profileUrl: values[PROFILE_KEYS.profileUrl] || null, + nickname: values[PROFILE_KEYS.nickname] || null, + region: values[PROFILE_KEYS.region] || null, + notificationEnabled: values[PROFILE_KEYS.notificationEnabled] === 'true', + }; +} + +export async function clearProfile(): Promise { + await AsyncStorage.multiRemove(Object.values(PROFILE_KEYS)); +} diff --git a/yarn.lock b/yarn.lock index c3de72e..0b728e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1380,6 +1380,13 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@mj-studio/react-native-naver-map@^2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@mj-studio/react-native-naver-map/-/react-native-naver-map-2.9.0.tgz#b54298cc379fc101d4e17f9fde97ce725039dd9e" + integrity sha512-eExomWy56gomqUcEvHZaj5XKQJboazFdNBR8BlwQQO3W9jSz3SWKAg4XGQluce89CFEghb7n6be7isxiRrWUJg== + dependencies: + object-hash "^3.0.0" + "@napi-rs/wasm-runtime@^0.2.11": version "0.2.12" resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" @@ -1574,6 +1581,13 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e" integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ== +"@react-native-async-storage/async-storage@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad" + integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== + dependencies: + merge-options "^3.0.4" + "@react-native/assets-registry@0.81.5": version "0.81.5" resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.81.5.tgz#d22c924fa6f6d4a463c5af34ce91f38756c0fa7d" @@ -2572,7 +2586,7 @@ balanced-match@^4.0.2: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== -base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: +base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -3558,6 +3572,11 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== +expo-application@~7.0.8: + version "7.0.8" + resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.8.tgz#320af0d6c39b331456d3bc833b25763c702d23db" + integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q== + expo-asset@~12.0.13: version "12.0.13" resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.13.tgz#1974ed7abee2ad987a519dbdcbf7f0c647dddf5b" @@ -3566,6 +3585,18 @@ expo-asset@~12.0.13: "@expo/image-utils" "^0.8.8" expo-constants "~18.0.13" +expo-auth-session@~7.0.11: + version "7.0.11" + resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-7.0.11.tgz#fa9510862030962eac0147708ef28d6c63df3f71" + integrity sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ== + dependencies: + expo-application "~7.0.8" + expo-constants "~18.0.13" + expo-crypto "~15.0.9" + expo-linking "~8.0.12" + expo-web-browser "~15.0.11" + invariant "^2.2.4" + expo-constants@~18.0.13: version "18.0.13" resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d" @@ -3574,6 +3605,13 @@ expo-constants@~18.0.13: "@expo/config" "~12.0.13" "@expo/env" "~2.0.8" +expo-crypto@~15.0.9: + version "15.0.9" + resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-15.0.9.tgz#5600c2e08e9946a8b1dfc8ceaa9891714b79ca8a" + integrity sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ== + dependencies: + base64-js "^1.3.0" + expo-file-system@~19.0.22: version "19.0.22" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b" @@ -3601,7 +3639,7 @@ expo-keep-awake@~15.0.8: resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d" integrity sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ== -expo-linking@~8.0.11: +expo-linking@~8.0.11, expo-linking@~8.0.12: version "8.0.12" resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-8.0.12.tgz#773bb2d18b89750af87fa807a6298d519130bdb6" integrity sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ== @@ -3656,6 +3694,11 @@ expo-router@~6.0.23: use-latest-callback "^0.2.1" vaul "^1.1.2" +expo-secure-store@~15.0.8: + version "15.0.8" + resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-15.0.8.tgz#678065599bb76061b5a85b15b9426bf7a11089ae" + integrity sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw== + expo-server@^1.0.5, expo-server@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-1.0.6.tgz#a821c55dd3f9f3f9bce0ba70d81bbc9fcc360ee8" @@ -3690,7 +3733,7 @@ expo-system-ui@~6.0.9: "@react-native/normalize-colors" "0.81.5" debug "^4.3.2" -expo-web-browser@~15.0.10: +expo-web-browser@~15.0.10, expo-web-browser@~15.0.11: version "15.0.11" resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-15.0.11.tgz#6a8134e2398031ef79c89f516b15a18103903ef5" integrity sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw== @@ -4388,6 +4431,11 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + is-regex@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" @@ -4910,6 +4958,13 @@ memoize-one@^6.0.0: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== +merge-options@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.4.tgz#84709c2aa2a4b24c1981f66c179fe5565cc6dbb7" + integrity sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ== + dependencies: + is-plain-obj "^2.1.0" + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -5520,6 +5575,11 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== +object-hash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== + object-inspect@^1.13.3, object-inspect@^1.13.4: version "1.13.4" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" @@ -5997,6 +6057,11 @@ react-native-screens@~4.16.0: react-native-is-edge-to-edge "^1.2.1" warn-once "^0.1.0" +react-native-url-polyfill@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-4.0.0.tgz#a8d204654bd341eba304091ae350b89866878bd7" + integrity sha512-eqYM3wBAA0eL1sPYbBAoNfbES3+NkgcxUdelQ7QzmoVtqKB5qGG0U13MPTRUroAWK+y2EoJFS3MZUK0fwTf0pA== + react-native-web@~0.21.0: version "0.21.2" resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.21.2.tgz#0f6983dfea600d9cc1c66fda87ff9ca585eaa647" From 38b1354f0d06587c61445c7a89464f29e8d993a1 Mon Sep 17 00:00:00 2001 From: sooloin Date: Thu, 27 Aug 2026 22:14:16 +0900 Subject: [PATCH 06/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=8F=84=20=ED=83=AD=20=ED=99=94=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (tabs)/mypage/index.tsx: 프로필 카드 + 메뉴 리스트로 재구성 (PC 사이드바를 마이페이지 표준 패턴으로 변환), 로그아웃은 실제 authStore.clearSession() 연동 - pets/index.tsx, pets/new.tsx: 반려동물 리스트 + 등록 폼 (종/성별 칩 선택, 생년월일 입력 시 만 나이 자동 계산) - pets/[petId]/index.tsx, weight.tsx, notes.tsx: 반려동물 상세정보, 체중 관리(react-native-svg 기반 추이 그래프 + 기록 추가/삭제), 특이사항 관리(카테고리 필터/추가/삭제) - family/index.tsx, apply.tsx, received.tsx: 가족 관리 메인 (펫별 구성원/초대코드 발급), 가족 신청 관리, 받은 신청 목록. 등록된 펫이 없으면 등록 유도 화면으로 전환 - device.tsx, walk-log.tsx, ai-report.tsx, profile-edit.tsx, notifications.tsx: 프로필 수정은 구현 완료, 나머지는 스텁으로 우선 라우팅만 연결 - react-native-svg 설치 - 특이사항 카테고리 드롭다운이 다른 카드에 가려 보이던 z-index 버그 수정 --- app/(tabs)/mypage.tsx | 24 -- app/(tabs)/mypage/_layout.tsx | 48 +++ app/(tabs)/mypage/ai-report.tsx | 16 + app/(tabs)/mypage/device.tsx | 16 + app/(tabs)/mypage/family/_mock.ts | 47 +++ app/(tabs)/mypage/family/apply.tsx | 275 ++++++++++++++ app/(tabs)/mypage/family/index.tsx | 343 ++++++++++++++++++ app/(tabs)/mypage/family/received.tsx | 174 +++++++++ app/(tabs)/mypage/index.tsx | 177 +++++++++ app/(tabs)/mypage/notifications.tsx | 16 + app/(tabs)/mypage/pets/[petId]/index.tsx | 346 ++++++++++++++++++ app/(tabs)/mypage/pets/[petId]/notes.tsx | 377 ++++++++++++++++++++ app/(tabs)/mypage/pets/[petId]/weight.tsx | 371 +++++++++++++++++++ app/(tabs)/mypage/pets/_mock.ts | 109 ++++++ app/(tabs)/mypage/pets/index.tsx | 201 +++++++++++ app/(tabs)/mypage/pets/new.tsx | 416 ++++++++++++++++++++++ app/(tabs)/mypage/profile-edit.tsx | 275 ++++++++++++++ app/(tabs)/mypage/walk-log.tsx | 16 + package.json | 5 +- yarn.lock | 89 ++++- 20 files changed, 3313 insertions(+), 28 deletions(-) delete mode 100644 app/(tabs)/mypage.tsx create mode 100644 app/(tabs)/mypage/_layout.tsx create mode 100644 app/(tabs)/mypage/ai-report.tsx create mode 100644 app/(tabs)/mypage/device.tsx create mode 100644 app/(tabs)/mypage/family/_mock.ts create mode 100644 app/(tabs)/mypage/family/apply.tsx create mode 100644 app/(tabs)/mypage/family/index.tsx create mode 100644 app/(tabs)/mypage/family/received.tsx create mode 100644 app/(tabs)/mypage/index.tsx create mode 100644 app/(tabs)/mypage/notifications.tsx create mode 100644 app/(tabs)/mypage/pets/[petId]/index.tsx create mode 100644 app/(tabs)/mypage/pets/[petId]/notes.tsx create mode 100644 app/(tabs)/mypage/pets/[petId]/weight.tsx create mode 100644 app/(tabs)/mypage/pets/_mock.ts create mode 100644 app/(tabs)/mypage/pets/index.tsx create mode 100644 app/(tabs)/mypage/pets/new.tsx create mode 100644 app/(tabs)/mypage/profile-edit.tsx create mode 100644 app/(tabs)/mypage/walk-log.tsx diff --git a/app/(tabs)/mypage.tsx b/app/(tabs)/mypage.tsx deleted file mode 100644 index 5b570b8..0000000 --- a/app/(tabs)/mypage.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { StyleSheet, Text, View } from 'react-native'; - -import { DodoColors } from '@/constants/theme'; - -export default function MyPageScreen() { - return ( - - 마이도도 화면 준비 중 - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: DodoColors.background, - }, - text: { - color: DodoColors.textSecondary, - fontSize: 15, - }, -}); diff --git a/app/(tabs)/mypage/_layout.tsx b/app/(tabs)/mypage/_layout.tsx new file mode 100644 index 0000000..c1790a8 --- /dev/null +++ b/app/(tabs)/mypage/_layout.tsx @@ -0,0 +1,48 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Stack, useRouter } from 'expo-router'; +import { TouchableOpacity } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +function BackButton() { + const router = useRouter(); + return ( + router.back()} hitSlop={8}> + + + ); +} + +const subScreenOptions = { + headerShown: true, + title: '', + headerStyle: { backgroundColor: DodoColors.surface }, + headerShadowVisible: false, + headerLeft: () => , +} as const; + +export default function MyPageStackLayout() { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/app/(tabs)/mypage/ai-report.tsx b/app/(tabs)/mypage/ai-report.tsx new file mode 100644 index 0000000..e04f582 --- /dev/null +++ b/app/(tabs)/mypage/ai-report.tsx @@ -0,0 +1,16 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export default function AiReportScreen() { + return ( + + AI 레포트 화면 준비 중 + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: DodoColors.background }, + text: { color: DodoColors.textSecondary, fontSize: 15 }, +}); diff --git a/app/(tabs)/mypage/device.tsx b/app/(tabs)/mypage/device.tsx new file mode 100644 index 0000000..4f1918b --- /dev/null +++ b/app/(tabs)/mypage/device.tsx @@ -0,0 +1,16 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export default function DeviceScreen() { + return ( + + 디바이스 관리 화면 준비 중 + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: DodoColors.background }, + text: { color: DodoColors.textSecondary, fontSize: 15 }, +}); diff --git a/app/(tabs)/mypage/family/_mock.ts b/app/(tabs)/mypage/family/_mock.ts new file mode 100644 index 0000000..60c939b --- /dev/null +++ b/app/(tabs)/mypage/family/_mock.ts @@ -0,0 +1,47 @@ +// TODO(이슈4): GET /pets/family/* 연동 후 mock 제거 +export type FamilyMember = { + id: string; + name: string; +}; + +export const MOCK_FAMILY_MEMBERS: Record = { + sundubu: [{ id: 'me', name: '조수빈' }], +}; + +export type SentRequestStatus = '승인 대기' | '승인됨' | '거절됨'; + +export type SentRequest = { + id: string; + petName: string; + status: SentRequestStatus; + message: string; + sentAt: string; +}; + +export const MOCK_SENT_REQUESTS: SentRequest[] = [ + { + id: 'sr1', + petName: '보라', + status: '거절됨', + message: '가족 신청을 보낸 반려동물이에요.', + sentAt: '2026-07-08 15:11', + }, +]; + +export type ReceivedRequestStatus = '승인 대기' | '거절됨'; + +export type ReceivedRequest = { + id: string; + petName: string; + status: ReceivedRequestStatus; + sentAt: string; +}; + +export const MOCK_RECEIVED_REQUESTS: ReceivedRequest[] = []; + +export type BlockedEntry = { + id: string; + petName: string; +}; + +export const MOCK_BLOCKED: BlockedEntry[] = []; diff --git a/app/(tabs)/mypage/family/apply.tsx b/app/(tabs)/mypage/family/apply.tsx new file mode 100644 index 0000000..b34b7c4 --- /dev/null +++ b/app/(tabs)/mypage/family/apply.tsx @@ -0,0 +1,275 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Link } from 'expo-router'; +import { useState } from 'react'; +import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +import { MOCK_SENT_REQUESTS, type SentRequestStatus } from './_mock'; + +const FILTERS: ('전체' | SentRequestStatus)[] = ['전체', '승인 대기', '거절됨']; + +function statusStyle(status: SentRequestStatus) { + if (status === '거절됨') return { bg: '#fee2e2', color: DodoColors.fenceOutside }; + if (status === '승인됨') return { bg: '#dcfce7', color: DodoColors.fenceActiveLabel }; + return { bg: DodoColors.background, color: DodoColors.fenceIdleLabel }; +} + +export default function FamilyApplyScreen() { + const [code, setCode] = useState(''); + const [filter, setFilter] = useState<(typeof FILTERS)[number]>('전체'); + + const filteredRequests = MOCK_SENT_REQUESTS.filter((r) => filter === '전체' || r.status === filter); + + const handleApply = () => { + if (!code.trim()) return; + Alert.alert('가족 신청', '가족 신청 기능은 준비 중이에요.'); + setCode(''); + }; + + return ( + + + + FAMILY + 가족 신청 관리 + + + + 메인 보기 + + + + + + 가족 신청 + + 가족 코드를 입력하면 신청할 수 있어요. 신청 상태는 내 신청 내역에서 확인해요. + + + + + 가족 신청 + + + + + + + 내 신청 내역 + + {FILTERS.map((f) => { + const selected = f === filter; + return ( + setFilter(f)} + > + {f} + + ); + })} + + + + {filteredRequests.length === 0 ? ( + 표시할 신청 내역이 없어요. + ) : ( + filteredRequests.map((request) => { + const style = statusStyle(request.status); + return ( + + + + + + + {request.petName} + + {request.status} + + + {request.message} + {request.sentAt} + + + ); + }) + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + topRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + outlineButton: { + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + outlineButtonText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 8, + }, + infoLabel: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.fenceIdleLabel, + letterSpacing: 0.3, + }, + hintText: { + fontSize: 11, + lineHeight: 16, + color: DodoColors.fenceIdleLabel, + }, + applyRow: { + flexDirection: 'row', + gap: 8, + marginTop: 4, + }, + input: { + flex: 1, + height: 40, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + fontSize: 12, + color: DodoColors.textPrimary, + }, + primaryButton: { + paddingHorizontal: 16, + height: 40, + borderRadius: 10, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.brandForeground, + }, + cardHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: 8, + }, + filterRow: { + flexDirection: 'row', + gap: 6, + }, + filterChip: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 999, + backgroundColor: DodoColors.background, + }, + filterChipSelected: { + backgroundColor: DodoColors.textPrimary, + }, + filterChipText: { + fontSize: 10, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + filterChipTextSelected: { + color: DodoColors.brandForeground, + }, + emptyText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + requestRow: { + flexDirection: 'row', + gap: 10, + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: DodoColors.background, + }, + requestAvatar: { + width: 36, + height: 36, + borderRadius: 10, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + requestTextCol: { + flex: 1, + gap: 2, + }, + requestNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + requestPetName: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + }, + statusBadgeText: { + fontSize: 10, + fontWeight: '700', + }, + requestMessage: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + requestDate: { + fontSize: 10, + color: DodoColors.fenceIdleLabel, + }, +}); diff --git a/app/(tabs)/mypage/family/index.tsx b/app/(tabs)/mypage/family/index.tsx new file mode 100644 index 0000000..5af63c4 --- /dev/null +++ b/app/(tabs)/mypage/family/index.tsx @@ -0,0 +1,343 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Link } from 'expo-router'; +import { useState } from 'react'; +import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +import { MOCK_PETS } from '../pets/_mock'; +import { MOCK_FAMILY_MEMBERS, MOCK_RECEIVED_REQUESTS } from './_mock'; + +function genderLabel(gender: 'F' | 'M' | null) { + if (gender === 'F') return '암컷'; + if (gender === 'M') return '수컷'; + return '미상'; +} + +export default function FamilyScreen() { + const [selectedPetId, setSelectedPetId] = useState(MOCK_PETS[0]?.id ?? ''); + const selectedPet = MOCK_PETS.find((p) => p.id === selectedPetId) ?? null; + const members = selectedPet ? (MOCK_FAMILY_MEMBERS[selectedPet.id] ?? []) : []; + + if (MOCK_PETS.length === 0 || !selectedPet) { + return ( + + + 가족을 관리하려면 먼저 반려동물을 등록해주세요. + + + 반려동물 추가하기 + + + + ); + } + + const handleCreateInviteCode = () => { + const code = Math.random().toString(36).slice(2, 8).toUpperCase(); + Alert.alert('초대 코드 발급', `${selectedPet.name}의 초대 코드가 발급됐어요.\n\n${code}\n\n15분간 유효합니다.`); + }; + + return ( + + FAMILY + 반려동물 가족 관리 + + + SELECT PET + 가족을 관리할 반려동물을 선택해 주세요 + + {MOCK_PETS.map((pet) => { + const selected = pet.id === selectedPetId; + return ( + setSelectedPetId(pet.id)} + > + {pet.name} + + ); + })} + + + + + + + + + + + {selectedPet.name} + + 가족 {members.length}명 + + + + {selectedPet.birthDate} ({selectedPet.ageLabel}) + + + {selectedPet.species} {selectedPet.breed} + + 성별 {genderLabel(selectedPet.gender)} + + + + 상세정보 + + + + + + + 가족 구성원 + {members.map((member) => ( + + + + + {member.name} + + ))} + + + + + + 가족 신청 + + + 전체보기 + + + + 새로운 가족을 만나보세요! 전체보기에서 확인할 수 있어요. + + + + + 받은 신청 목록 + + + 전체보기 + + + + + {MOCK_RECEIVED_REQUESTS.length === 0 + ? '아직 받은 신청이 없어요.' + : `${MOCK_RECEIVED_REQUESTS.length}건의 신청이 있어요.`} + + + + + + 초대 코드 발급 + + 선택한 반려동물 기준으로 초대 코드를 발급할 수 있어요.{'\n'}생성된 코드는 15분 동안 유효하고, 같은 코드로 가족 + 신청을 받을 수 있어요. + + + 초대 코드 만들기 + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + marginBottom: 4, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 8, + }, + cardRow: { + flexDirection: 'row', + gap: 12, + }, + cardHalf: { + flex: 1, + }, + infoLabel: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.fenceIdleLabel, + letterSpacing: 0.3, + }, + sectionHint: { + fontSize: 12, + color: DodoColors.textSecondary, + marginTop: -4, + }, + chipRow: { + marginTop: 4, + }, + chip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + marginRight: 8, + }, + chipSelected: { + borderColor: DodoColors.brand, + backgroundColor: DodoColors.brand, + }, + chipText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + chipTextSelected: { + color: DodoColors.brandForeground, + }, + petRow: { + flexDirection: 'row', + gap: 12, + alignItems: 'center', + }, + petAvatar: { + width: 56, + height: 56, + borderRadius: 12, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + petTextCol: { + flex: 1, + gap: 2, + }, + petNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + petName: { + fontSize: 15, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + familyCountBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + backgroundColor: '#fdead9', + }, + familyCountBadgeText: { + fontSize: 10, + fontWeight: '700', + color: DodoColors.brand, + }, + petMeta: { + fontSize: 11, + color: DodoColors.textSecondary, + }, + outlineButton: { + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + outlineButtonText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + memberRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + memberAvatar: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + memberName: { + fontSize: 13, + color: DodoColors.textPrimary, + }, + cardHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + linkText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + hintText: { + fontSize: 11, + lineHeight: 16, + color: DodoColors.fenceIdleLabel, + }, + primaryButton: { + alignSelf: 'flex-start', + paddingHorizontal: 16, + paddingVertical: 9, + borderRadius: 8, + backgroundColor: DodoColors.brand, + marginTop: 4, + }, + primaryButtonText: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.brandForeground, + }, + emptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + padding: 32, + }, + emptyText: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + textAlign: 'center', + }, + addButton: { + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 999, + backgroundColor: DodoColors.brand, + }, + addButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, +}); diff --git a/app/(tabs)/mypage/family/received.tsx b/app/(tabs)/mypage/family/received.tsx new file mode 100644 index 0000000..273858d --- /dev/null +++ b/app/(tabs)/mypage/family/received.tsx @@ -0,0 +1,174 @@ +import { Link } from 'expo-router'; +import { useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +import { MOCK_BLOCKED, MOCK_RECEIVED_REQUESTS, type ReceivedRequestStatus } from './_mock'; + +const FILTERS: ReceivedRequestStatus[] = ['승인 대기', '거절됨']; + +export default function FamilyReceivedScreen() { + const [filter, setFilter] = useState('승인 대기'); + const filteredRequests = MOCK_RECEIVED_REQUESTS.filter((r) => r.status === filter); + + return ( + + + + FAMILY + 받은 신청 목록 + + + + 메인 보기 + + + + + + + 받은 신청 목록 + + {FILTERS.map((f) => { + const selected = f === filter; + return ( + setFilter(f)} + > + {f} + + ); + })} + + + + {filteredRequests.length === 0 ? ( + 아직 받은 신청이 없어요. + ) : ( + filteredRequests.map((request) => ( + + {request.petName} + {request.sentAt} + + )) + )} + + + + 차단 목록 + {MOCK_BLOCKED.length === 0 ? ( + 선택한 반려동물의 차단 목록이 비어 있어요. + ) : ( + MOCK_BLOCKED.map((entry) => ( + + {entry.petName} + + )) + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + topRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + outlineButton: { + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + outlineButtonText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 8, + }, + cardHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + infoLabel: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.fenceIdleLabel, + letterSpacing: 0.3, + }, + filterRow: { + flexDirection: 'row', + gap: 6, + }, + filterChip: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 999, + backgroundColor: DodoColors.background, + }, + filterChipSelected: { + backgroundColor: DodoColors.textPrimary, + }, + filterChipText: { + fontSize: 10, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + filterChipTextSelected: { + color: DodoColors.brandForeground, + }, + emptyText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + requestRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: DodoColors.background, + }, + requestPetName: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + requestDate: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, +}); diff --git a/app/(tabs)/mypage/index.tsx b/app/(tabs)/mypage/index.tsx new file mode 100644 index 0000000..416a190 --- /dev/null +++ b/app/(tabs)/mypage/index.tsx @@ -0,0 +1,177 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Link, useRouter } from 'expo-router'; +import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; + +// TODO(이슈4): GET /users/me 연동 후 mock 제거 +const MOCK_USER = { + nickname: '조펭이', + region: '서울특별시 서대문구', + email: 'csb7543@naver.com', +}; + +type MenuItem = { + key: string; + label: string; + icon: keyof typeof Ionicons.glyphMap; + href?: Parameters[0]['href']; + danger?: boolean; +}; + +const PET_MENU: MenuItem[] = [ + { key: 'pets', label: '반려동물 리스트', icon: 'paw-outline', href: '/(tabs)/mypage/pets' }, + { key: 'device', label: '디바이스 관리', icon: 'hardware-chip-outline', href: '/(tabs)/mypage/device' }, + { key: 'family', label: '가족 관리', icon: 'people-outline', href: '/(tabs)/mypage/family' }, + { key: 'walk-log', label: '산책 기록', icon: 'walk-outline', href: '/(tabs)/mypage/walk-log' }, + { key: 'ai-report', label: 'AI 레포트', icon: 'analytics-outline', href: '/(tabs)/mypage/ai-report' }, +]; + +const ACCOUNT_MENU: MenuItem[] = [ + { key: 'profile-edit', label: '회원정보 수정', icon: 'person-circle-outline', href: '/(tabs)/mypage/profile-edit' }, + { key: 'notifications', label: '알림함', icon: 'notifications-outline', href: '/(tabs)/mypage/notifications' }, + { key: 'logout', label: '로그아웃', icon: 'log-out-outline', danger: true }, +]; + +export default function MyPageScreen() { + const router = useRouter(); + const clearSession = useAuthStore((state) => state.clearSession); + + const handleLogout = () => { + Alert.alert('로그아웃', '로그아웃 하시겠어요?', [ + { text: '취소', style: 'cancel' }, + { + text: '로그아웃', + style: 'destructive', + onPress: async () => { + await clearSession(); + router.replace('/(tabs)'); + }, + }, + ]); + }; + + const renderRow = (item: MenuItem) => { + const content = ( + + + + {item.label} + + {!item.danger && } + + ); + + if (item.key === 'logout') { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); + }; + + return ( + + + + + + {MOCK_USER.nickname}님 + {MOCK_USER.region} + {MOCK_USER.email} + + + 반려동물 + {PET_MENU.map(renderRow)} + + 회원정보 + {ACCOUNT_MENU.map(renderRow)} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + }, + profileCard: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 20, + alignItems: 'center', + marginBottom: 20, + }, + avatar: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 10, + }, + nickname: { + fontSize: 17, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + profileMeta: { + fontSize: 12, + color: DodoColors.textSecondary, + marginTop: 2, + }, + sectionLabel: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.fenceIdleLabel, + marginBottom: 8, + marginTop: 4, + }, + menuCard: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + paddingHorizontal: 16, + marginBottom: 20, + }, + menuRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: DodoColors.background, + }, + menuRowLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + menuLabel: { + fontSize: 14, + color: DodoColors.textPrimary, + }, + menuLabelDanger: { + color: DodoColors.fenceOutside, + }, +}); diff --git a/app/(tabs)/mypage/notifications.tsx b/app/(tabs)/mypage/notifications.tsx new file mode 100644 index 0000000..9ba2eaf --- /dev/null +++ b/app/(tabs)/mypage/notifications.tsx @@ -0,0 +1,16 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export default function NotificationsScreen() { + return ( + + 알림함 화면 준비 중 + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: DodoColors.background }, + text: { color: DodoColors.textSecondary, fontSize: 15 }, +}); diff --git a/app/(tabs)/mypage/pets/[petId]/index.tsx b/app/(tabs)/mypage/pets/[petId]/index.tsx new file mode 100644 index 0000000..5cf4945 --- /dev/null +++ b/app/(tabs)/mypage/pets/[petId]/index.tsx @@ -0,0 +1,346 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Link, useLocalSearchParams, useRouter } from 'expo-router'; +import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +import { MOCK_NOTES, MOCK_PETS, MOCK_WEIGHT_RECORDS } from '../_mock'; + +export default function PetDetailScreen() { + const { petId } = useLocalSearchParams<{ petId: string }>(); + const router = useRouter(); + const pet = MOCK_PETS.find((p) => p.id === petId) ?? MOCK_PETS[0]; + const weightRecords = MOCK_WEIGHT_RECORDS[pet.id] ?? []; + const latestWeight = weightRecords[0]; + const notes = MOCK_NOTES[pet.id] ?? []; + + const handleLeaveFamily = () => { + Alert.alert('가족 나가기', `${pet.name}의 가족에서 나가시겠어요?`, [ + { text: '취소', style: 'cancel' }, + { text: '나가기', style: 'destructive' }, + ]); + }; + + return ( + + + + PET DETAIL + 반려동물 상세정보 + + + router.back()}> + 목록으로 + + + 정보 수정 + + + + + + + + + + + + {pet.name} + {pet.gender && ( + + )} + + + {pet.birthDate} ({pet.ageLabel}) + + + {pet.species} {pet.breed} + + + + + + + 기본 정보 + + 동물등록번호 + {pet.registrationNumber ?? '미등록'} + + + 디바이스 ID + {pet.deviceId} + + + 기준 심박수 + {pet.baselineHeartRate} + + + + + + 체중 정보 + + + 전체보기 + + + + {latestWeight ? ( + + {latestWeight.weightKg}kg + 최근 측정일 {latestWeight.measuredAt} + + ) : ( + 등록된 체중 기록이 없어요. + )} + + + + + 가족 구성원 + + 전체보기 + + + + + + + 가족 구성원 1명과 함께 관리 중이에요. + + + + + 최근 활동 + 최근 활동 정보가 없습니다. + + + + + 특이사항 + + + 전체보기 + + + + 총 {notes.length}개의 특이사항이 등록되어 있어요. + {notes.slice(0, 3).map((note) => ( + + + {note.tag} + + {note.date} + + {note.content} + + + ))} + + + + 펫 가족 나가기 + + 가족에서 나가면 이 반려동물의 가족 구성원 목록과 관련 활동 기록을 더 이상 확인할 수 없어요. 다시 참여하려면 + 초대 코드를 다시 등록해야 해요. + + + 가족 나가기 + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + topRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + }, + topRowActions: { + flexDirection: 'row', + gap: 8, + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + outlineButton: { + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + outlineButtonText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 10, + }, + petHeaderRow: { + flexDirection: 'row', + gap: 14, + alignItems: 'center', + }, + petAvatar: { + width: 60, + height: 60, + borderRadius: 14, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + petHeaderTextCol: { + gap: 3, + }, + petNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + petName: { + fontSize: 17, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + petMeta: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + infoLabel: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.3, + }, + cardHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + linkText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + infoKey: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + infoValue: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + weightRow: { + flexDirection: 'row', + alignItems: 'baseline', + gap: 8, + }, + weightValue: { + fontSize: 22, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + weightDate: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + familyRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + familyAvatar: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + notesSummary: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + noteRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + noteTag: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 6, + backgroundColor: DodoColors.background, + }, + noteTagText: { + fontSize: 10, + fontWeight: '700', + color: DodoColors.textSecondary, + }, + noteDate: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + noteContent: { + flex: 1, + fontSize: 12, + color: DodoColors.textPrimary, + }, + leaveHint: { + fontSize: 11, + lineHeight: 16, + color: DodoColors.fenceIdleLabel, + }, + leaveButton: { + alignSelf: 'flex-start', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.fenceOutside, + }, + leaveButtonText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.fenceOutside, + }, +}); diff --git a/app/(tabs)/mypage/pets/[petId]/notes.tsx b/app/(tabs)/mypage/pets/[petId]/notes.tsx new file mode 100644 index 0000000..99d3f5a --- /dev/null +++ b/app/(tabs)/mypage/pets/[petId]/notes.tsx @@ -0,0 +1,377 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useState } from 'react'; +import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +import { MOCK_NOTES, MOCK_PETS, NOTE_CATEGORIES, type NoteCategory, type SignificantNote } from '../_mock'; + +const TAG_COLORS: Record = { + 알레르기: '#fdead9', + 병원: '#e0f2fe', + 약물: '#fdead9', + 음식: '#fef9c3', + 행동: '#ede9fe', + 증상: '#fee2e2', + 기타: DodoColors.background, +}; + +export default function PetNotesScreen() { + const { petId } = useLocalSearchParams<{ petId: string }>(); + const router = useRouter(); + const pet = MOCK_PETS.find((p) => p.id === petId) ?? MOCK_PETS[0]; + const [notes, setNotes] = useState(MOCK_NOTES[pet.id] ?? []); + const [content, setContent] = useState(''); + const [category, setCategory] = useState('알레르기'); + const [pickerOpen, setPickerOpen] = useState(false); + const [filter, setFilter] = useState<'전체' | NoteCategory>('전체'); + + const filteredNotes = notes.filter((n) => filter === '전체' || n.tag === filter); + + const handleAdd = () => { + if (!content.trim()) return; + const today = new Date().toISOString().slice(0, 10); + setNotes((prev) => [{ id: `note-${Date.now()}`, tag: category, date: today, content: content.trim() }, ...prev]); + setContent(''); + }; + + const handleDelete = (id: string) => { + setNotes((prev) => prev.filter((n) => n.id !== id)); + }; + + const handleEdit = () => { + Alert.alert('수정', '특이사항 수정 기능은 준비 중이에요.'); + }; + + return ( + + + + PET NOTES + 특이사항 관리 + + router.back()}> + 상세정보 보기 + + + + + + {pet.name} + + 총 {notes.length}개 + + + {pet.name}의 특이사항을 정리하고 필요한 메모를 추가하거나 수정할 수 있어요. + + + + + + setPickerOpen((v) => !v)}> + {category} + + + {pickerOpen && ( + + {NOTE_CATEGORIES.map((option) => ( + { + setCategory(option); + setPickerOpen(false); + }} + > + {option} + + ))} + + )} + + + + 추가 + + + + + + + {(['전체', ...NOTE_CATEGORIES] as const).map((f) => { + const selected = f === filter; + return ( + setFilter(f)} + > + {f} + + ); + })} + + + {filteredNotes.length === 0 ? ( + 등록된 특이사항이 없어요. + ) : ( + filteredNotes.map((note, index) => ( + + + {note.tag} + + + {note.date} + {note.content} + + + + 수정 + + handleDelete(note.id)} + > + 삭제 + + + + )) + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + topRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + outlineButton: { + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + outlineButtonText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 8, + }, + petNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + petName: { + fontSize: 16, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + countBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + backgroundColor: '#fdead9', + }, + countBadgeText: { + fontSize: 10, + fontWeight: '700', + color: DodoColors.brand, + }, + hintText: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + addCard: { + zIndex: 20, + }, + addRow: { + flexDirection: 'row', + gap: 8, + zIndex: 10, + }, + pickerWrap: { + position: 'relative', + }, + picker: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + height: 40, + paddingHorizontal: 12, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + }, + pickerText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + pickerOptions: { + position: 'absolute', + top: 44, + left: 0, + backgroundColor: DodoColors.surface, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + paddingVertical: 4, + minWidth: 90, + zIndex: 20, + }, + pickerOption: { + paddingHorizontal: 12, + paddingVertical: 8, + }, + pickerOptionText: { + fontSize: 12, + color: DodoColors.textPrimary, + }, + input: { + flex: 1, + height: 40, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + fontSize: 12, + color: DodoColors.textPrimary, + }, + addButton: { + paddingHorizontal: 16, + height: 40, + borderRadius: 10, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + addButtonText: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.brandForeground, + }, + filterScroll: { + marginBottom: 4, + }, + filterChip: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: DodoColors.background, + marginRight: 6, + }, + filterChipSelected: { + backgroundColor: DodoColors.textPrimary, + }, + filterChipText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + filterChipTextSelected: { + color: DodoColors.brandForeground, + }, + emptyText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + noteRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: DodoColors.background, + }, + noteRowLast: { + borderBottomWidth: 0, + }, + noteTag: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + }, + noteTagText: { + fontSize: 10, + fontWeight: '700', + color: DodoColors.textSecondary, + }, + noteContentCol: { + flex: 1, + gap: 2, + }, + noteDate: { + fontSize: 10, + color: DodoColors.fenceIdleLabel, + }, + noteContent: { + fontSize: 13, + color: DodoColors.textPrimary, + }, + noteActions: { + flexDirection: 'row', + gap: 6, + }, + noteActionButton: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + noteActionText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + noteDeleteButton: { + borderColor: DodoColors.fenceOutside, + }, + noteDeleteText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.fenceOutside, + }, +}); diff --git a/app/(tabs)/mypage/pets/[petId]/weight.tsx b/app/(tabs)/mypage/pets/[petId]/weight.tsx new file mode 100644 index 0000000..9adfba7 --- /dev/null +++ b/app/(tabs)/mypage/pets/[petId]/weight.tsx @@ -0,0 +1,371 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { Circle, Polyline, Svg } from 'react-native-svg'; + +import { DodoColors } from '@/constants/theme'; + +import { MOCK_PETS, MOCK_WEIGHT_RECORDS, type WeightRecord } from '../_mock'; + +const CHART_WIDTH = 300; +const CHART_HEIGHT = 140; +const CHART_PADDING = 20; + +function WeightChart({ records }: { records: WeightRecord[] }) { + const oldestFirst = [...records].reverse(); + const values = oldestFirst.map((r) => r.weightKg); + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + + const points = oldestFirst.map((record, index) => { + const x = CHART_PADDING + (index * (CHART_WIDTH - CHART_PADDING * 2)) / Math.max(oldestFirst.length - 1, 1); + const y = CHART_PADDING + (1 - (record.weightKg - min) / range) * (CHART_HEIGHT - CHART_PADDING * 2); + return { x, y, record }; + }); + + return ( + + + `${p.x},${p.y}`).join(' ')} + fill="none" + stroke={DodoColors.brand} + strokeWidth={2} + /> + {points.map((p) => ( + + ))} + + + {oldestFirst.map((r) => ( + + {r.measuredAt.slice(5).replace('. ', '.')} + + ))} + + + ); +} + +export default function PetWeightScreen() { + const { petId } = useLocalSearchParams<{ petId: string }>(); + const router = useRouter(); + const pet = MOCK_PETS.find((p) => p.id === petId) ?? MOCK_PETS[0]; + const [records, setRecords] = useState(MOCK_WEIGHT_RECORDS[pet.id] ?? []); + const [date, setDate] = useState(''); + const [weight, setWeight] = useState(''); + + const latest = records[0]; + const oldest = records[records.length - 1]; + const lowest = useMemo(() => (records.length ? Math.min(...records.map((r) => r.weightKg)) : null), [records]); + const change = latest && oldest ? Math.round((latest.weightKg - oldest.weightKg) * 10) / 10 : null; + + const handleAdd = () => { + if (!date.trim() || !weight.trim()) return; + const weightKg = Number(weight); + if (Number.isNaN(weightKg)) return; + + setRecords((prev) => [{ id: `w-${Date.now()}`, weightKg, measuredAt: date.trim() }, ...prev]); + setDate(''); + setWeight(''); + }; + + const handleDelete = (id: string) => { + setRecords((prev) => prev.filter((r) => r.id !== id)); + }; + + const handleEdit = () => { + Alert.alert('수정', '체중 기록 수정 기능은 준비 중이에요.'); + }; + + return ( + + + + PET WEIGHT + 체중 관리 + + router.back()}> + 상세정보 보기 + + + + + + {pet.name} + + 총 {records.length}개 + + + {latest ? ( + + 최근 체중은 {latest.weightKg}kg이며, 최근 측정일은 {latest.measuredAt}입니다. + + ) : ( + 등록된 체중 기록이 없어요. + )} + + + {records.length > 0 && ( + + WEIGHT TREND + 체중 추이 그래프 + + + 최신 체중 + {latest.weightKg}kg + + + 최저 체중 + {lowest}kg + + + 변화량 + 0 && styles.statValuePositive]}> + {change != null && change > 0 ? '+' : ''} + {change}kg + + + + + + )} + + + WEIGHT RECORDS + 체중 기록 관리 + 기록을 추가하고, 잘못 입력한 값은 바로 수정하거나 삭제할 수 있어요. + + + + + + 추가 + + + + {records.map((record, index) => ( + + + {record.weightKg}kg + 측정일 {record.measuredAt} + + + + 수정 + + handleDelete(record.id)} + > + 삭제 + + + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + topRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + outlineButton: { + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + outlineButtonText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 8, + }, + petNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + petName: { + fontSize: 16, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + countBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + backgroundColor: '#fdead9', + }, + countBadgeText: { + fontSize: 10, + fontWeight: '700', + color: DodoColors.brand, + }, + summaryText: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + sectionTitle: { + fontSize: 15, + fontWeight: '700', + color: DodoColors.textPrimary, + marginTop: -4, + }, + sectionHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + marginTop: -6, + }, + statsRow: { + flexDirection: 'row', + gap: 20, + marginBottom: 4, + }, + statItem: { + gap: 2, + }, + statLabel: { + fontSize: 10, + color: DodoColors.fenceIdleLabel, + }, + statValue: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + statValuePositive: { + color: DodoColors.brand, + }, + chartLabelsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: CHART_PADDING - 8, + }, + chartLabel: { + fontSize: 10, + color: DodoColors.fenceIdleLabel, + }, + addRow: { + flexDirection: 'row', + gap: 8, + }, + input: { + height: 40, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 10, + fontSize: 12, + color: DodoColors.textPrimary, + }, + weightInput: { + flex: 1, + }, + addButton: { + paddingHorizontal: 16, + height: 40, + borderRadius: 10, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + addButtonText: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.brandForeground, + }, + recordRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: DodoColors.background, + }, + recordRowLast: { + borderBottomWidth: 0, + }, + recordWeight: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + recordDate: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + marginTop: 2, + }, + recordActions: { + flexDirection: 'row', + gap: 6, + }, + recordActionButton: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + recordActionText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + recordDeleteButton: { + borderColor: DodoColors.fenceOutside, + }, + recordDeleteText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.fenceOutside, + }, +}); diff --git a/app/(tabs)/mypage/pets/_mock.ts b/app/(tabs)/mypage/pets/_mock.ts new file mode 100644 index 0000000..cc86b2b --- /dev/null +++ b/app/(tabs)/mypage/pets/_mock.ts @@ -0,0 +1,109 @@ +// TODO(이슈4): GET /pets/list, GET /pets/{petId} 연동 후 mock 제거 +export type Pet = { + id: string; + name: string; + gender: 'F' | 'M' | null; + birthDate: string; + ageLabel: string; + species: string; + breed: string; + deviceId: string; + baselineHeartRate: number; + registrationNumber: string | null; +}; + +export const MOCK_PETS: Pet[] = [ + { + id: 'sundubu', + name: '순두부', + gender: 'F', + birthDate: '2024. 12. 10', + ageLabel: '만 1세', + species: '강아지', + breed: '말티즈', + deviceId: 'ABC123678', + baselineHeartRate: 87, + registrationNumber: null, + }, + { + id: 'godeungeo', + name: '고등어', + gender: 'M', + birthDate: '2025. 01. 07', + ageLabel: '만 1세', + species: '고양이', + breed: '코리안숏헤어', + deviceId: 'DEF456789', + baselineHeartRate: 92, + registrationNumber: null, + }, + { + id: 'maltese', + name: '말티티', + gender: 'F', + birthDate: '2026. 06. 02', + ageLabel: '만 0세', + species: '강아지', + breed: '말티즈', + deviceId: 'GHI789012', + baselineHeartRate: 90, + registrationNumber: null, + }, + { + id: 'pold', + name: '폴드', + gender: null, + birthDate: '2024. 06. 04', + ageLabel: '만 2세', + species: '고양이', + breed: '스코티시폴드', + deviceId: 'JKL012345', + baselineHeartRate: 95, + registrationNumber: null, + }, + { + id: 'puchi', + name: '푸치', + gender: 'M', + birthDate: '2026. 06. 01', + ageLabel: '만 0세', + species: '강아지', + breed: '말티즈', + deviceId: 'MNO345678', + baselineHeartRate: 88, + registrationNumber: null, + }, +]; + +export type WeightRecord = { + id: string; + weightKg: number; + measuredAt: string; +}; + +export const MOCK_WEIGHT_RECORDS: Record = { + sundubu: [ + { id: 'w1', weightKg: 5.6, measuredAt: '2026. 05. 08' }, + { id: 'w2', weightKg: 5.4, measuredAt: '2026. 02. 08' }, + { id: 'w3', weightKg: 5.2, measuredAt: '2026. 01. 08' }, + ], +}; + +export const NOTE_CATEGORIES = ['알레르기', '병원', '약물', '음식', '행동', '증상', '기타'] as const; +export type NoteCategory = (typeof NOTE_CATEGORIES)[number]; + +export type SignificantNote = { + id: string; + tag: NoteCategory; + date: string; + content: string; +}; + +export const MOCK_NOTES: Record = { + sundubu: [ + { id: 'n1', tag: '약물', date: '2026-07-08', content: '감기약 복용' }, + { id: 'n2', tag: '증상', date: '2026-07-08', content: '구토를 안함' }, + { id: 'n3', tag: '음식', date: '2026-07-08', content: '두부를 못 먹음' }, + { id: 'n4', tag: '알레르기', date: '2026-07-08', content: '생선 알레르기 있음' }, + ], +}; diff --git a/app/(tabs)/mypage/pets/index.tsx b/app/(tabs)/mypage/pets/index.tsx new file mode 100644 index 0000000..5bf5823 --- /dev/null +++ b/app/(tabs)/mypage/pets/index.tsx @@ -0,0 +1,201 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Link } from 'expo-router'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +import { MOCK_PETS, type Pet } from './_mock'; + +function GenderIcon({ gender }: { gender: Pet['gender'] }) { + if (!gender) return null; + return ( + + ); +} + +export default function PetListScreen() { + if (MOCK_PETS.length === 0) { + return ( + + + 아직 등록된 반려동물이 없어요. + + + 반려동물 추가하기 + + + + ); + } + + return ( + + + + PET LIST + 반려동물 리스트 + + + + 총 {MOCK_PETS.length}마리 + + + + 추가하기 + + + + + + {MOCK_PETS.map((pet) => ( + + + + + + + {pet.name} + + + + {pet.birthDate} ({pet.ageLabel}) + + + {pet.species} {pet.breed} + + + + + + 상세 정보 + + + + + 체중 관리 + + + + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + topRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: 4, + }, + topRowRight: { + alignItems: 'flex-end', + gap: 8, + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + countBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 999, + backgroundColor: DodoColors.surface, + borderWidth: 1, + borderColor: DodoColors.border, + }, + countBadgeText: { + fontSize: 11, + color: DodoColors.textSecondary, + }, + addButton: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 999, + backgroundColor: DodoColors.brand, + }, + addButtonText: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.brandForeground, + }, + petCard: { + flexDirection: 'row', + gap: 12, + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 14, + }, + petAvatar: { + width: 56, + height: 56, + borderRadius: 12, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + petInfo: { + flex: 1, + gap: 3, + }, + petNameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + petName: { + fontSize: 15, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + petMeta: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + petActions: { + justifyContent: 'center', + gap: 6, + }, + petActionButton: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + petActionText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + emptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + padding: 32, + }, + emptyText: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + }, +}); diff --git a/app/(tabs)/mypage/pets/new.tsx b/app/(tabs)/mypage/pets/new.tsx new file mode 100644 index 0000000..2a79b06 --- /dev/null +++ b/app/(tabs)/mypage/pets/new.tsx @@ -0,0 +1,416 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +type Species = '강아지' | '고양이'; +type Gender = '수컷' | '암컷'; + +function calculateAgeLabel(birthDate: string): string | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(birthDate.trim()); + if (!match) return null; + const [, y, m, d] = match; + const birth = new Date(Number(y), Number(m) - 1, Number(d)); + if (Number.isNaN(birth.getTime())) return null; + + const now = new Date(); + let age = now.getFullYear() - birth.getFullYear(); + const hasHadBirthdayThisYear = + now.getMonth() > birth.getMonth() || (now.getMonth() === birth.getMonth() && now.getDate() >= birth.getDate()); + if (!hasHadBirthdayThisYear) age -= 1; + return `만 ${Math.max(age, 0)}세`; +} + +function ChipSelect({ + options, + value, + onChange, +}: { + options: readonly T[]; + value: T | null; + onChange: (v: T) => void; +}) { + return ( + + {options.map((option) => { + const selected = value === option; + return ( + onChange(option)} + > + {option} + + ); + })} + + ); +} + +export default function NewPetScreen() { + const router = useRouter(); + const [name, setName] = useState(''); + const [breed, setBreed] = useState(''); + const [species, setSpecies] = useState(null); + const [gender, setGender] = useState(null); + const [birthDate, setBirthDate] = useState(''); + const [registrationNumber, setRegistrationNumber] = useState(''); + const [deviceId, setDeviceId] = useState(''); + const [baselineHeartRate, setBaselineHeartRate] = useState(''); + + const ageLabel = useMemo(() => calculateAgeLabel(birthDate), [birthDate]); + + const canSubmit = + name.trim().length > 0 && + breed.trim().length > 0 && + !!species && + !!gender && + !!ageLabel && + deviceId.trim().length > 0 && + baselineHeartRate.trim().length > 0; + + return ( + + + 새 반려동물 등록하기 + + 반려동물의 기본 정보와 디바이스 정보를 입력해 등록을 시작해보세요. 생년월일을 입력하면 만 나이는 자동으로 + 계산돼요. + + + + + + + + + + JPG / PNG + 최대 10MB + + 사진이나 버튼을 눌러 반려동물 프로필 이미지를 등록할 수 있어요. + {/* TODO(이슈7): expo-image-picker 설치 후 실제 첨부 연동 */} + + 이미지 등록 + + + + + + + 기본 정보 + 이름, 종, 성별, 생년월일 같은 기본 프로필을 입력해 주세요. + + + + 반려동물 이름 * + + + + 품종 * + + + + + + 종 * + + + + + 성별 * + + + + + + 생년월일 * + + + + 만 나이 + + + {ageLabel ?? '생년월일을 입력하면 자동 계산돼요.'} + + + + + + + 등록번호 + + + + + + 디바이스 및 건강 기준값 + 디바이스 ID와 기준 심박수를 입력해 이후 기능과 연결할 수 있어요. + + + + 디바이스 ID * + + + + 기준 심박수 * + + + + + + * 표시는 필수 입력 항목입니다. + + + + router.back()}> + 취소 + + + 등록하기 + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 16, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + subtitle: { + fontSize: 12, + color: DodoColors.textSecondary, + lineHeight: 18, + marginTop: -8, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 14, + }, + imageRow: { + flexDirection: 'row', + gap: 14, + }, + imagePlaceholder: { + width: 72, + height: 72, + borderRadius: 16, + backgroundColor: DodoColors.background, + alignItems: 'center', + justifyContent: 'center', + }, + imageTextCol: { + flex: 1, + gap: 6, + }, + imageBadgeRow: { + flexDirection: 'row', + gap: 6, + }, + imageBadge: { + fontSize: 10, + fontWeight: '700', + color: DodoColors.textSecondary, + backgroundColor: DodoColors.background, + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + }, + imageHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + lineHeight: 16, + }, + imageButton: { + alignSelf: 'flex-start', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + imageButtonText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + sectionHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + marginTop: -8, + }, + fieldRow: { + flexDirection: 'row', + gap: 12, + }, + fieldHalf: { + flex: 1, + gap: 6, + }, + field: { + gap: 6, + }, + label: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + input: { + height: 42, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + fontSize: 13, + color: DodoColors.textPrimary, + }, + readonlyInput: { + height: 42, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + justifyContent: 'center', + }, + readonlyValue: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + readonlyPlaceholder: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + chipRow: { + flexDirection: 'row', + gap: 8, + }, + chip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + }, + chipSelected: { + borderColor: DodoColors.brand, + backgroundColor: DodoColors.brand, + }, + chipText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + chipTextSelected: { + color: DodoColors.brandForeground, + }, + requiredHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + actionBar: { + flexDirection: 'row', + gap: 8, + padding: 12, + borderTopWidth: 1, + borderTopColor: DodoColors.border, + backgroundColor: DodoColors.surface, + }, + secondaryButton: { + flex: 1, + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + primaryButton: { + flex: 1.4, + height: 44, + borderRadius: 10, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonDisabled: { + opacity: 0.4, + }, + primaryButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, +}); diff --git a/app/(tabs)/mypage/profile-edit.tsx b/app/(tabs)/mypage/profile-edit.tsx new file mode 100644 index 0000000..61c9e38 --- /dev/null +++ b/app/(tabs)/mypage/profile-edit.tsx @@ -0,0 +1,275 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useState } from 'react'; +import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +// TODO(이슈4): GET /users/me, PATCH /users/me, PUT /users/me/profile, DELETE /users/me 연동 후 mock 제거 +const MOCK_ACCOUNT = { + email: 'csb7543@naver.com', + name: '조수빈', + nickname: '조펭이', + region: '서울특별시 서대문구', +}; + +export default function ProfileEditScreen() { + const [nickname, setNickname] = useState(''); + const [region, setRegion] = useState(''); + + const handleSave = () => { + Alert.alert('저장 완료', '변경사항이 저장됐어요.'); + }; + + const handleWithdrawal = () => { + Alert.alert('회원 탈퇴', '탈퇴하면 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요. 계속하시겠어요?', [ + { text: '취소', style: 'cancel' }, + { text: '탈퇴하기', style: 'destructive' }, + ]); + }; + + return ( + + ACCOUNT + 회원정보 수정 + + + + + + + + + 최대 10MB + + 사진을 선택해 프로필 이미지를 변경할 수 있어요. + 변경한 이미지는 저장 후 바로 반영돼요. + {/* TODO(이슈7): expo-image-picker 설치 후 실제 첨부 연동 */} + + 이미지 변경 + + + + + + + 기본 정보 + + 이메일과 이름은 조회만 가능하고, 닉네임과 활동 지역은 이 화면에서 수정할 수 있어요. + + + + + 이메일 + + {MOCK_ACCOUNT.email} + + + + 이름 + + {MOCK_ACCOUNT.name} + + + + + + + 닉네임 * + + 비워두면 현재 닉네임을 그대로 유지해요. + + + 활동 지역 * + + 검색해서 활동 지역을 선택해주세요. + + + + * 표시된 항목은 필수 입력값입니다. + + + 변경사항 저장 + + + + + 회원 탈퇴 + 탈퇴하면 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요. + + 회원 탈퇴 + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 16, + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + marginBottom: 4, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 12, + }, + imageRow: { + flexDirection: 'row', + gap: 14, + }, + avatar: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + imageTextCol: { + flex: 1, + gap: 4, + }, + imageBadgeRow: { + flexDirection: 'row', + }, + imageBadge: { + fontSize: 10, + fontWeight: '700', + color: DodoColors.textSecondary, + backgroundColor: DodoColors.background, + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + }, + imageHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + lineHeight: 16, + }, + imageButton: { + alignSelf: 'flex-start', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + marginTop: 4, + }, + imageButtonText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + sectionHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + marginTop: -8, + }, + fieldRow: { + flexDirection: 'row', + gap: 12, + }, + fieldHalf: { + flex: 1, + gap: 6, + }, + label: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + input: { + height: 42, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + fontSize: 13, + color: DodoColors.textPrimary, + }, + fieldHint: { + fontSize: 10, + color: DodoColors.fenceIdleLabel, + }, + readonlyInput: { + height: 42, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + justifyContent: 'center', + }, + readonlyValue: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + requiredHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + saveButton: { + alignSelf: 'flex-end', + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 10, + backgroundColor: DodoColors.brand, + }, + saveButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, + withdrawalButton: { + alignSelf: 'flex-start', + paddingHorizontal: 16, + paddingVertical: 9, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.fenceOutside, + }, + withdrawalButtonText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.fenceOutside, + }, +}); diff --git a/app/(tabs)/mypage/walk-log.tsx b/app/(tabs)/mypage/walk-log.tsx new file mode 100644 index 0000000..79e767a --- /dev/null +++ b/app/(tabs)/mypage/walk-log.tsx @@ -0,0 +1,16 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export default function WalkLogScreen() { + return ( + + 산책 기록 화면 준비 중 + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: DodoColors.background }, + text: { color: DodoColors.textSecondary, fontSize: 15 }, +}); diff --git a/package.json b/package.json index 7f5ef38..a395b4b 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint", "lint:fix": "eslint . --fix", @@ -46,6 +46,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", "react-native-url-polyfill": "^4.0.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", diff --git a/yarn.lock b/yarn.lock index 0b728e5..adcb297 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2608,6 +2608,11 @@ big-integer@1.6.x: resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85" integrity sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg== +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + bplist-creator@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/bplist-creator/-/bplist-creator-0.1.0.tgz#018a2d1b587f769e379ef5519103730f8963ba1e" @@ -2987,6 +2992,30 @@ css-in-js-utils@^3.1.0: dependencies: hyphenate-style-name "^1.0.3" +css-select@^5.1.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e" + integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-tree@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + +css-what@^6.1.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" + integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== + csstype@^3.0.2: version "3.2.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" @@ -3122,6 +3151,36 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + dotenv-expand@~11.0.6: version "11.0.7" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-11.0.7.tgz#af695aea007d6fdc84c86cd8d0ad7beb40a0bd08" @@ -3178,6 +3237,11 @@ encodeurl@~2.0.0: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== +entities@^4.2.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + env-editor@^0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/env-editor/-/env-editor-0.4.2.tgz#4e76568d0bd8f5c2b6d314a9412c8fe9aa3ae861" @@ -4948,6 +5012,11 @@ math-intrinsics@^1.1.0: resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + memoize-one@^5.0.0: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" @@ -5551,6 +5620,13 @@ npm-package-arg@^11.0.0: semver "^7.3.5" validate-npm-package-name "^5.0.0" +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + nullthrows@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" @@ -6057,6 +6133,15 @@ react-native-screens@~4.16.0: react-native-is-edge-to-edge "^1.2.1" warn-once "^0.1.0" +react-native-svg@15.12.1: + version "15.12.1" + resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.12.1.tgz#7ba756dd6a235f86a2c312a1e7911f9b0d18ad3a" + integrity sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g== + dependencies: + css-select "^5.1.0" + css-tree "^1.1.3" + warn-once "0.1.1" + react-native-url-polyfill@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-4.0.0.tgz#a8d204654bd341eba304091ae350b89866878bd7" @@ -6623,7 +6708,7 @@ source-map@^0.5.6: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== -source-map@^0.6.0: +source-map@^0.6.0, source-map@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -7216,7 +7301,7 @@ walker@^1.0.7, walker@^1.0.8: dependencies: makeerror "1.0.12" -warn-once@^0.1.0, warn-once@^0.1.1: +warn-once@0.1.1, warn-once@^0.1.0, warn-once@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/warn-once/-/warn-once-0.1.1.tgz#952088f4fb56896e73fd4e6a3767272a3fccce43" integrity sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q== From 917337a1cf6cc92239bb824e38cc714c34774505 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 01:23:13 +0900 Subject: [PATCH 07/24] =?UTF-8?q?=F0=9F=90=9E=20Fix:=20=EC=97=A3=EC=A7=80?= =?UTF-8?q?=20=ED=88=AC=20=EC=97=A3=EC=A7=80=20=ED=99=94=EB=A9=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=83=81=ED=83=9C=EB=B0=94/=EC=A0=9C=EC=8A=A4?= =?UTF-8?q?=EC=B2=98=20=EB=B0=94=EC=99=80=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SafeAreaView 컴포넌트 대신 useSafeAreaInsets 훅으로 전환 (SafeAreaView가 New Architecture에서 검은 화면 유발) - 홈/커뮤니티/산책 화면 상단 패딩에 insets.top 반영 - 탭 바 높이에 insets.bottom 반영, 루트 Stack에 배경색 contentStyle 추가 --- app/(tabs)/_layout.tsx | 7 +- app/(tabs)/community/index.tsx | 4 +- app/(tabs)/index.tsx | 144 +++++++++++----------- app/_layout.tsx | 22 ++-- components/walk/fence-settings-screen.tsx | 78 ++++++------ 5 files changed, 136 insertions(+), 119 deletions(-) diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 86082e9..ef1307f 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,11 +1,14 @@ import Ionicons from '@expo/vector-icons/Ionicons'; import { Tabs } from 'expo-router'; import React from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { HapticTab } from '@/components/haptic-tab'; import { DodoColors } from '@/constants/theme'; export default function TabLayout() { + const insets = useSafeAreaInsets(); + return ( - + 커뮤니티 diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6e21f9b..f56b034 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,5 +1,6 @@ import Ionicons from '@expo/vector-icons/Ionicons'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DodoColors } from '@/constants/theme'; @@ -21,90 +22,93 @@ const MOCK_NOTICES: { tag: '안내' | '긴급'; title: string }[] = [ ]; export default function HomeScreen() { + const insets = useSafeAreaInsets(); return ( - - {/* AI 건강 레포트 */} - - - - AI 건강 레포트 - - - - - + + + {/* AI 건강 레포트 */} + + + + AI 건강 레포트 + - {MOCK_PET.name}의 건강 데이터를 분석 중이에요. - - {MOCK_PET.name}의 첫 건강 레포트를 만들 수 있도록 산책과 건강 기록을 조금 더 쌓아보세요. - + + + - - 레포트 준비 중 - - - + {MOCK_PET.name}의 건강 데이터를 분석 중이에요. + + {MOCK_PET.name}의 첫 건강 레포트를 만들 수 있도록 산책과 건강 기록을 조금 더 쌓아보세요. + - {/* 펫 카드 */} - - - - - - - {MOCK_PET.name} - - - {MOCK_PET.ageLabel} - + + 레포트 준비 중 + + - - - 품종 - {MOCK_PET.breed} - - - 체중 - {MOCK_PET.weight} + {/* 펫 카드 */} + + + + + + + {MOCK_PET.name} + + + {MOCK_PET.ageLabel} + - - - {MOCK_PET_PHOTOS.map((id, index) => ( - - + + + 품종 + {MOCK_PET.breed} - ))} - - + + 체중 + {MOCK_PET.weight} + + - {/* 공지사항 */} - - - 공지사항 - - - + + {MOCK_PET_PHOTOS.map((id, index) => ( + + + + ))} + - {MOCK_NOTICES.map((notice, index) => ( - - - - {notice.tag} + {/* 공지사항 */} + + + 공지사항 + + + + + + {MOCK_NOTICES.map((notice, index) => ( + + + + {notice.tag} + + + + {notice.title} - - {notice.title} - - - ))} - - + ))} + + + ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index 6475b1e..326d1de 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -6,8 +6,10 @@ import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; import 'react-native-reanimated'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import { useColorScheme } from '@/hooks/use-color-scheme'; +import { DodoColors } from '@/constants/theme'; import { useAuthStore } from '@/shared/lib/auth/authStore'; import { queryClient } from '@/shared/lib/react-query/queryClient'; @@ -24,14 +26,16 @@ export default function RootLayout() { }, [hydrate]); return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/components/walk/fence-settings-screen.tsx b/components/walk/fence-settings-screen.tsx index 34d6e69..d865c42 100644 --- a/components/walk/fence-settings-screen.tsx +++ b/components/walk/fence-settings-screen.tsx @@ -1,5 +1,6 @@ import { ReactNode, useState } from 'react'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DodoColors } from '@/constants/theme'; @@ -37,53 +38,56 @@ type Props = { export function FenceSettingsScreen({ renderMap }: Props) { const [selectedPetId, setSelectedPetId] = useState(MOCK_FENCES[1].petId); const selectedFence = MOCK_FENCES.find((f) => f.petId === selectedPetId) ?? MOCK_FENCES[0]; + const insets = useSafeAreaInsets(); return ( - - 산책 - 반려동물 안전 울타리를 설정하세요. + + + 산책 + 반려동물 안전 울타리를 설정하세요. - - 울타리 설정 + + 울타리 설정 - 반려동물 선택 - - {MOCK_FENCES.map((fence) => { - const selected = fence.petId === selectedPetId; - return ( - setSelectedPetId(fence.petId)} - > - {fence.petName} - - ); - })} - + 반려동물 선택 + + {MOCK_FENCES.map((fence) => { + const selected = fence.petId === selectedPetId; + return ( + setSelectedPetId(fence.petId)} + > + {fence.petName} + + ); + })} + - {renderMap({ selectedFence, fences: MOCK_FENCES })} + {renderMap({ selectedFence, fences: MOCK_FENCES })} - - - - {selectedFence.petName} - - - {selectedFence.active ? '사용 중' : '미사용'} - + + + + {selectedFence.petName} + + + {selectedFence.active ? '사용 중' : '미사용'} + + + 반경 {selectedFence.radiusMeters}m - 반경 {selectedFence.radiusMeters}m + + 울타리 수정 + - - 울타리 수정 - - - + + ); } From 3ea46a348aac8591ea6f6f2a1df917338a1f0b04 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 01:24:33 +0900 Subject: [PATCH 08/24] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Chore:=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EC=A7=80=EB=8F=84=20SDK=EC=9A=A9=20Gradle?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EB=93=B1=EB=A1=9D=20=ED=94=8C?= =?UTF-8?q?=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Naver Maps SDK(com.naver.maps:map-sdk) 아티팩트가 없어 빌드 실패하던 문제 해결 - 커스텀 Expo config plugin으로 android/build.gradle에 네이버 Maven 저장소 주입 --- app.json | 3 ++- plugins/withNaverMapsRepository.js | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 plugins/withNaverMapsRepository.js diff --git a/app.json b/app.json index 5dad73a..de6f613 100644 --- a/app.json +++ b/app.json @@ -47,7 +47,8 @@ "client_id": "furxnmcb69" } ], - "expo-secure-store" + "expo-secure-store", + "./plugins/withNaverMapsRepository.js" ], "experiments": { "typedRoutes": true, diff --git a/plugins/withNaverMapsRepository.js b/plugins/withNaverMapsRepository.js new file mode 100644 index 0000000..801eb97 --- /dev/null +++ b/plugins/withNaverMapsRepository.js @@ -0,0 +1,19 @@ +const { withProjectBuildGradle } = require('@expo/config-plugins'); + +// @mj-studio/react-native-naver-map의 config plugin이 AndroidManifest/Info.plist는 +// 채워주지만, 네이버 지도 SDK(com.naver.maps:map-sdk)가 올라가 있는 전용 Maven +// 저장소는 넣어주지 않는다. google()/mavenCentral()에는 없는 패키지라 이걸 빠뜨리면 +// "Could not find com.naver.maps:map-sdk" 에러로 빌드가 실패한다. +const NAVER_MAVEN_URL = 'https://repository.map.naver.com/archive/maven'; + +module.exports = function withNaverMapsRepository(config) { + return withProjectBuildGradle(config, (config) => { + if (config.modResults.language === 'groovy' && !config.modResults.contents.includes(NAVER_MAVEN_URL)) { + config.modResults.contents = config.modResults.contents.replace( + /allprojects\s*{\s*repositories\s*{/, + (match) => `${match}\n maven { url '${NAVER_MAVEN_URL}' }`, + ); + } + return config; + }); +}; From 3d61ff147213db7ef39afb1dd2ed047930fe03df Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 01:27:23 +0900 Subject: [PATCH 09/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=9E=AC=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프론트가 구글/네이버에 직접 인가 코드를 요청하고 백엔드는 코드 교환만 수행하는 구조로 변경 - POST /auth/social-login, POST /auth/reissue 연동 (accessTokenExpiresIn 단위가 로그인은 ms, 재발급은 초 단위인 실제 백엔드 특성 반영) - 마이도도 로그아웃 버튼에 실제 로그아웃 API 연동 - .env에 배포 주소/구글 클라이언트 ID 반영, 네이버는 백엔드 미배포로 선택값 처리 --- app/(tabs)/mypage/index.tsx | 37 ++++++++---- shared/api/authApi.ts | 63 ++++++++++++++++++++ shared/api/axios.ts | 71 +++++++++++++++------- shared/config/env.ts | 8 +++ shared/lib/auth/authStore.ts | 27 ++++++++- shared/lib/auth/oauth.ts | 85 +++++++++++++++------------ shared/lib/auth/refreshAccessToken.ts | 26 ++++---- 7 files changed, 232 insertions(+), 85 deletions(-) create mode 100644 shared/api/authApi.ts diff --git a/app/(tabs)/mypage/index.tsx b/app/(tabs)/mypage/index.tsx index 416a190..9ff262a 100644 --- a/app/(tabs)/mypage/index.tsx +++ b/app/(tabs)/mypage/index.tsx @@ -1,9 +1,12 @@ import Ionicons from '@expo/vector-icons/Ionicons'; import { Link, useRouter } from 'expo-router'; import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { logout } from '@/shared/api/authApi'; import { DodoColors } from '@/constants/theme'; import { useAuthStore } from '@/shared/lib/auth/authStore'; +import * as tokenStorage from '@/shared/lib/auth/tokenStorage'; // TODO(이슈4): GET /users/me 연동 후 mock 제거 const MOCK_USER = { @@ -37,6 +40,7 @@ const ACCOUNT_MENU: MenuItem[] = [ export default function MyPageScreen() { const router = useRouter(); const clearSession = useAuthStore((state) => state.clearSession); + const insets = useSafeAreaInsets(); const handleLogout = () => { Alert.alert('로그아웃', '로그아웃 하시겠어요?', [ @@ -45,6 +49,11 @@ export default function MyPageScreen() { text: '로그아웃', style: 'destructive', onPress: async () => { + const refreshToken = await tokenStorage.getRefreshToken(); + if (refreshToken) { + // 서버 로그아웃 실패해도 로컬 세션은 정리한다 (토큰이 이미 무효했을 수도 있음) + await logout(refreshToken).catch(() => {}); + } await clearSession(); router.replace('/(tabs)'); }, @@ -83,22 +92,24 @@ export default function MyPageScreen() { }; return ( - - - - + + + + + + + {MOCK_USER.nickname}님 + {MOCK_USER.region} + {MOCK_USER.email} - {MOCK_USER.nickname}님 - {MOCK_USER.region} - {MOCK_USER.email} - - 반려동물 - {PET_MENU.map(renderRow)} + 반려동물 + {PET_MENU.map(renderRow)} - 회원정보 - {ACCOUNT_MENU.map(renderRow)} - + 회원정보 + {ACCOUNT_MENU.map(renderRow)} + + ); } diff --git a/shared/api/authApi.ts b/shared/api/authApi.ts new file mode 100644 index 0000000..84603c2 --- /dev/null +++ b/shared/api/authApi.ts @@ -0,0 +1,63 @@ +import { apiClient } from './axios'; +import type { SocialProvider } from '@/shared/lib/auth/oauth'; + +// 웹(dodo-frontend)의 features/auth/api/auth.ts, model/types.ts와 동일한 계약. + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + /** 밀리초 (소셜 로그인/가입 응답 기준) */ + accessTokenExpiresIn: number; +} + +export interface SocialLoginSuccess extends AuthTokens { + profileUrl: string; +} + +export interface SocialSignupRequired { + email: string; + name: string; + profileUrl?: string; + /** 가입 완료(PUT /users/me/profile) 시 Authorization 헤더로 전달하는 임시 토큰 */ + registrationToken: string; + /** 임시 토큰 만료까지 남은 시간(ms) */ + tokenExpiresIn: number; +} + +export type SocialLoginResult = + | { kind: 'LOGIN'; data: SocialLoginSuccess } + | { kind: 'SIGNUP_REQUIRED'; data: SocialSignupRequired }; + +const SIGNUP_REQUIRED_STATUS = 202; + +/** + * 소셜 로그인 (POST /auth/social-login) + * - 200: 기존 회원 → 토큰 발급 (kind: 'LOGIN') + * - 202: 신규 사용자 → registrationToken 발급, 추가 정보 입력 필요 (kind: 'SIGNUP_REQUIRED') + */ +export async function socialLogin(provider: SocialProvider, code: string): Promise { + const response = await apiClient.post( + '/auth/social-login', + { provider, code }, + { skipAuthAttach: true, skipAuthRefresh: true }, + ); + + if (response.status === SIGNUP_REQUIRED_STATUS) { + return { kind: 'SIGNUP_REQUIRED', data: response.data as SocialSignupRequired }; + } + + return { kind: 'LOGIN', data: response.data as SocialLoginSuccess }; +} + +export interface LogoutResponse { + message: string; +} + +/** + * 로그아웃 (POST /auth/logout) + * accessToken은 apiClient 인터셉터가 Authorization 헤더로 자동 첨부한다. + */ +export async function logout(refreshToken: string): Promise { + const response = await apiClient.post('/auth/logout', { refreshToken }); + return response.data; +} diff --git a/shared/api/axios.ts b/shared/api/axios.ts index dd0f9fb..bcf9e7f 100644 --- a/shared/api/axios.ts +++ b/shared/api/axios.ts @@ -4,8 +4,24 @@ import { apiConfig } from '@/shared/config'; import { refreshAccessToken } from '@/shared/lib/auth/refreshAccessToken'; import * as tokenStorage from '@/shared/lib/auth/tokenStorage'; +// 웹(dodo-frontend)의 shared/api/axios.ts와 동일한 커스텀 config 확장. +declare module 'axios' { + export interface AxiosRequestConfig { + /** true면 Authorization 미첨부·선제 refresh 생략 (소셜 로그인 등) */ + skipAuthAttach?: boolean; + /** true면 401 시 재발급·재시도를 하지 않음 */ + skipAuthRefresh?: boolean; + _retry?: boolean; + } +} + type RetryableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean }; +function isPublicAuthPath(url: string | undefined): boolean { + if (!url) return false; + return url.includes('/auth/social-login') || url.includes('/auth/reissue'); +} + export const apiClient = axios.create({ baseURL: apiConfig.baseURL, timeout: apiConfig.timeout, @@ -14,20 +30,29 @@ export const apiClient = axios.create({ }, }); -apiClient.interceptors.request.use( - async (config) => { - let accessToken = await tokenStorage.getAccessToken(); - - // 만료 60초 전이면 요청 전에 미리 갱신 (웹의 ACCESS_TOKEN_REFRESH_BUFFER_MS와 동일한 전략) - if (accessToken && (await tokenStorage.isAccessTokenExpiringSoon())) { - accessToken = (await refreshAccessToken()) ?? accessToken; - } - - if (accessToken) { - config.headers.Authorization = `Bearer ${accessToken}`; +async function attachAccessToken(config: InternalAxiosRequestConfig): Promise { + if (config.skipAuthAttach || isPublicAuthPath(config.url)) { + if (config.headers) { + delete config.headers.Authorization; } return config; - }, + } + + let accessToken = await tokenStorage.getAccessToken(); + + if (accessToken && !config.skipAuthRefresh && (await tokenStorage.isAccessTokenExpiringSoon())) { + accessToken = (await refreshAccessToken()) ?? accessToken; + } + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +} + +apiClient.interceptors.request.use( + async (config) => attachAccessToken(config), (error) => Promise.reject(error), ); @@ -36,14 +61,20 @@ apiClient.interceptors.response.use( async (error) => { const originalRequest = error.config as RetryableRequestConfig | undefined; - // 사전 갱신을 놓친 경우(서버 측 강제 만료 등)를 대비한 사후 처리 - if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { - originalRequest._retry = true; - const newAccessToken = await refreshAccessToken(); - if (newAccessToken) { - originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return apiClient(originalRequest); - } + if ( + !originalRequest || + originalRequest.skipAuthRefresh || + originalRequest._retry || + error.response?.status !== 401 + ) { + return Promise.reject(error); + } + + originalRequest._retry = true; + const newAccessToken = await refreshAccessToken(); + if (newAccessToken) { + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return apiClient(originalRequest); } return Promise.reject(error); diff --git a/shared/config/env.ts b/shared/config/env.ts index 9053895..f9d025b 100644 --- a/shared/config/env.ts +++ b/shared/config/env.ts @@ -1,9 +1,17 @@ const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL; +const GOOGLE_CLIENT_ID = process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID; +// TODO: 네이버 로그인 백엔드 배포되면 필수값으로 승격하고 아래 throw 추가 +const NAVER_CLIENT_ID = process.env.EXPO_PUBLIC_NAVER_CLIENT_ID ?? ''; if (!API_BASE_URL) { throw new Error('Missing env: EXPO_PUBLIC_API_BASE_URL'); } +if (!GOOGLE_CLIENT_ID) { + throw new Error('Missing env: EXPO_PUBLIC_GOOGLE_CLIENT_ID'); +} export const env = { API_BASE_URL, + GOOGLE_CLIENT_ID, + NAVER_CLIENT_ID, }; diff --git a/shared/lib/auth/authStore.ts b/shared/lib/auth/authStore.ts index 76d6e3e..24bb82b 100644 --- a/shared/lib/auth/authStore.ts +++ b/shared/lib/auth/authStore.ts @@ -3,13 +3,23 @@ import { create } from 'zustand'; import * as tokenStorage from './tokenStorage'; import type { AuthProfile, TokenPair } from './tokenStorage'; +export type LoginSessionData = { + accessToken: string; + refreshToken: string; + /** 밀리초 (POST /auth/social-login, PUT /users/me/profile 응답 기준) */ + accessTokenExpiresIn: number; + profileUrl: string; + nickname?: string; + region?: string; +}; + type AuthState = { /** 앱 시작 시 SecureStore/AsyncStorage에서 세션 복원이 끝났는지 여부 */ isHydrated: boolean; isAuthenticated: boolean; profile: AuthProfile; hydrate: () => Promise; - setSession: (params: { tokens: TokenPair; profile: AuthProfile }) => Promise; + setSession: (login: LoginSessionData) => Promise; updateProfile: (profile: Partial) => Promise; clearSession: () => Promise; }; @@ -33,7 +43,20 @@ export const useAuthStore = create((set) => ({ set({ isHydrated: true, isAuthenticated: !!accessToken, profile }); }, - setSession: async ({ tokens, profile }) => { + setSession: async (login) => { + const tokens: TokenPair = { + accessToken: login.accessToken, + refreshToken: login.refreshToken, + accessTokenTtlMs: login.accessTokenExpiresIn, + accessTokenExpiresAt: Date.now() + login.accessTokenExpiresIn, + }; + const profile: AuthProfile = { + profileUrl: login.profileUrl, + nickname: login.nickname ?? null, + region: login.region ?? null, + notificationEnabled: false, + }; + await Promise.all([tokenStorage.saveTokens(tokens), tokenStorage.saveProfile(profile)]); set({ isAuthenticated: true, profile }); }, diff --git a/shared/lib/auth/oauth.ts b/shared/lib/auth/oauth.ts index 632ee2d..fb93bb5 100644 --- a/shared/lib/auth/oauth.ts +++ b/shared/lib/auth/oauth.ts @@ -2,37 +2,55 @@ import * as Crypto from 'expo-crypto'; import * as Linking from 'expo-linking'; import * as WebBrowser from 'expo-web-browser'; -import { apiConfig } from '@/shared/config'; +import { env } from '@/shared/config/env'; -import type { TokenPair } from './tokenStorage'; +// 웹(dodo-frontend)의 features/auth/lib/oauth.ts와 동일한 방식. +// 앱이 직접 구글/네이버 인가 서버로 이동하고, 돌아온 인가 코드(code)를 백엔드 +// POST /auth/social-login 으로 넘겨 토큰을 발급받는다 (백엔드가 OAuth를 대행하지 않음). +export type SocialProvider = 'GOOGLE' | 'NAVER'; -export type SocialProvider = 'google' | 'naver'; +interface ProviderOAuthConfig { + authorizeUrl: string; + clientId: string; + scope?: string; +} + +const PROVIDER_OAUTH: Record = { + GOOGLE: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + clientId: env.GOOGLE_CLIENT_ID, + scope: 'openid email profile', + }, + NAVER: { + authorizeUrl: 'https://nid.naver.com/oauth2.0/authorize', + clientId: env.NAVER_CLIENT_ID, + }, +}; -export type SocialLoginResult = - | { success: true; tokens: TokenPair } - | { success: false; reason: 'cancelled' | 'error' }; +export type SocialAuthCodeResult = + | { success: true; code: string } + | { success: false; reason: 'cancelled' | 'error' | 'state_mismatch' }; /** - * TODO(백엔드 협의 필요): 정확한 OAuth 시작 엔드포인트/파라미터명 미확정. - * 아래는 Spring Security OAuth2 Client 기본 컨벤션(`/oauth2/authorization/{provider}`)을 - * 가정한 값이며, 콜백에 accessToken/refreshToken/accessTokenTtlMs를 쿼리 파라미터로 - * 실어 돌려준다고 가정한다. 웹의 buildSocialAuthUrl()과 실제 스펙을 맞춰야 한다. - * 딥링크(dodoapp://auth/callback/{provider})는 Google/Naver 콘솔 + 백엔드 양쪽에 - * 등록되어 있어야 정상 동작한다 (docs/DODO_RN_PORTING_SPEC.md §5 참고). + * 구글/네이버 로그인 화면을 열고, 돌아온 인가 코드(code)를 반환한다. + * 코드를 실제 토큰으로 교환하는 건 shared/api/authApi.ts의 socialLogin()이 담당. */ -function buildAuthorizeUrl(provider: SocialProvider, redirectUri: string, state: string): string { - const url = new URL(`${apiConfig.baseURL}/oauth2/authorization/${provider}`); - url.searchParams.set('redirect_uri', redirectUri); - url.searchParams.set('state', state); - return url.toString(); -} - -export async function startSocialLogin(provider: SocialProvider): Promise { - const redirectUri = Linking.createURL(`auth/callback/${provider}`); - // 웹의 CSRF state 검증(sessionStorage에 저장 후 콜백에서 비교)과 동일한 목적 +export async function requestSocialAuthCode(provider: SocialProvider): Promise { + const config = PROVIDER_OAUTH[provider]; + const redirectUri = Linking.createURL(`auth/callback/${provider.toLowerCase()}`); const state = Crypto.randomUUID(); - const authorizeUrl = buildAuthorizeUrl(provider, redirectUri, state); + const params = new URLSearchParams({ + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectUri, + state, + }); + if (config.scope) { + params.set('scope', config.scope); + } + + const authorizeUrl = `${config.authorizeUrl}?${params.toString()}`; const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, redirectUri); if (result.type !== 'success' || !result.url) { @@ -41,25 +59,14 @@ export async function startSocialLogin(provider: SocialProvider): Promise( - `${apiConfig.baseURL}/auth/refresh`, +// 웹(dodo-frontend)의 shared/lib/auth/refreshSession.ts와 동일한 계약. +interface ReissueResponse { + accessToken: string; + refreshToken: string; + /** OpenAPI 기준 초 단위 — ms로 변환해서 저장 */ + accessTokenExpiresIn: number; +} + +async function requestReissue(refreshToken: string): Promise { + const { data } = await axios.post( + `${apiConfig.baseURL}/auth/reissue`, { refreshToken }, + { headers: { 'Content-Type': 'application/json' }, timeout: apiConfig.timeout }, ); return data; } @@ -35,11 +39,11 @@ async function performRefresh(): Promise { if (!refreshToken) return null; try { - const result = await requestNewAccessToken(refreshToken); - const accessTokenTtlMs = result.accessTokenTtlMs; + const result = await requestReissue(refreshToken); + const accessTokenTtlMs = result.accessTokenExpiresIn * 1000; await tokenStorage.saveTokens({ accessToken: result.accessToken, - refreshToken: result.refreshToken ?? refreshToken, + refreshToken: result.refreshToken, accessTokenTtlMs, accessTokenExpiresAt: Date.now() + accessTokenTtlMs, }); From d3928dbde04990cfc9e785129bd1a12c20851c87 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 13:12:20 +0900 Subject: [PATCH 10/24] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Chore:=20=EC=9B=B9?= =?UTF-8?q?=EC=86=8C=EC=BC=93=C2=B7=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=94=BC?= =?UTF-8?q?=EC=BB=A4=20=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @stomp/stompjs: 산책 실시간 위치 STOMP 연동용 - expo-image-picker: 사진 업로드 기능용 (앨범 접근 권한 플러그인 설정 포함) --- app.json | 8 ++++++++ package.json | 2 ++ yarn.lock | 17 +++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/app.json b/app.json index de6f613..787249d 100644 --- a/app.json +++ b/app.json @@ -48,6 +48,14 @@ } ], "expo-secure-store", + [ + "expo-image-picker", + { + "photosPermission": "사진을 선택하려면 앨범 접근 권한이 필요해요.", + "cameraPermission": false, + "microphonePermission": false + } + ], "./plugins/withNaverMapsRepository.js" ], "experiments": { diff --git a/package.json b/package.json index a395b4b..7285b40 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.100.9", "axios": "^1.16.0", "expo": "~54.0.33", @@ -31,6 +32,7 @@ "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.11", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", diff --git a/yarn.lock b/yarn.lock index adcb297..95bfc3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1812,6 +1812,11 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@stomp/stompjs@^7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@stomp/stompjs/-/stompjs-7.3.0.tgz#5655b93e086a0be684291424c5bc8c92949b33ee" + integrity sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ== + "@tanstack/query-core@5.100.9": version "5.100.9" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.9.tgz#dcf44ef25cf42a4da229bcab1d8d33e80a740a99" @@ -3693,6 +3698,18 @@ expo-haptics@~15.0.8: resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3" integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g== +expo-image-loader@~6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072" + integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ== + +expo-image-picker@~17.0.11: + version "17.0.11" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.11.tgz#8f1537df946f7c19747ba9d0043ef7e3e8cb67dc" + integrity sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ== + dependencies: + expo-image-loader "~6.0.0" + expo-image@~3.0.11: version "3.0.11" resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-3.0.11.tgz#54195565dc710e632c10414c3609deebb7149ac5" From ef50314c98a2abcca476eac4b5ca738c6a30c531 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 13:13:03 +0900 Subject: [PATCH 11/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20API=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EB=B0=8F=20=EA=B3=B5=EC=9A=A9=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인별 axios 함수/타입 전체 작성 (활동/커뮤니티/가족/울타리/파일/메인/펫/유저) - 산책 실시간 위치용 STOMP 훅, 이미지 피커/캘린더/지역선택 등 재사용 컴포넌트 추가 - 지역(시/도·시/군/구) 데이터는 웹(dodo-frontend)에서 그대로 이식 --- components/auth/region-select-modal.tsx | 146 ++++++++++++ components/common/avatar-image-picker.tsx | 120 ++++++++++ components/common/date-picker-modal.tsx | 247 +++++++++++++++++++++ components/common/multi-image-picker.tsx | 162 ++++++++++++++ shared/api/activityApi.ts | 196 ++++++++++++++++ shared/api/communityApi.ts | 240 ++++++++++++++++++++ shared/api/familyApi.ts | 116 ++++++++++ shared/api/fenceApi.ts | 60 +++++ shared/api/fileApi.ts | 47 ++++ shared/api/mainApi.ts | 41 ++++ shared/api/petApi.ts | 206 +++++++++++++++++ shared/api/userApi.ts | 65 ++++++ shared/lib/family/invitationCodeCache.ts | 34 +++ shared/lib/files/imageUploadPolicy.ts | 49 ++++ shared/lib/files/pickImage.ts | 53 +++++ shared/lib/format/date.ts | 38 ++++ shared/lib/geo/haversine.ts | 28 +++ shared/lib/regions/data.ts | 223 +++++++++++++++++++ shared/lib/regions/index.ts | 34 +++ shared/lib/ws/useActivityLocationSocket.ts | 16 ++ shared/lib/ws/useFenceLocationSocket.ts | 19 ++ shared/lib/ws/useStompTopic.ts | 65 ++++++ 22 files changed, 2205 insertions(+) create mode 100644 components/auth/region-select-modal.tsx create mode 100644 components/common/avatar-image-picker.tsx create mode 100644 components/common/date-picker-modal.tsx create mode 100644 components/common/multi-image-picker.tsx create mode 100644 shared/api/activityApi.ts create mode 100644 shared/api/communityApi.ts create mode 100644 shared/api/familyApi.ts create mode 100644 shared/api/fenceApi.ts create mode 100644 shared/api/fileApi.ts create mode 100644 shared/api/mainApi.ts create mode 100644 shared/api/petApi.ts create mode 100644 shared/api/userApi.ts create mode 100644 shared/lib/family/invitationCodeCache.ts create mode 100644 shared/lib/files/imageUploadPolicy.ts create mode 100644 shared/lib/files/pickImage.ts create mode 100644 shared/lib/format/date.ts create mode 100644 shared/lib/geo/haversine.ts create mode 100644 shared/lib/regions/data.ts create mode 100644 shared/lib/regions/index.ts create mode 100644 shared/lib/ws/useActivityLocationSocket.ts create mode 100644 shared/lib/ws/useFenceLocationSocket.ts create mode 100644 shared/lib/ws/useStompTopic.ts diff --git a/components/auth/region-select-modal.tsx b/components/auth/region-select-modal.tsx new file mode 100644 index 0000000..cedb75e --- /dev/null +++ b/components/auth/region-select-modal.tsx @@ -0,0 +1,146 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useEffect, useState } from 'react'; +import { FlatList, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { DodoColors } from '@/constants/theme'; +import { + formatRegionLabel, + getSigunguList, + hasSigunguOptions, + parseRegionLabel, + SIDO_LIST, +} from '@/shared/lib/regions'; + +type Props = { + visible: boolean; + initialRegion: string; + onClose: () => void; + onConfirm: (region: string) => void; +}; + +export function RegionSelectModal({ visible, initialRegion, onClose, onConfirm }: Props) { + const insets = useSafeAreaInsets(); + const [sido, setSido] = useState(''); + + useEffect(() => { + if (visible) setSido(parseRegionLabel(initialRegion).sido); + }, [visible, initialRegion]); + + const handleSelectSido = (value: string) => { + if (hasSigunguOptions(value)) { + setSido(value); + return; + } + onConfirm(formatRegionLabel(value, null)); + onClose(); + }; + + const handleSelectSigungu = (value: string) => { + onConfirm(formatRegionLabel(sido, value)); + onClose(); + }; + + const sigunguList = sido ? getSigunguList(sido) : []; + + return ( + + + + + + {sido ? ( + setSido('')}> + + + ) : ( + + )} + {sido ? `${sido} · 시/군/구 선택` : '지역 선택'} + + + + + + {!sido ? ( + item} + style={styles.list} + renderItem={({ item }) => ( + handleSelectSido(item)}> + {item} + + + )} + /> + ) : ( + item} + style={styles.list} + renderItem={({ item }) => ( + handleSelectSigungu(item)}> + {item} + + )} + /> + )} + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + backdropTap: { + flex: 1, + }, + sheet: { + backgroundColor: DodoColors.surface, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + height: '70%', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 12, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: DodoColors.border, + }, + headerButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 15, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + list: { + flex: 1, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: DodoColors.background, + }, + rowText: { + fontSize: 14, + color: DodoColors.textPrimary, + }, +}); diff --git a/components/common/avatar-image-picker.tsx b/components/common/avatar-image-picker.tsx new file mode 100644 index 0000000..0d1e20e --- /dev/null +++ b/components/common/avatar-image-picker.tsx @@ -0,0 +1,120 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Image } from 'expo-image'; +import { useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; +import { uploadFiles } from '@/shared/api/fileApi'; +import { IMAGE_UPLOAD_POLICY_DESCRIPTION } from '@/shared/lib/files/imageUploadPolicy'; +import { pickSingleImage, toLocalFile } from '@/shared/lib/files/pickImage'; + +type Props = { + imageUrl: string | null; + onUploaded: (url: string) => void; + /** 회원가입 중처럼 정식 accessToken이 아직 없을 때(registrationToken) 전달 */ + authToken?: string; + size?: number; +}; + +export function AvatarImagePicker({ imageUrl, onUploaded, authToken, size = 64 }: Props) { + const [localPreview, setLocalPreview] = useState(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(''); + + const displayUrl = localPreview ?? imageUrl; + + const handlePick = async () => { + if (uploading) return; + setError(''); + + try { + const asset = await pickSingleImage(); + if (!asset) return; + + setLocalPreview(asset.uri); + setUploading(true); + + const { imageUrls } = await uploadFiles([toLocalFile(asset)], authToken); + if (imageUrls[0]) onUploaded(imageUrls[0]); + } catch (err) { + setLocalPreview(null); + setError(err instanceof Error ? err.message : '이미지 업로드에 실패했어요.'); + } finally { + setUploading(false); + } + }; + + return ( + + + {displayUrl ? ( + + ) : ( + + + + )} + {uploading && ( + + + + )} + + + + + {error || IMAGE_UPLOAD_POLICY_DESCRIPTION} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + gap: 6, + }, + avatarWrap: { + position: 'relative', + }, + avatar: { + backgroundColor: DodoColors.background, + }, + avatarFallback: { + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + uploadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.35)', + }, + editButton: { + position: 'absolute', + bottom: -2, + right: -2, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: DodoColors.surface, + }, + hint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + textAlign: 'center', + }, + hintError: { + color: DodoColors.fenceOutside, + }, +}); diff --git a/components/common/date-picker-modal.tsx b/components/common/date-picker-modal.tsx new file mode 100644 index 0000000..2de94e5 --- /dev/null +++ b/components/common/date-picker-modal.tsx @@ -0,0 +1,247 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useEffect, useState } from 'react'; +import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { DodoColors } from '@/constants/theme'; + +type Props = { + visible: boolean; + /** 'YYYY-MM-DD' */ + initialDate?: string; + /** 'YYYY-MM-DD' — 이 날짜 이후는 선택 불가 */ + maxDate?: string; + /** 'YYYY-MM-DD' — 이 날짜 이전은 선택 불가 */ + minDate?: string; + onClose: () => void; + onConfirm: (date: string) => void; +}; + +const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; + +function pad(n: number) { + return String(n).padStart(2, '0'); +} + +function toDateString(y: number, m: number, d: number) { + return `${y}-${pad(m + 1)}-${pad(d)}`; +} + +function parseDate(value?: string): Date | null { + if (!value) return null; + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!match) return null; + const [, y, m, d] = match; + const date = new Date(Number(y), Number(m) - 1, Number(d)); + return Number.isNaN(date.getTime()) ? null : date; +} + +export function DatePickerModal({ visible, initialDate, maxDate, minDate, onClose, onConfirm }: Props) { + const insets = useSafeAreaInsets(); + const today = new Date(); + const initial = parseDate(initialDate) ?? today; + const [viewYear, setViewYear] = useState(initial.getFullYear()); + const [viewMonth, setViewMonth] = useState(initial.getMonth()); + + useEffect(() => { + if (!visible) return; + const base = parseDate(initialDate) ?? today; + setViewYear(base.getFullYear()); + setViewMonth(base.getMonth()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, initialDate]); + + const max = parseDate(maxDate); + const min = parseDate(minDate); + const selected = parseDate(initialDate); + + const firstOfMonth = new Date(viewYear, viewMonth, 1); + const startWeekday = firstOfMonth.getDay(); + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + + const cells: (number | null)[] = [ + ...Array.from({ length: startWeekday }, () => null), + ...Array.from({ length: daysInMonth }, (_, i) => i + 1), + ]; + + const goPrevMonth = () => { + if (viewMonth === 0) { + setViewYear((y) => y - 1); + setViewMonth(11); + } else { + setViewMonth((m) => m - 1); + } + }; + + const goNextMonth = () => { + if (viewMonth === 11) { + setViewYear((y) => y + 1); + setViewMonth(0); + } else { + setViewMonth((m) => m + 1); + } + }; + + const isDisabled = (day: number) => { + const d = new Date(viewYear, viewMonth, day); + if (max && d > max) return true; + if (min && d < min) return true; + return false; + }; + + const isSelected = (day: number) => + !!selected && + selected.getFullYear() === viewYear && + selected.getMonth() === viewMonth && + selected.getDate() === day; + + return ( + + + + + + + + + + {viewYear}년 {viewMonth + 1}월 + + + + + + + + {WEEKDAYS.map((w) => ( + + {w} + + ))} + + + + {cells.map((day, index) => { + if (day === null) return ; + const disabled = isDisabled(day); + const active = isSelected(day); + return ( + { + onConfirm(toDateString(viewYear, viewMonth, day)); + onClose(); + }} + > + + + {day} + + + + ); + })} + + + { + setViewYear(today.getFullYear()); + setViewMonth(today.getMonth()); + }} + > + 오늘로 이동 + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + backdropTap: { + flex: 1, + }, + sheet: { + backgroundColor: DodoColors.surface, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingTop: 16, + paddingHorizontal: 16, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingBottom: 12, + }, + headerButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 15, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + weekdayRow: { + flexDirection: 'row', + }, + weekdayText: { + flex: 1, + textAlign: 'center', + fontSize: 11, + fontWeight: '600', + color: DodoColors.fenceIdleLabel, + paddingBottom: 6, + }, + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + cell: { + width: `${100 / 7}%`, + aspectRatio: 1, + alignItems: 'center', + justifyContent: 'center', + }, + dayCircle: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + dayCircleActive: { + backgroundColor: DodoColors.brand, + }, + dayText: { + fontSize: 13, + color: DodoColors.textPrimary, + }, + dayTextDisabled: { + color: DodoColors.border, + }, + dayTextActive: { + color: DodoColors.brandForeground, + fontWeight: '700', + }, + todayButton: { + alignSelf: 'center', + paddingVertical: 10, + }, + todayButtonText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + textDecorationLine: 'underline', + }, +}); diff --git a/components/common/multi-image-picker.tsx b/components/common/multi-image-picker.tsx new file mode 100644 index 0000000..fb25d44 --- /dev/null +++ b/components/common/multi-image-picker.tsx @@ -0,0 +1,162 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Image } from 'expo-image'; +import { useState } from 'react'; +import { ActivityIndicator, Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; +import { uploadFiles } from '@/shared/api/fileApi'; +import { pickMultipleImages, toLocalFile } from '@/shared/lib/files/pickImage'; + +type Item = { key: string; uri: string; uploading: boolean }; + +type Props = { + imageUrls: string[]; + onChange: (urls: string[]) => void; + maxCount?: number; +}; + +let keySeq = 0; + +export function MultiImagePicker({ imageUrls, onChange, maxCount = 10 }: Props) { + const [items, setItems] = useState(() => + imageUrls.map((uri) => ({ key: String(keySeq++), uri, uploading: false })), + ); + + const commit = (next: Item[]) => { + setItems(next); + onChange(next.filter((i) => !i.uploading).map((i) => i.uri)); + }; + + const handleAdd = async () => { + const remaining = maxCount - items.length; + if (remaining <= 0) { + Alert.alert('알림', `이미지는 최대 ${maxCount}장까지 첨부할 수 있어요.`); + return; + } + + try { + const assets = await pickMultipleImages(remaining); + if (assets.length === 0) return; + + const pending: Item[] = assets.map((asset) => ({ key: String(keySeq++), uri: asset.uri, uploading: true })); + const withPending = [...items, ...pending]; + setItems(withPending); + + const { imageUrls: uploaded } = await uploadFiles(assets.map(toLocalFile)); + + const resolved = withPending.map((item) => { + const pendingIndex = pending.findIndex((p) => p.key === item.key); + if (pendingIndex === -1) return item; + return { ...item, uri: uploaded[pendingIndex] ?? item.uri, uploading: false }; + }); + commit(resolved); + } catch (error) { + setItems(items); + Alert.alert('오류', error instanceof Error ? error.message : '이미지를 업로드하지 못했어요.'); + } + }; + + const handleRemove = (key: string) => { + commit(items.filter((i) => i.key !== key)); + }; + + return ( + + + + 이미지 추가 + + + {items.length === 0 ? ( + + 아직 첨부된 이미지가 없어요. + + ) : ( + + {items.map((item) => ( + + + {item.uploading && ( + + + + )} + {!item.uploading && ( + handleRemove(item.key)}> + + + )} + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + addButton: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: 6, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.surface, + marginBottom: 10, + }, + addButtonText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + empty: { + borderRadius: 10, + borderWidth: 1, + borderStyle: 'dashed', + borderColor: DodoColors.border, + paddingVertical: 20, + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + row: { + flexDirection: 'row', + }, + thumbWrap: { + width: 76, + height: 76, + borderRadius: 10, + overflow: 'hidden', + marginRight: 8, + position: 'relative', + backgroundColor: DodoColors.background, + }, + thumb: { + width: '100%', + height: '100%', + }, + thumbOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.35)', + }, + removeButton: { + position: 'absolute', + top: 4, + right: 4, + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: 'rgba(0,0,0,0.55)', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/shared/api/activityApi.ts b/shared/api/activityApi.ts new file mode 100644 index 0000000..659015f --- /dev/null +++ b/shared/api/activityApi.ts @@ -0,0 +1,196 @@ +import { apiClient } from './axios'; + +// 활동 기록(산책) 도메인. 웹에는 없던 신규 기능 — API 명세(Activity History API) 기준으로 작성. +// 상태 흐름: BEFORE(대기) --start--> IN_PROGRESS(진행중) --finish--> COMPLETED(완료) +// IN_PROGRESS --cancel--> CANCELED(중단) --start(재개)--> IN_PROGRESS + +export type ActivityType = 'WALKING' | 'SLEEPING'; +export type ActivityHistoryStatus = 'BEFORE' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED'; + +export interface ActivityPetSummary { + id: number; + name: string; + age?: number; + profileImageUrl?: string | null; +} + +export interface ActivityHistoryListItem { + historyId: number; + activityType: ActivityType; + distance: number; + activityHistoryStartAt: string; + activityHistoryEndAt: string | null; + activityHistoryStatus: ActivityHistoryStatus; + reactionCount: number; + heartAverage: number | null; + pet: ActivityPetSummary; +} + +export interface GetActivityHistoryResponse { + message: string; + histories: ActivityHistoryListItem[]; +} + +/** 내 활동 기록 조회 (GET /activities/history) */ +export async function getActivityHistory( + page = 0, + size = 10, + sort = 'activityHistoryStartAt,desc', +): Promise { + const response = await apiClient.get('/activities/history', { + params: { page, size, sort }, + }); + return response.data; +} + +export interface CreateActivityRequest { + petId: number; + activityType: ActivityType; +} + +export interface CreateActivityResponse { + message: string; + historyId: number; + activityType: ActivityType; +} + +/** 활동 기록 생성 (POST /activities/history) — 초기 상태 BEFORE. 진행 중인 기록이 있으면 409 */ +export async function createActivityHistory(body: CreateActivityRequest): Promise { + const response = await apiClient.post('/activities/history', body); + return response.data; +} + +export interface StartActivityRequest { + startLatitude: number; + startLongitude: number; +} + +/** 활동 시작/재개 (PATCH /activities/history/{historyId}/start) — BEFORE|CANCELED → IN_PROGRESS */ +export async function startActivityHistory( + historyId: number | string, + body: StartActivityRequest, +): Promise<{ message: string }> { + const response = await apiClient.patch<{ message: string }>(`/activities/history/${historyId}/start`, body); + return response.data; +} + +export interface FinishActivityResponse { + message: string; + historyId: number; + activityType: ActivityType; + distance: number; + activityHistoryStartAt: string; + activityHistoryEndAt: string; +} + +/** 활동 종료 (PATCH /activities/history/{historyId}/finish) — IN_PROGRESS → COMPLETED */ +export async function finishActivityHistory(historyId: number | string): Promise { + const response = await apiClient.patch(`/activities/history/${historyId}/finish`); + return response.data; +} + +/** 활동 취소/일시정지 (PATCH /activities/history/{historyId}/cancel) — IN_PROGRESS → CANCELED */ +export async function cancelActivityHistory(historyId: number | string): Promise<{ message: string }> { + const response = await apiClient.patch<{ message: string }>(`/activities/history/${historyId}/cancel`); + return response.data; +} + +export interface PetActivityStatusResponse { + message: string; + historyId?: number; +} + +export type InferredActivityStatus = 'IN_PROGRESS' | 'BEFORE' | 'CANCELED' | 'NONE'; + +/** + * 반려동물 활동 상태 조회 (GET /activities/history/{petId}/status) + * 응답에 상태 enum이 따로 없고 message 문구로만 상태를 구분해야 해서, 문구 기반으로 상태를 추론한다. + * 백엔드가 문구를 바꾸면 이 추론이 깨질 수 있음 — 실제 연동 후 문구 재확인 필요. + */ +export function inferActivityStatus(message: string): InferredActivityStatus { + if (message.includes('기록중')) return 'IN_PROGRESS'; + if (message.includes('중단')) return 'CANCELED'; + if (message.includes('시작 전')) return 'BEFORE'; + return 'NONE'; +} + +export async function getPetActivityStatus(petId: number | string): Promise { + const response = await apiClient.get(`/activities/history/${petId}/status`); + return response.data; +} + +export interface ActivityHistoryDetail { + message: string; + historyId: number; + petId: number; + distance: number; + activityHistoryStartAt: string; + activityHistoryEndAt: string | null; + startLatitude: number; + startLongitude: number; + reactionCount: number; + isLikedByMe: boolean; +} + +/** 활동 상세 정보 조회 (GET /activities/history/{historyId}) */ +export async function getActivityHistoryDetail(historyId: number | string): Promise { + const response = await apiClient.get(`/activities/history/${historyId}`); + return response.data; +} + +/** 활동 기록 삭제 (DELETE /activities/history/{historyId}) */ +export async function deleteActivityHistory(historyId: number | string): Promise<{ message: string }> { + const response = await apiClient.delete<{ message: string }>(`/activities/history/${historyId}`); + return response.data; +} + +export interface RoutePoint { + routePointId: number; + latitude: number; + longitude: number; + measuredAt?: string; +} + +export interface ActivityRouteResponse { + message: string; + historyId: number; + activityType: ActivityType; + distance: number; + activityHistoryStartAt: string; + activityHistoryEndAt: string | null; + startLatitude: number; + startLongitude: number; + activityHistoryStatus: ActivityHistoryStatus; + routePoints: RoutePoint[]; +} + +/** 활동 상세 경로 조회 (GET /activities/history/{historyId}/route) */ +export async function getActivityRoute(historyId: number | string): Promise { + const response = await apiClient.get(`/activities/history/${historyId}/route`); + return response.data; +} + +export type PopularReactionType = 'LIKE' | 'DISLIKE'; + +export interface GetPopularActivitiesParams { + latitude: number; + longitude: number; + reactionType: PopularReactionType; + limit?: number; + cursor?: number; +} + +export interface GetPopularActivitiesResponse { + message: string; + histories: ActivityHistoryListItem[]; +} + +/** + * 주변 인기 활동 조회 (GET /activities/history/popular) + * 커서 기반 페이지네이션 — 다음 페이지 cursor는 마지막 항목의 historyId를 사용한다고 가정 + * (응답에 별도 nextCursor 필드가 명세에 없어 이렇게 추정, 실제 응답 확인 후 조정 필요) + */ +export async function getPopularActivities(params: GetPopularActivitiesParams): Promise { + const response = await apiClient.get('/activities/history/popular', { params }); + return response.data; +} diff --git a/shared/api/communityApi.ts b/shared/api/communityApi.ts new file mode 100644 index 0000000..4f305bc --- /dev/null +++ b/shared/api/communityApi.ts @@ -0,0 +1,240 @@ +import { apiClient } from './axios'; + +// 웹(dodo-frontend)의 커뮤니티 도메인(게시글/댓글/반응) 계약과 동일. + +export type ReactionType = 'LIKE' | 'DISLIKE'; + +export interface BoardListItem { + boardId: number; + boardTitle: string; + boardContentPreview: string; + thumbnailImageUrl: string | null; + nickname: string; + viewCount: number; + commentCount: number; + likeCount: number; + dislikeCount: number; + createdAt: string; + modifiedAt: string; +} + +export interface GetBoardsParams { + page: number; + size?: number; +} + +export interface GetBoardsResponse { + boards: BoardListItem[]; +} + +/** 커뮤니티 피드 (GET /boards) — 로그인 불필요(공개). 웹도 토큰이 있으면 그냥 같이 보낸다(skipAuthAttach 안 씀). */ +export async function getBoards(params: GetBoardsParams): Promise { + const response = await apiClient.get('/boards', { + params: { page: params.page, size: params.size ?? 12 }, + }); + return response.data; +} + +export interface BoardDetail { + boardId: number; + boardTitle: string; + boardContent: string; + imageFileUrls: string[]; + profileUrl: string | null; + nickname: string; + likeCount: number; + dislikeCount: number; + commentCount?: number; + viewCount: number; + boardCreatedAt: string; + /** 백엔드 실제 필드명 불확실 — 4가지 후보를 방어적으로 확인 (웹과 동일 정책) */ + reactionType?: ReactionType | null; + myReactionType?: ReactionType | null; + currentUserReactionType?: ReactionType | null; + userReactionType?: ReactionType | null; +} + +export function getMyReaction(board: BoardDetail): ReactionType | null { + return board.reactionType ?? board.myReactionType ?? board.currentUserReactionType ?? board.userReactionType ?? null; +} + +/** 게시글 상세 (GET /boards/{boardId}) */ +export async function getBoardDetail(boardId: number | string): Promise { + const response = await apiClient.get(`/boards/${boardId}`); + return response.data; +} + +// 웹도 이 필드들 위치가 불확실해서 author.* / 최상위 둘 다 방어적으로 확인한다 (features/community/ui/BoardDetailContent.tsx 참고). +export interface CommentAuthor { + userId?: string; + nickname?: string; + profileUrl?: string | null; +} + +export interface CommentItem { + commentId: number; + boardId: number; + parentCommentId: number | null; + commentContent: string; + author?: CommentAuthor; + userId?: string; + nickname?: string; + profileUrl?: string | null; + createdAt?: string; + modifiedAt?: string; + deleted?: boolean; + isDeleted?: boolean; +} + +export function getCommentNickname(comment: CommentItem): string { + return comment.author?.nickname?.trim() || comment.nickname?.trim() || '알 수 없음'; +} + +export function getCommentProfileUrl(comment: CommentItem): string | null { + return comment.author?.profileUrl ?? comment.profileUrl ?? null; +} + +export function getCommentTimestamp(comment: CommentItem): string | undefined { + return comment.modifiedAt ?? comment.createdAt; +} + +export function isCommentDeleted(comment: CommentItem): boolean { + return Boolean(comment.deleted || comment.isDeleted); +} + +export interface PageInfo { + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export interface GetCommentsResponse { + pageInfo: PageInfo; + data: CommentItem[]; +} + +/** 게시글 댓글 목록 (GET /comments/{boardId}) — 오프셋 페이지네이션, 무한스크롤 아님 */ +export async function getComments(boardId: number | string, page: number, size = 20): Promise { + const response = await apiClient.get(`/comments/${boardId}`, { params: { page, size } }); + return response.data; +} + +export interface CreateCommentRequest { + boardId: number; + commentContent: string; + parentCommentId?: number; +} + +/** 댓글/답글 등록 (POST /comments) */ +export async function createComment(body: CreateCommentRequest): Promise { + await apiClient.post('/comments', body); +} + +/** 댓글 수정 (PATCH /comments/{commentId}) */ +export async function updateComment(commentId: number | string, commentContent: string): Promise { + await apiClient.patch(`/comments/${commentId}`, { commentContent }); +} + +/** 댓글 삭제 (DELETE /comments/{commentId}) */ +export async function deleteComment(commentId: number | string): Promise { + await apiClient.delete(`/comments/${commentId}`); +} + +/** 게시글 반응 최초 등록 (POST /reactions/board) */ +export async function createBoardReaction(boardId: number, reactionType: ReactionType): Promise { + await apiClient.post('/reactions/board', { boardId, reactionType }); +} + +/** 같은 반응 재클릭 시 취소 (DELETE /reactions/board/{boardId}) */ +export async function deleteBoardReaction(boardId: number | string): Promise { + await apiClient.delete(`/reactions/board/${boardId}`); +} + +/** 좋아요↔싫어요 전환 (PATCH /reactions/board/{boardId}) */ +export async function updateBoardReaction(boardId: number | string, reactionType: ReactionType): Promise { + await apiClient.patch(`/reactions/board/${boardId}`, { reactionType }); +} + +export interface CreateBoardRequest { + boardTitle: string; + boardContent: string; + imageFileUrls: string[]; +} + +export interface CreateBoardResponse { + boardId: number; +} + +/** 게시글 작성 (POST /boards) */ +export async function createBoard(body: CreateBoardRequest): Promise { + const response = await apiClient.post('/boards', body); + return response.data; +} + +/** 게시글 수정 (PATCH /boards/{boardId}) */ +export async function updateBoard(boardId: number | string, body: CreateBoardRequest): Promise { + await apiClient.patch(`/boards/${boardId}`, body); +} + +/** 게시글 삭제 (DELETE /boards/{boardId}) */ +export async function deleteBoard(boardId: number | string): Promise { + await apiClient.delete(`/boards/${boardId}`); +} + +export interface TempSaveBoardRequest { + boardTitle?: string; + boardContent?: string; + imageFileUrl?: string; + imageFileUrls?: string[]; +} + +export interface TempSaveBoardResponse { + sessionKey: string; +} + +/** 임시 저장 (POST /boards/temp-save, 수정 중이면 ?boardId= 추가) */ +export async function tempSaveBoard( + body: TempSaveBoardRequest, + boardId?: number | string, +): Promise { + const response = await apiClient.post('/boards/temp-save', body, { + params: boardId ? { boardId } : undefined, + }); + return response.data; +} + +/** 임시 저장 불러오기 (GET /boards/temp-save/{sessionKey}) */ +export async function getTempSaveBoard(sessionKey: string): Promise { + const response = await apiClient.get(`/boards/temp-save/${sessionKey}`); + return response.data; +} + +export interface GetMyBoardsResponse { + boards: BoardListItem[]; +} + +/** 내 게시글 (GET /boards/me) — 응답에 totalPages 없음, length>=size 휴리스틱으로 다음 페이지 여부 판단 */ +export async function getMyBoards(page: number, size = 10): Promise { + const response = await apiClient.get('/boards/me', { params: { page, size } }); + return response.data; +} + +export interface MyCommentItem { + commentId: number; + boardId: number; + boardTitle: string; + parentCommentId: number | null; + commentContent: string; +} + +export interface GetMyCommentsResponse { + pageInfo: PageInfo; + data: MyCommentItem[]; +} + +/** 내 댓글 (GET /comments/me) */ +export async function getMyComments(page: number, size = 10): Promise { + const response = await apiClient.get('/comments/me', { params: { page, size } }); + return response.data; +} diff --git a/shared/api/familyApi.ts b/shared/api/familyApi.ts new file mode 100644 index 0000000..cb13035 --- /dev/null +++ b/shared/api/familyApi.ts @@ -0,0 +1,116 @@ +import { apiClient } from './axios'; + +// 웹(dodo-frontend)의 가족 초대/신청/차단 도메인 계약과 동일. +// 주의: pending-users/blocked-users/applications는 서버 필터가 없어 전체를 받아 클라이언트에서 petId로 필터링해야 한다. + +export type FamilyApplicationStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'BLOCKED'; + +// userId는 UUID 문자열, 필드명 userName/profileImageUrl — GET /pets/{petId}의 familyMembers 응답으로 확인됨(2026-08-28). +// 이 엔드포인트 자체는 실응답 미확인이라 같은 백엔드 컨벤션으로 추정 적용 — 다를 경우 재확인 필요. +export interface PendingUser { + userId: string; + userName: string; + profileImageUrl: string | null; + petId: number; + petName: string; + status: FamilyApplicationStatus; + appliedAt: string; +} + +export interface GetPendingUsersResponse { + users: PendingUser[]; +} + +/** 받은 가족 신청 목록 — petId 서버 필터 없음 (GET /pets/family/pending-users) */ +export async function getPendingUsers( + status?: FamilyApplicationStatus, + page = 0, + size = 20, +): Promise { + const response = await apiClient.get('/pets/family/pending-users', { + params: { status, page, size }, + }); + return response.data; +} + +export interface BlockedUser { + userId: string; + userName: string; + petId: number; + petName: string; +} + +export interface GetBlockedUsersResponse { + users: BlockedUser[]; +} + +/** 차단 목록 — petId 서버 필터 없음 (GET /pets/family/blocked-users) */ +export async function getBlockedUsers(page = 0, size = 20): Promise { + const response = await apiClient.get('/pets/family/blocked-users', { + params: { page, size }, + }); + return response.data; +} + +export interface InvitationCodeResponse { + code: string; + /** 초 단위로 추정 — 웹은 이 값을 그대로 localStorage 캐시 TTL로 사용 */ + expiresIn: number; +} + +/** 초대 코드 발급/재생성 (POST /pets/{petId}/invitation-code) — "현재 코드 조회" API는 없음, 로컬 캐시 필수 */ +export async function createInvitationCode(petId: number | string): Promise { + const response = await apiClient.post(`/pets/${petId}/invitation-code`); + return response.data; +} + +export interface JoinFamilyResponse { + petId: number; + message: string; +} + +/** 초대코드로 가족 참여/신청 (POST /pets/family) — 6자리 영문대문자+숫자 코드 */ +export async function joinFamily(code: string): Promise { + const response = await apiClient.post('/pets/family', { code }); + return response.data; +} + +export interface FamilyApplication { + applicationId: number; + petId: number; + petName: string; + status: FamilyApplicationStatus; + appliedAt: string; +} + +export interface GetFamilyApplicationsResponse { + applications: FamilyApplication[]; +} + +/** 내가 보낸 가족 신청 목록 (GET /pets/family/applications) */ +export async function getFamilyApplications( + status?: FamilyApplicationStatus, + page = 0, + size = 20, +): Promise { + const response = await apiClient.get('/pets/family/applications', { + params: { status, page, size }, + }); + return response.data; +} + +export type FamilyApprovalAction = 'APPROVED' | 'REJECTED' | 'BLOCKED'; + +/** 가족 신청 승인/거절/차단 (POST /pets/family/approval) */ +export async function approveFamilyApplication( + petId: number, + targetUserId: string, + action: FamilyApprovalAction, +): Promise { + await apiClient.post('/pets/family/approval', { petId, targetUserId, action }); +} + +/** 차단 해제 (DELETE /pets/family/block) — DELETE 요청 body 사용 */ +export async function unblockFamilyUser(petId: number, targetUserId: string): Promise { + await apiClient.delete('/pets/family/block', { data: { petId, targetUserId } }); +} diff --git a/shared/api/fenceApi.ts b/shared/api/fenceApi.ts new file mode 100644 index 0000000..c636331 --- /dev/null +++ b/shared/api/fenceApi.ts @@ -0,0 +1,60 @@ +import { apiClient } from './axios'; + +// 웹(dodo-frontend)의 산책/지오펜스(울타리) 도메인 계약과 동일. 울타리는 원(중심+반경)만 지원, pet당 1개. + +// dodo-frontend의 검증된 타입 기준(2026-08-28 재확인): center는 {lat,lng}가 아니라 {latitude,longitude}. +export interface FenceBoundary { + fenceId: number; + fenceName: string; + center: { latitude: number; longitude: number }; + radius: number; + isActive: boolean; + petId: number; + petName: string; + petImageUrl: string | null; +} + +export interface GetFenceBoundariesResponse { + boundaries: FenceBoundary[]; +} + +/** 전체 울타리 목록 (GET /fence/boundaries) */ +export async function getFenceBoundaries(): Promise { + const response = await apiClient.get('/fence/boundaries'); + return response.data; +} + +export interface CreateFenceRequest { + petId: number; + centerLatitude: number; + centerLongitude: number; + radius: number; + fenceName: string; +} + +export interface CreateFenceResponse { + fenceId: number; +} + +/** 울타리 생성 (POST /fence/range) — 선택 pet에 기존 울타리가 없을 때만 */ +export async function createFence(body: CreateFenceRequest): Promise { + const response = await apiClient.post('/fence/range', body); + return response.data; +} + +export interface UpdateFenceRequest { + centerLatitude?: number; + centerLongitude?: number; + fenceName?: string; + radius?: number; +} + +/** 울타리 수정 (PATCH /fence/{fenceId}/range) — 슬라이더 조작은 즉시 반영 안 됨, 저장 버튼 눌러야 호출 */ +export async function updateFence(fenceId: number | string, body: UpdateFenceRequest): Promise { + await apiClient.patch(`/fence/${fenceId}/range`, body); +} + +/** 울타리 on/off 토글 (PATCH /fence/{fenceId}/toggle) */ +export async function toggleFence(fenceId: number | string, fenceIsActive: boolean): Promise { + await apiClient.patch(`/fence/${fenceId}/toggle`, { fenceIsActive }); +} diff --git a/shared/api/fileApi.ts b/shared/api/fileApi.ts new file mode 100644 index 0000000..ce53a16 --- /dev/null +++ b/shared/api/fileApi.ts @@ -0,0 +1,47 @@ +import type { AxiosRequestConfig } from 'axios'; + +import { apiClient } from './axios'; + +// 웹(dodo-frontend)과 동일하게 이미지 업로드는 공용 엔드포인트 하나만 사용한다 (presigned URL 방식 아님). + +export interface UploadFilesResponse { + imageUrls: string[]; +} + +export interface LocalFile { + uri: string; + name: string; + type: string; +} + +/** + * 이미지 업로드 (POST /files/upload) + * @param authToken 회원가입 중(registrationToken)처럼 정식 accessToken이 아직 없을 때 넘긴다. + * + * 주의: Content-Type을 'multipart/form-data'로 직접 지정하면 안 된다 — boundary 파라미터 없이 + * 헤더를 고정해버리면 RN의 네트워킹 레이어가 FormData를 보고 자동으로 boundary를 붙여주는 동작을 + * 막아버려서, 서버가 멀티파트 바디를 파싱하지 못한다(업로드가 통째로 실패하는 원인). Content-Type은 + * 아예 지정하지 않고 axios/RN이 FormData를 보고 알아서 올바른 boundary까지 설정하게 둬야 한다. + */ +export async function uploadFiles(files: LocalFile[], authToken?: string): Promise { + const formData = new FormData(); + files.forEach((file) => { + formData.append('files', { + uri: file.uri, + name: file.name, + type: file.type, + } as unknown as Blob); + }); + + // apiClient 인스턴스 기본 헤더가 Content-Type: application/json으로 고정돼있어서, 명시적으로 + // undefined를 줘서 지워야 axios/RN이 FormData를 보고 boundary 포함한 헤더를 자동으로 붙여준다. + const config: AxiosRequestConfig = { + headers: { + 'Content-Type': undefined, + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }, + }; + + const response = await apiClient.post('/files/upload', formData, config); + return response.data; +} diff --git a/shared/api/mainApi.ts b/shared/api/mainApi.ts new file mode 100644 index 0000000..da5d542 --- /dev/null +++ b/shared/api/mainApi.ts @@ -0,0 +1,41 @@ +import { apiClient } from './axios'; + +// 웹(dodo-frontend)의 MainPage(GET /main) 계약과 동일. + +export type PetSpecies = 'CANINE' | 'FELINE'; +export type PetSex = 'MALE' | 'FEMALE' | 'NEUTER'; + +export interface MainPetProfile { + petId: number; + name: string; + imageFileUrl: string | null; + breed: string; + age: number; + species: PetSpecies; + sex: PetSex; + weight: number; +} + +export interface MainHealthReport { + petId: number; + petName: string; + dashboardId: number; + healthReportTitle: string; + /** 실제 백엔드 필드명 미확인 — healthReportSummary로 추정 */ + healthReportSummary: string; + /** JSON 문자열 · 실제 백엔드 필드명 미확인 — healthReportContent로 추정 */ + healthReportContent: string; + checkupDate: string; +} + +export interface MainResponse { + petProfiles: MainPetProfile[]; + healthReports: MainHealthReport[]; + /** 웹에서도 화면에 렌더되지 않는 죽은 필드 — 공지사항 UI는 목데이터 사용 */ + announcement: unknown[]; +} + +export async function getMain(): Promise { + const response = await apiClient.get('/main'); + return response.data; +} diff --git a/shared/api/petApi.ts b/shared/api/petApi.ts new file mode 100644 index 0000000..e710181 --- /dev/null +++ b/shared/api/petApi.ts @@ -0,0 +1,206 @@ +import { apiClient } from './axios'; +import type { PetSex, PetSpecies } from './mainApi'; + +export type { PetSex, PetSpecies }; + +// 웹(dodo-frontend)의 반려동물 도메인 계약과 동일. +// 주의: 체중 API만 단수형 /pet/, 나머지는 전부 복수형 /pets/ — 실제 백엔드 스펙이며 오타 아님. + +// 실제 응답 확인됨 (2026-08-28): userId는 숫자가 아니라 UUID 문자열, 필드명도 userName/profileImageUrl +export interface FamilyMember { + userId: string; + userName: string; + profileImageUrl: string | null; +} + +export interface PetListItem { + petId: number; + name: string; + imageFileUrl: string | null; + breed: string; + age: number; + species: PetSpecies; + sex: PetSex; +} + +export interface GetPetsListResponse { + pets: PetListItem[]; +} + +/** 내 반려동물 목록 (GET /pets/list) */ +export async function getPetsList(page = 0, size = 10): Promise { + const response = await apiClient.get('/pets/list', { + params: { page, size, sort: 'registrationCreatedAt,desc' }, + }); + return response.data; +} + +export interface PetActivity { + measuredAt: string; + [key: string]: unknown; +} + +export interface PetDetail { + petId: number; + petName: string; + imageFileUrl: string | null; + species: PetSpecies; + breed: string; + sex: PetSex; + age: number; + birth: string; + registrationNumber: string | null; + deviceId: string; + referenceHeartRate: number; + familyMembers: FamilyMember[]; + lastActivity: PetActivity | null; + specialNotes: SignificantNote[]; + specialNotesCount: number; + weightInfo: unknown; +} + +/** 반려동물 상세 (GET /pets/{petId}) */ +export async function getPetDetail(petId: number | string): Promise { + const response = await apiClient.get(`/pets/${petId}`); + return response.data; +} + +export interface CreatePetRequest { + imageUrl: string; + imageFileUrl: string; + petName: string; + species: PetSpecies; + sex: PetSex; + breed: string; + /** "YYYY-MM-DDT00:00:00" */ + birth: string; + age: number; + registrationNumber?: string; + referenceHeartRate: number; + deviceId: string; +} + +export interface CreatePetResponse { + petId: number; +} + +/** 반려동물 등록 (POST /pets) */ +export async function createPet(body: CreatePetRequest): Promise { + const response = await apiClient.post('/pets', body); + return response.data; +} + +export interface UpdatePetRequest { + imageFileUrl: string; + petName: string; + species: PetSpecies; + sex: PetSex; + breed: string; + birth: string; + age: number; + registrationNumber?: string; + referenceHeartRate: number; + deviceId: string; +} + +/** + * 반려동물 정보 수정 (PATCH /pets/{petId}) + * 웹은 species·birth를 폼에는 보여주면서 실제로는 전송하지 않는 버그가 있음 — RN에서는 정상적으로 포함해 전송한다. + * 백엔드가 이 두 필드를 실제로 반영하는지는 로그인 복구 후 별도 확인 필요. + */ +export async function updatePet(petId: number | string, body: UpdatePetRequest): Promise { + await apiClient.patch(`/pets/${petId}`, body); +} + +/** 가족 나가기 — "펫 삭제"가 아니라 내 가족 구성원 자격 해제 (POST /pets 하드삭제 엔드포인트는 존재하지 않음) */ +export async function leavePetFamily(petId: number | string): Promise { + await apiClient.delete(`/pets/${petId}`); +} + +export const NOTE_TYPES = ['ALLERGY', 'HOSPITAL', 'MEDICATION', 'FOOD', 'BEHAVIOR', 'SYMPTOM', 'ETC'] as const; +export type NoteType = (typeof NOTE_TYPES)[number]; + +export interface SignificantNote { + noteId: number; + petId: number; + noteContent: string; + noteType: NoteType; + createdAt: string; +} + +export interface GetSignificantNotesResponse { + notes: SignificantNote[]; +} + +/** 특이사항 목록 (GET /pets/{petId}/significant) */ +export async function getSignificantNotes( + petId: number | string, + page = 0, + size = 10, +): Promise { + const response = await apiClient.get(`/pets/${petId}/significant`, { + params: { page, size, sort: 'createdAt,desc' }, + }); + return response.data; +} + +/** 특이사항 추가 (POST /pets/significant) */ +export async function createSignificantNote(petId: number, noteContent: string, noteType: NoteType): Promise { + await apiClient.post('/pets/significant', { petId, noteContent, noteType }); +} + +/** 특이사항 수정 (PATCH /pets/significant/{noteId}) — petId는 URL/body 어디에도 없음 */ +export async function updateSignificantNote( + noteId: number | string, + noteContent: string, + noteType: NoteType, +): Promise { + await apiClient.patch(`/pets/significant/${noteId}`, { noteContent, noteType }); +} + +/** 특이사항 삭제 (DELETE /pets/significant/{noteId}) */ +export async function deleteSignificantNote(noteId: number | string): Promise { + await apiClient.delete(`/pets/significant/${noteId}`); +} + +export interface WeightRecord { + weightId: number; + petId: number; + weight: number; + petWeightsMeasuredAt: string; +} + +export interface GetWeightHistoryResponse { + records: WeightRecord[]; +} + +/** 체중 이력 (GET /pet/{petId}/weight/history) — 경로가 단수형 /pet/ 임에 주의 */ +export async function getWeightHistory(petId: number | string, page = 0, size = 15): Promise { + const response = await apiClient.get(`/pet/${petId}/weight/history`, { + params: { page, size, sort: 'petWeightsMeasuredAt,desc' }, + }); + return response.data; +} + +/** 체중 기록 추가 (POST /pet/{petId}/weight) */ +export async function createWeightRecord( + petId: number | string, + weight: number, + petWeightsMeasuredAt: string, +): Promise { + await apiClient.post(`/pet/${petId}/weight`, { weight, petWeightsMeasuredAt }); +} + +/** 체중 기록 수정 (PATCH /pet/{petId}/weight/{weightId}) */ +export async function updateWeightRecord( + petId: number | string, + weightId: number | string, + body: { weight?: number; petWeightsMeasuredAt?: string }, +): Promise { + await apiClient.patch(`/pet/${petId}/weight/${weightId}`, body); +} + +/** 체중 기록 삭제 (DELETE /pet/{petId}/weight/{weightId}) */ +export async function deleteWeightRecord(petId: number | string, weightId: number | string): Promise { + await apiClient.delete(`/pet/${petId}/weight/${weightId}`); +} diff --git a/shared/api/userApi.ts b/shared/api/userApi.ts new file mode 100644 index 0000000..75ebadd --- /dev/null +++ b/shared/api/userApi.ts @@ -0,0 +1,65 @@ +import { apiClient } from './axios'; + +// 웹(dodo-frontend)의 사용자/계정 도메인(마이도도, 회원정보 수정, 알림설정, 탈퇴) 계약과 동일. + +export interface UserMe { + email: string; + name: string; + nickname: string; + region: string; + hasFamily: boolean; + profileUrl: string | null; + notificationEnabled: boolean; + userCreatedAt: string; +} + +/** 내 정보 조회 (GET /users/me) */ +export async function getMe(): Promise { + const response = await apiClient.get('/users/me'); + return response.data; +} + +export interface CheckNicknameResponse { + message: string; + nickname: string; + duplicated: boolean; +} + +/** 닉네임 중복 확인 (GET /users/nickname/check) — 회원가입 중엔 registrationToken 헤더로 호출 */ +export async function checkNickname(nickname: string, registrationToken?: string): Promise { + const response = await apiClient.get('/users/nickname/check', { + params: { nickname }, + headers: registrationToken ? { Authorization: `Bearer ${registrationToken}` } : undefined, + }); + return response.data; +} + +export interface UpdateMeRequest { + nickname: string; + region: string; + hasFamily: boolean; + profileUrl?: string | null; +} + +/** 회원정보 수정 (PATCH /users/me) — 이메일/이름은 읽기전용, hasFamily는 기존값 그대로 전송 */ +export async function updateMe(body: UpdateMeRequest): Promise { + await apiClient.patch('/users/me', body); +} + +/** 알림 설정 (PATCH /users/me/setting/notification) — notificationEnabled 단일 boolean만 존재 */ +export async function updateNotificationSetting(notificationEnabled: boolean): Promise<{ message: string }> { + const response = await apiClient.patch<{ message: string }>('/users/me/setting/notification', { + notificationEnabled, + }); + return response.data; +} + +/** 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email) — 60초 쿨다운, 서버도 1분 내 재요청 시 429 */ +export async function sendWithdrawalEmail(): Promise { + await apiClient.post('/users/me/withdrawal/email'); +} + +/** 회원 탈퇴 (DELETE /users/me) */ +export async function withdrawUser(authCode: string): Promise { + await apiClient.delete('/users/me', { data: { authCode } }); +} diff --git a/shared/lib/family/invitationCodeCache.ts b/shared/lib/family/invitationCodeCache.ts new file mode 100644 index 0000000..9b5e2d0 --- /dev/null +++ b/shared/lib/family/invitationCodeCache.ts @@ -0,0 +1,34 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// 웹과 동일: "현재 초대 코드 조회" API가 없어 발급된 코드를 로컬에 캐시해 재사용한다. +// 캐시가 사라지면(앱 재설치 등) 만료 전이라도 복구할 수 없다 — 웹과 동일한 제약. + +type CachedCode = { code: string; expiresAt: number }; + +function key(petId: number | string) { + return `dodo.invitationCode.${petId}`; +} + +export async function saveInvitationCode( + petId: number | string, + code: string, + expiresInSeconds: number, +): Promise { + const cached: CachedCode = { code, expiresAt: Date.now() + expiresInSeconds * 1000 }; + await AsyncStorage.setItem(key(petId), JSON.stringify(cached)); +} + +export async function getInvitationCode(petId: number | string): Promise { + const raw = await AsyncStorage.getItem(key(petId)); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as CachedCode; + if (parsed.expiresAt <= Date.now()) { + await AsyncStorage.removeItem(key(petId)); + return null; + } + return parsed; + } catch { + return null; + } +} diff --git a/shared/lib/files/imageUploadPolicy.ts b/shared/lib/files/imageUploadPolicy.ts new file mode 100644 index 0000000..4426da1 --- /dev/null +++ b/shared/lib/files/imageUploadPolicy.ts @@ -0,0 +1,49 @@ +// 웹(dodo-frontend)의 shared/lib/files/imageUploadPolicy.ts와 동일한 정책. + +export const ALLOWED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png'] as const; + +export const MAX_IMAGE_FILE_SIZE_MB = 10; +export const MAX_IMAGE_FILE_SIZE = MAX_IMAGE_FILE_SIZE_MB * 1024 * 1024; + +export const MAX_IMAGE_UPLOAD_REQUEST_SIZE_MB = 50; +export const MAX_IMAGE_UPLOAD_REQUEST_SIZE = MAX_IMAGE_UPLOAD_REQUEST_SIZE_MB * 1024 * 1024; + +export const IMAGE_UPLOAD_POLICY_DESCRIPTION = `PNG, JPG 형식 / 최대 ${MAX_IMAGE_FILE_SIZE_MB}MB`; + +export interface PickedImage { + uri: string; + mimeType?: string | null; + fileSize?: number | null; + fileName?: string | null; +} + +function isAllowedImageMimeType(type: string | null | undefined): boolean { + if (!type) return true; // 일부 플랫폼은 mimeType을 안 줄 수 있음 — 그런 경우는 통과시키고 서버 검증에 맡김 + return ALLOWED_IMAGE_MIME_TYPES.includes(type as (typeof ALLOWED_IMAGE_MIME_TYPES)[number]); +} + +export function validateImageFile(file: PickedImage): void { + if (!isAllowedImageMimeType(file.mimeType)) { + throw new Error('JPG 또는 PNG 이미지 파일만 업로드할 수 있어요.'); + } + if (file.fileSize != null && file.fileSize > MAX_IMAGE_FILE_SIZE) { + throw new Error(`이미지 한 장당 최대 ${MAX_IMAGE_FILE_SIZE_MB}MB까지 업로드할 수 있어요.`); + } +} + +export function validateImageFiles(files: PickedImage[]): void { + files.forEach(validateImageFile); + + const totalSize = files.reduce((sum, file) => sum + (file.fileSize ?? 0), 0); + if (totalSize > MAX_IMAGE_UPLOAD_REQUEST_SIZE) { + throw new Error(`한 번의 요청으로는 최대 ${MAX_IMAGE_UPLOAD_REQUEST_SIZE_MB}MB까지 업로드할 수 있어요.`); + } +} + +/** 파일명에서 확장자로 대략적인 MIME 타입 추론 (expo-image-picker가 mimeType을 안 줄 때 fileApi 업로드용) */ +export function inferMimeType(uri: string, fileName?: string | null): string { + const name = fileName ?? uri; + const ext = name.split('.').pop()?.toLowerCase(); + if (ext === 'png') return 'image/png'; + return 'image/jpeg'; +} diff --git a/shared/lib/files/pickImage.ts b/shared/lib/files/pickImage.ts new file mode 100644 index 0000000..9479e01 --- /dev/null +++ b/shared/lib/files/pickImage.ts @@ -0,0 +1,53 @@ +import * as ImagePicker from 'expo-image-picker'; + +import type { LocalFile } from '@/shared/api/fileApi'; + +import { inferMimeType, validateImageFile, validateImageFiles, type PickedImage } from './imageUploadPolicy'; + +async function ensureLibraryPermission(): Promise { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + throw new Error('사진 접근 권한이 필요해요. 설정에서 권한을 허용해주세요.'); + } +} + +/** 사진 1장 선택 (프로필/펫 등록 사진용). 취소 시 null. */ +export async function pickSingleImage(): Promise { + await ensureLibraryPermission(); + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 0.8, + }); + + if (result.canceled || result.assets.length === 0) return null; + + const asset = result.assets[0]; + validateImageFile(asset); + return asset; +} + +/** 사진 여러 장 선택 (게시글 첨부용). 취소 시 빈 배열. */ +export async function pickMultipleImages(maxCount: number): Promise { + await ensureLibraryPermission(); + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 0.8, + allowsMultipleSelection: true, + selectionLimit: maxCount, + }); + + if (result.canceled || result.assets.length === 0) return []; + + validateImageFiles(result.assets); + return result.assets; +} + +export function toLocalFile(asset: PickedImage): LocalFile { + return { + uri: asset.uri, + name: asset.fileName ?? 'photo.jpg', + type: asset.mimeType ?? inferMimeType(asset.uri, asset.fileName), + }; +} diff --git a/shared/lib/format/date.ts b/shared/lib/format/date.ts new file mode 100644 index 0000000..a152e6a --- /dev/null +++ b/shared/lib/format/date.ts @@ -0,0 +1,38 @@ +/** 커뮤니티 목록에서 쓰는 짧은 날짜 표기 (예: "6월 12일") */ +export function formatShortDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return `${date.getMonth() + 1}월 ${date.getDate()}일`; +} + +/** 초 단위 경과 시간을 mm:ss 또는 h:mm:ss로 표기 (산책 타이머용) */ +export function formatDurationSeconds(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +/** 시작~종료 시각 문자열로부터 경과 시간(초)을 계산 */ +export function durationSecondsBetween(startIso: string, endIso: string): number { + const start = new Date(startIso).getTime(); + const end = new Date(endIso).getTime(); + if (Number.isNaN(start) || Number.isNaN(end)) return 0; + return Math.max(0, Math.round((end - start) / 1000)); +} + +/** 게시글 상세에서 쓰는 전체 날짜/시간 표기 (예: "2026. 06. 12. 오후 07:33") */ +export function formatFullDateTime(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const hours24 = date.getHours(); + const period = hours24 < 12 ? '오전' : '오후'; + const hours12 = String(hours24 % 12 === 0 ? 12 : hours24 % 12).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${y}. ${m}. ${d}. ${period} ${hours12}:${minutes}`; +} diff --git a/shared/lib/geo/haversine.ts b/shared/lib/geo/haversine.ts new file mode 100644 index 0000000..6e0e8ba --- /dev/null +++ b/shared/lib/geo/haversine.ts @@ -0,0 +1,28 @@ +const EARTH_RADIUS_M = 6371000; + +function toRad(deg: number): number { + return (deg * Math.PI) / 180; +} + +/** 두 좌표 사이 거리(미터). 실시간 산책 경로의 클라이언트 측 거리 누적 계산용. */ +export function distanceMeters( + a: { latitude: number; longitude: number }, + b: { latitude: number; longitude: number }, +): number { + const dLat = toRad(b.latitude - a.latitude); + const dLng = toRad(b.longitude - a.longitude); + const lat1 = toRad(a.latitude); + const lat2 = toRad(b.latitude); + + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; + return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** 경로 포인트 배열의 총 이동 거리(미터) */ +export function totalRouteDistanceMeters(points: { latitude: number; longitude: number }[]): number { + let total = 0; + for (let i = 1; i < points.length; i++) { + total += distanceMeters(points[i - 1], points[i]); + } + return total; +} diff --git a/shared/lib/regions/data.ts b/shared/lib/regions/data.ts new file mode 100644 index 0000000..abac95f --- /dev/null +++ b/shared/lib/regions/data.ts @@ -0,0 +1,223 @@ +/** @see https://github.com/cosmosfarm/korea-administrative-district (20241209) */ + +export const REGIONS_BY_SIDO: Record = { + 서울특별시: [ + '종로구', + '중구', + '용산구', + '성동구', + '광진구', + '동대문구', + '중랑구', + '성북구', + '강북구', + '도봉구', + '노원구', + '은평구', + '서대문구', + '마포구', + '양천구', + '강서구', + '구로구', + '금천구', + '영등포구', + '동작구', + '관악구', + '서초구', + '강남구', + '송파구', + '강동구', + ], + 부산광역시: [ + '중구', + '서구', + '동구', + '영도구', + '부산진구', + '동래구', + '남구', + '북구', + '강서구', + '해운대구', + '사하구', + '금정구', + '연제구', + '수영구', + '사상구', + '기장군', + ], + 인천광역시: ['중구', '동구', '미추홀구', '연수구', '남동구', '부평구', '계양구', '서구', '강화군', '옹진군'], + 대구광역시: ['중구', '동구', '서구', '남구', '북구', '수성구', '달서구', '달성군', '군위군'], + 광주광역시: ['동구', '서구', '남구', '북구', '광산구'], + 대전광역시: ['동구', '중구', '서구', '유성구', '대덕구'], + 울산광역시: ['중구', '남구', '동구', '북구', '울주군'], + 세종특별자치시: [], + 경기도: [ + '가평군', + '고양시', + '과천시', + '광명시', + '광주시', + '구리시', + '군포시', + '김포시', + '남양주시', + '동두천시', + '부천시', + '성남시', + '수원시', + '시흥시', + '안산시', + '안성시', + '안양시', + '양주시', + '양평군', + '여주시', + '연천군', + '오산시', + '용인시', + '의왕시', + '의정부시', + '이천시', + '파주시', + '평택시', + '포천시', + '하남시', + '화성시', + ], + 강원특별자치도: [ + '원주시', + '춘천시', + '강릉시', + '동해시', + '속초시', + '삼척시', + '홍천군', + '태백시', + '철원군', + '횡성군', + '평창군', + '영월군', + '정선군', + '인제군', + '고성군', + '양양군', + '화천군', + '양구군', + ], + 충청북도: [ + '청주시', + '충주시', + '제천시', + '보은군', + '옥천군', + '영동군', + '증평군', + '진천군', + '괴산군', + '음성군', + '단양군', + ], + 충청남도: [ + '천안시', + '공주시', + '보령시', + '아산시', + '서산시', + '논산시', + '계룡시', + '당진시', + '금산군', + '부여군', + '서천군', + '청양군', + '홍성군', + '예산군', + '태안군', + ], + 경상북도: [ + '포항시', + '경주시', + '김천시', + '안동시', + '구미시', + '영주시', + '영천시', + '상주시', + '문경시', + '경산시', + '의성군', + '청송군', + '영양군', + '영덕군', + '청도군', + '고령군', + '성주군', + '칠곡군', + '예천군', + '봉화군', + '울진군', + '울릉군', + ], + 경상남도: [ + '창원시', + '김해시', + '진주시', + '양산시', + '거제시', + '통영시', + '사천시', + '밀양시', + '함안군', + '거창군', + '창녕군', + '고성군', + '하동군', + '합천군', + '남해군', + '함양군', + '산청군', + '의령군', + ], + 전북특별자치도: [ + '전주시', + '익산시', + '군산시', + '정읍시', + '완주군', + '김제시', + '남원시', + '고창군', + '부안군', + '임실군', + '순창군', + '진안군', + '장수군', + '무주군', + ], + 전라남도: [ + '여수시', + '순천시', + '목포시', + '광양시', + '나주시', + '무안군', + '해남군', + '고흥군', + '화순군', + '영암군', + '영광군', + '완도군', + '담양군', + '장성군', + '보성군', + '신안군', + '장흥군', + '강진군', + '함평군', + '진도군', + '곡성군', + '구례군', + ], + 제주특별자치도: ['제주시', '서귀포시'], +}; diff --git a/shared/lib/regions/index.ts b/shared/lib/regions/index.ts new file mode 100644 index 0000000..1b6d30b --- /dev/null +++ b/shared/lib/regions/index.ts @@ -0,0 +1,34 @@ +import { REGIONS_BY_SIDO } from './data'; + +/** 시/도 목록 (17개) */ +export const SIDO_LIST = Object.keys(REGIONS_BY_SIDO); + +/** 선택한 시/도의 시/군/구 목록. 세종 등은 빈 배열 */ +export function getSigunguList(sido: string): string[] { + return REGIONS_BY_SIDO[sido] ?? []; +} + +/** 시/도 + 시/군/구 → API 전송용 문자열 (예: 서울특별시 동대문구) */ +export function formatRegionLabel(sido: string, sigungu: string | null): string { + if (!sigungu) return sido; + return `${sido} ${sigungu}`; +} + +/** 저장된 region 문자열 → 선택 상태로 분해 */ +export function parseRegionLabel(region: string): { sido: string; sigungu: string | null } { + const trimmed = region.trim(); + if (!trimmed) return { sido: '', sigungu: null }; + + for (const sido of SIDO_LIST) { + if (trimmed === sido) return { sido, sigungu: null }; + if (trimmed.startsWith(`${sido} `)) { + return { sido, sigungu: trimmed.slice(sido.length + 1) }; + } + } + + return { sido: '', sigungu: null }; +} + +export function hasSigunguOptions(sido: string): boolean { + return getSigunguList(sido).length > 0; +} diff --git a/shared/lib/ws/useActivityLocationSocket.ts b/shared/lib/ws/useActivityLocationSocket.ts new file mode 100644 index 0000000..91b5f4b --- /dev/null +++ b/shared/lib/ws/useActivityLocationSocket.ts @@ -0,0 +1,16 @@ +import { useStompTopic } from './useStompTopic'; + +// 산책 활동(activities/history) 실시간 경로 — historyId 단위로 구독한다 (펫 단위인 울타리 채널과 별개). +// 백엔드 확인된 계약(2026-08-28): 구독 경로 /sub/activities/history/routes/{historyId} +// 수신 payload: { routePointId, latitude, longitude, measuredAt } + +export interface ActivityRoutePoint { + routePointId: number; + latitude: number; + longitude: number; + measuredAt: string; +} + +export function useActivityLocationSocket(historyId: number | string | null): ActivityRoutePoint | null { + return useStompTopic(historyId != null ? `/sub/activities/history/routes/${historyId}` : null); +} diff --git a/shared/lib/ws/useFenceLocationSocket.ts b/shared/lib/ws/useFenceLocationSocket.ts new file mode 100644 index 0000000..c7fa692 --- /dev/null +++ b/shared/lib/ws/useFenceLocationSocket.ts @@ -0,0 +1,19 @@ +import { useStompTopic } from './useStompTopic'; + +// 웹(dodo-frontend)과 동일한 STOMP 계약. 위치 수신 전용 — publish는 없음. +// petId를 선택하는 즉시 연결하고, petId가 바뀌면 기존 client를 deactivate() 한 뒤 새로 만든다. + +export interface FenceLocationPayload { + petId: number; + latitude: number; + longitude: number; + measuredAt: string; + insideFence: boolean; + distanceMeter: number; + radius: number; + message?: string; +} + +export function useFenceLocationSocket(petId: number | string | null): FenceLocationPayload | null { + return useStompTopic(petId != null ? `/sub/fence/location/${petId}` : null); +} diff --git a/shared/lib/ws/useStompTopic.ts b/shared/lib/ws/useStompTopic.ts new file mode 100644 index 0000000..a1aa9a1 --- /dev/null +++ b/shared/lib/ws/useStompTopic.ts @@ -0,0 +1,65 @@ +import { Client, type IMessage } from '@stomp/stompjs'; +import { useEffect, useState } from 'react'; + +import { env } from '@/shared/config'; +import * as tokenStorage from '@/shared/lib/auth/tokenStorage'; + +// 공통 STOMP 구독 로직. destination이 null이면 연결하지 않고, 값이 바뀌면 기존 연결을 정리하고 새로 만든다. +// 서버 프레임은 전부 { type, payload } 모양을 공유한다는 전제(웹/백엔드 공통 계약). + +interface Envelope { + type: string; + payload: T; +} + +export function useStompTopic(destination: string | null): T | null { + const [data, setData] = useState(null); + + useEffect(() => { + if (!destination) { + setData(null); + return; + } + + let cancelled = false; + // TODO: 실시간 안 뜨는 문제 진단용 임시 로그 — 확인되면 제거 + console.log('[stomp] connecting…', destination); + const client = new Client({ + brokerURL: env.WS_URL, + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + debug: (str) => console.log('[stomp:frame]', destination, str), + beforeConnect: async () => { + const accessToken = await tokenStorage.getAccessToken(); + client.connectHeaders = accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; + }, + onConnect: () => { + console.log('[stomp] connected, subscribing', destination); + if (cancelled) return; + client.subscribe(destination, (message: IMessage) => { + console.log('[stomp] message', destination, message.body); + try { + const parsed = JSON.parse(message.body) as Envelope; + if (!cancelled) setData(parsed.payload); + } catch (err) { + console.log('[stomp] parse error', destination, err); + } + }); + }, + onStompError: (frame) => console.log('[stomp] STOMP error', destination, frame.headers, frame.body), + onWebSocketError: (event) => console.log('[stomp] WS error', destination, event), + onWebSocketClose: (event) => console.log('[stomp] WS closed', destination, event?.code, event?.reason), + }); + + client.activate(); + + return () => { + cancelled = true; + setData(null); + client.deactivate(); + }; + }, [destination]); + + return data; +} From d4005fd3adac86d2cf6448d0707e0086e8923f32 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 13:14:08 +0900 Subject: [PATCH 12/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EB=B0=A9=EC=8B=9D=20=EC=A0=84=ED=99=98=20=EB=B0=8F?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=204=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20=EC=9C=84=EC=A0=80=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구글 로그인을 네이티브 SDK에서 웹과 동일한 브라우저 리다이렉트 방식으로 재작성 (Android가 커스텀 스킴 직접 리다이렉트를 차단하는 정책 문제 해결) - 네이버 로그인도 동일 방식으로 활성화 - 회원가입을 약관 동의 → 프로필(닉네임 중복확인/지역선택/사진) → 알림설정 → 완료 4단계 위저드로 재구현, 웹의 안전한 재시도 로직까지 이식 --- app/_layout.tsx | 1 + app/auth/_layout.tsx | 10 + app/auth/index.tsx | 205 ++++++++++++++ app/auth/signup.tsx | 563 +++++++++++++++++++++++++++++++++++++++ shared/api/authApi.ts | 27 ++ shared/config/env.ts | 10 + shared/lib/auth/oauth.ts | 29 +- 7 files changed, 837 insertions(+), 8 deletions(-) create mode 100644 app/auth/_layout.tsx create mode 100644 app/auth/index.tsx create mode 100644 app/auth/signup.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 326d1de..4b980ed 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -31,6 +31,7 @@ export default function RootLayout() { + diff --git a/app/auth/_layout.tsx b/app/auth/_layout.tsx new file mode 100644 index 0000000..d13d835 --- /dev/null +++ b/app/auth/_layout.tsx @@ -0,0 +1,10 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ( + + + + + ); +} diff --git a/app/auth/index.tsx b/app/auth/index.tsx new file mode 100644 index 0000000..bad6d27 --- /dev/null +++ b/app/auth/index.tsx @@ -0,0 +1,205 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { DodoColors } from '@/constants/theme'; +import { socialLogin } from '@/shared/api/authApi'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; +import { requestSocialAuthCode, type SocialProvider } from '@/shared/lib/auth/oauth'; + +export default function LoginScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const setSession = useAuthStore((state) => state.setSession); + const [loadingProvider, setLoadingProvider] = useState(null); + + const providerLabel: Record = { GOOGLE: '구글', NAVER: '네이버' }; + + const handleSocialLogin = async (provider: SocialProvider) => { + if (loadingProvider) return; + setLoadingProvider(provider); + + try { + const codeResult = await requestSocialAuthCode(provider); + if (!codeResult.success) { + if (codeResult.reason !== 'cancelled') { + Alert.alert('로그인 실패', `${providerLabel[provider]} 로그인 중 문제가 발생했어요. 다시 시도해주세요.`); + } + return; + } + + const result = await socialLogin(provider, codeResult.code); + + if (result.kind === 'LOGIN') { + await setSession(result.data); + router.replace('/(tabs)/mypage'); + return; + } + + router.push({ + pathname: '/auth/signup', + params: { + registrationToken: result.data.registrationToken, + email: result.data.email, + name: result.data.name, + profileUrl: result.data.profileUrl ?? '', + }, + }); + } catch { + Alert.alert('로그인 실패', '잠시 후 다시 시도해주세요.'); + } finally { + setLoadingProvider(null); + } + }; + + const handleSkip = () => { + router.replace('/(tabs)'); + }; + + return ( + + + + + + DoDo + DoDo와 함께 시작해보세요 + 구글 계정으로 간편하게 시작할 수 있어요. + + + + handleSocialLogin('GOOGLE')} + disabled={loadingProvider !== null} + activeOpacity={0.85} + > + {loadingProvider === 'GOOGLE' ? ( + + ) : ( + <> + + Google로 시작하기 + + )} + + + handleSocialLogin('NAVER')} + disabled={loadingProvider !== null} + activeOpacity={0.85} + > + {loadingProvider === 'NAVER' ? ( + + ) : ( + <> + N + 네이버로 시작하기 + + )} + + + + + 로그인 없이 둘러보기 + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + paddingHorizontal: 24, + justifyContent: 'space-between', + }, + hero: { + alignItems: 'center', + gap: 8, + marginTop: 40, + }, + logoCircle: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 4, + }, + logoText: { + fontSize: 20, + fontWeight: '800', + color: DodoColors.brand, + marginBottom: 12, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + subtitle: { + fontSize: 13, + color: DodoColors.textSecondary, + }, + buttonGroup: { + gap: 12, + }, + socialButton: { + height: 52, + borderRadius: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + googleButton: { + backgroundColor: DodoColors.surface, + borderWidth: 1, + borderColor: DodoColors.border, + }, + googleButtonText: { + fontSize: 14, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + naverButton: { + backgroundColor: '#03C75A', + }, + naverButtonText: { + fontSize: 14, + fontWeight: '600', + color: '#ffffff', + }, + naverGlyph: { + fontSize: 16, + fontWeight: '800', + color: '#ffffff', + }, + comingSoonBadge: { + position: 'absolute', + right: 12, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 999, + backgroundColor: 'rgba(255,255,255,0.25)', + }, + comingSoonBadgeText: { + fontSize: 10, + fontWeight: '700', + color: '#ffffff', + }, + skipButton: { + alignSelf: 'center', + paddingVertical: 12, + }, + skipButtonText: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + textDecorationLine: 'underline', + }, +}); diff --git a/app/auth/signup.tsx b/app/auth/signup.tsx new file mode 100644 index 0000000..6504474 --- /dev/null +++ b/app/auth/signup.tsx @@ -0,0 +1,563 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { isAxiosError } from 'axios'; +import { useLocalSearchParams, useRouter, type Href } from 'expo-router'; +import { ReactNode, useRef, useState } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AvatarImagePicker } from '@/components/common/avatar-image-picker'; +import { RegionSelectModal } from '@/components/auth/region-select-modal'; +import { DodoColors } from '@/constants/theme'; +import { registerProfile } from '@/shared/api/authApi'; +import { checkNickname, updateNotificationSetting } from '@/shared/api/userApi'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; + +// 백엔드 명세 기준: 2~10자, 한글/영문/숫자/공백만 허용 +const NICKNAME_REGEX = /^[가-힣a-zA-Z0-9 ]{2,10}$/; + +// generated route types omit the bare "/auth" index route (typegen quirk), but it's the only path that resolves at runtime +const AUTH_HREF = '/auth' as Href; + +type Step = 'terms' | 'profile' | 'notification' | 'complete'; +type NicknameStatus = 'idle' | 'valid' | 'invalid'; + +function StepLayout({ children, footer }: { children: ReactNode; footer: ReactNode }) { + const insets = useSafeAreaInsets(); + return ( + + {children} + {footer} + + ); +} + +function ChoiceRow({ selected, label, onPress }: { selected: boolean; label: string; onPress: () => void }) { + return ( + + + {selected && } + + {label} + + ); +} + +export default function SignupScreen() { + const router = useRouter(); + const setSession = useAuthStore((state) => state.setSession); + const updateProfile = useAuthStore((state) => state.updateProfile); + const params = useLocalSearchParams<{ + registrationToken: string; + email?: string; + name?: string; + profileUrl?: string; + }>(); + + const registrationToken = params.registrationToken; + + const [step, setStep] = useState('terms'); + const [agreed, setAgreed] = useState(false); + + const [nickname, setNickname] = useState(''); + const [nicknameStatus, setNicknameStatus] = useState('idle'); + const [nicknameErrorMessage, setNicknameErrorMessage] = useState(''); + const [checkingNickname, setCheckingNickname] = useState(false); + + const [region, setRegion] = useState(''); + const [regionModalVisible, setRegionModalVisible] = useState(false); + + const [profileImageUrl, setProfileImageUrl] = useState(params.profileUrl || null); + + const [allowNotification, setAllowNotification] = useState(null); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [fatalError, setFatalError] = useState(null); + const profileCompletedRef = useRef(false); + + const handleChangeNickname = (value: string) => { + setNickname(value); + setNicknameStatus('idle'); + setNicknameErrorMessage(''); + }; + + const handleCheckNickname = async () => { + const trimmed = nickname.trim(); + if (!trimmed) { + setNicknameStatus('invalid'); + setNicknameErrorMessage('닉네임을 입력해주세요.'); + return; + } + if (!NICKNAME_REGEX.test(trimmed)) { + setNicknameStatus('invalid'); + setNicknameErrorMessage('닉네임은 2~10자, 한글·영문·숫자, 띄어쓰기만 사용할 수 있어요.'); + return; + } + + setCheckingNickname(true); + setNicknameErrorMessage(''); + + try { + const result = await checkNickname(trimmed, registrationToken); + if (result.duplicated) { + setNicknameStatus('invalid'); + setNicknameErrorMessage('이미 사용 중인 닉네임이에요.'); + return; + } + setNicknameStatus('valid'); + } catch { + setNicknameStatus('invalid'); + setNicknameErrorMessage('중복 확인에 실패했어요. 잠시 후 다시 시도해주세요.'); + } finally { + setCheckingNickname(false); + } + }; + + const canProceedProfile = nicknameStatus === 'valid' && region.trim().length > 0; + + // registerProfile → 알림 설정 저장 순으로 가입을 완료. ①이 이미 성공했으면 재시도 시 ②만 다시 호출. + const handleSubmit = async () => { + if (allowNotification === null || !registrationToken) return; + + setSubmitting(true); + setError(''); + + try { + if (!profileCompletedRef.current) { + const response = await registerProfile( + { + hasFamily: false, + nickname: nickname.trim(), + region: region.trim(), + profileUrl: profileImageUrl, + }, + registrationToken, + ); + + await setSession({ + accessToken: response.accessToken, + refreshToken: response.refreshToken, + accessTokenExpiresIn: response.accessTokenExpiresIn, + profileUrl: response.profileUrl, + nickname: nickname.trim(), + region: region.trim(), + }); + profileCompletedRef.current = true; + } + + await updateNotificationSetting(allowNotification); + await updateProfile({ notificationEnabled: allowNotification }); + setStep('complete'); + } catch (submitError) { + if (profileCompletedRef.current) { + setError('알림 설정 저장에 실패했어요. 다시 시도해주세요.'); + return; + } + + const status = isAxiosError(submitError) ? submitError.response?.status : undefined; + if (status === 401 || status === 409) { + setFatalError('가입 세션이 만료됐거나 이미 사용 중인 정보가 있어요. 처음부터 다시 로그인해주세요.'); + return; + } + setError('회원가입에 실패했어요. 잠시 후 다시 시도해주세요.'); + } finally { + setSubmitting(false); + } + }; + + if (!registrationToken) { + return ( + + 가입 정보가 없어요 + router.replace(AUTH_HREF)}> + 처음부터 로그인하기 + + + ); + } + + if (fatalError) { + return ( + + + 로그인이 필요해요 + {fatalError} + router.replace(AUTH_HREF)}> + 처음부터 로그인하기 + + + ); + } + + if (step === 'terms') { + return ( + setStep('profile')} + > + 다음 + + } + > + + 약관에 동의해주세요 + + 서비스 이용에 필요한 필수 항목입니다.{'\n'}데이터는 약관에 따라 안전하게 보호되며,{'\n'}오직 서비스 개선과 + 기능 제공에만 사용됩니다. + + + + setAgreed((v) => !v)} /> + {/* TODO(이슈X): 약관 상세 페이지 연결 — 웹도 아직 연결 안 되어있음 */} + + DoDo 이용 약관 + 개인정보 제3자 이용 동의 + + + + ); + } + + if (step === 'profile') { + return ( + setStep('notification')} + > + 다음 + + } + > + SIGN UP + 추가 정보 입력 + + {params.name ? `${params.name}님, ` : ''}회원가입을 완료하려면 몇 가지 정보가 더 필요해요. + + + + + + + + 이메일 + + {params.email} + + + 이름 + + {params.name} + + + 닉네임 * + + + + {checkingNickname ? ( + + ) : ( + 중복 확인 + )} + + + {nicknameStatus !== 'idle' && ( + + {nicknameStatus === 'valid' ? '사용 가능한 닉네임입니다.' : nicknameErrorMessage} + + )} + + 활동 지역 * + + setRegionModalVisible(true)} + > + + {region || '지역을 선택해주세요'} + + + setRegionModalVisible(true)}> + 검색 + + + + + setRegionModalVisible(false)} + onConfirm={setRegion} + /> + + ); + } + + if (step === 'notification') { + return ( + + {submitting ? ( + + ) : ( + 다음 + )} + + } + > + + 중요한 소식이 있을 때{'\n'}알려드릴까요? + 언제든지 설정에서 바꿀 수 있어요. + + + setAllowNotification(true)} + /> + setAllowNotification(false)} + /> + + + {!!error && {error}} + + ); + } + + return ( + + router.replace('/(tabs)/mypage')}> + HOME + + router.replace('/(tabs)/mypage/family/apply')}> + 가족 등록하러 가기 + + + } + > + + + 회원가입 완료 + 반려동물과의 행복한 하루,{'\n'}DoDo가 함께할게요! + + + ); +} + +const styles = StyleSheet.create({ + stepContainer: { + flex: 1, + backgroundColor: DodoColors.background, + }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + stepContent: { + paddingHorizontal: 24, + paddingBottom: 24, + }, + stepFooter: { + paddingHorizontal: 24, + paddingTop: 12, + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + marginTop: 12, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: DodoColors.textPrimary, + marginTop: 16, + }, + subtitle: { + fontSize: 13, + color: DodoColors.textSecondary, + lineHeight: 19, + marginTop: 10, + }, + errorTitle: { + fontSize: 17, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + errorMessage: { + fontSize: 13, + color: DodoColors.textSecondary, + textAlign: 'center', + }, + termsBlock: { + marginTop: 32, + gap: 8, + }, + termsLinksRow: { + flexDirection: 'row', + gap: 12, + paddingLeft: 30, + }, + termsLink: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + textDecorationLine: 'underline', + }, + choiceRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + choiceDot: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: DodoColors.border, + alignItems: 'center', + justifyContent: 'center', + }, + choiceDotSelected: { + backgroundColor: DodoColors.brand, + }, + choiceLabel: { + fontSize: 14, + color: DodoColors.textPrimary, + }, + avatarRow: { + alignItems: 'center', + marginTop: 20, + }, + card: { + marginTop: 24, + gap: 8, + }, + label: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + marginTop: 10, + }, + input: { + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.surface, + paddingHorizontal: 12, + fontSize: 13, + color: DodoColors.textPrimary, + }, + inlineRow: { + flexDirection: 'row', + gap: 8, + }, + inlineInput: { + flex: 1, + }, + subButton: { + paddingHorizontal: 14, + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + alignItems: 'center', + justifyContent: 'center', + }, + subButtonDisabled: { + opacity: 0.5, + }, + subButtonText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + readonlyInput: { + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + justifyContent: 'center', + }, + readonlyValue: { + fontSize: 13, + color: DodoColors.textPrimary, + }, + placeholderValue: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + }, + feedback: { + fontSize: 11, + marginTop: 2, + }, + feedbackSuccess: { + color: DodoColors.brand, + }, + feedbackError: { + color: DodoColors.fenceOutside, + }, + completeBlock: { + alignItems: 'center', + marginTop: 40, + gap: 4, + }, + primaryButton: { + height: 50, + borderRadius: 12, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonDisabled: { + opacity: 0.4, + }, + primaryButtonText: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.brandForeground, + }, + textLinkButton: { + alignItems: 'center', + paddingVertical: 6, + }, + textLink: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + textDecorationLine: 'underline', + }, +}); diff --git a/shared/api/authApi.ts b/shared/api/authApi.ts index 84603c2..b71d25c 100644 --- a/shared/api/authApi.ts +++ b/shared/api/authApi.ts @@ -49,6 +49,33 @@ export async function socialLogin(provider: SocialProvider, code: string): Promi return { kind: 'LOGIN', data: response.data as SocialLoginSuccess }; } +export interface RegisterProfileRequest { + hasFamily: boolean; + nickname: string; + region: string; + profileUrl?: string | null; +} + +export interface RegisterProfileResponse extends AuthTokens { + message: string; + profileUrl: string; +} + +/** + * 추가 정보 입력 → 가입 완료 (PUT /users/me/profile) + * - 202 응답으로 받은 registrationToken을 Authorization 헤더로 전달 + * - 200: 계정 ACTIVE 전환 + 새 토큰 발급 + */ +export async function registerProfile( + body: RegisterProfileRequest, + registrationToken: string, +): Promise { + const response = await apiClient.put('/users/me/profile', body, { + headers: { Authorization: `Bearer ${registrationToken}` }, + }); + return response.data; +} + export interface LogoutResponse { message: string; } diff --git a/shared/config/env.ts b/shared/config/env.ts index f9d025b..d80c5b6 100644 --- a/shared/config/env.ts +++ b/shared/config/env.ts @@ -1,17 +1,27 @@ const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL; +const WS_URL = process.env.EXPO_PUBLIC_WS_URL; const GOOGLE_CLIENT_ID = process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID; +const OAUTH_REDIRECT_URI = process.env.EXPO_PUBLIC_OAUTH_REDIRECT_URI; // TODO: 네이버 로그인 백엔드 배포되면 필수값으로 승격하고 아래 throw 추가 const NAVER_CLIENT_ID = process.env.EXPO_PUBLIC_NAVER_CLIENT_ID ?? ''; if (!API_BASE_URL) { throw new Error('Missing env: EXPO_PUBLIC_API_BASE_URL'); } +if (!WS_URL) { + throw new Error('Missing env: EXPO_PUBLIC_WS_URL'); +} if (!GOOGLE_CLIENT_ID) { throw new Error('Missing env: EXPO_PUBLIC_GOOGLE_CLIENT_ID'); } +if (!OAUTH_REDIRECT_URI) { + throw new Error('Missing env: EXPO_PUBLIC_OAUTH_REDIRECT_URI'); +} export const env = { API_BASE_URL, + WS_URL, GOOGLE_CLIENT_ID, + OAUTH_REDIRECT_URI, NAVER_CLIENT_ID, }; diff --git a/shared/lib/auth/oauth.ts b/shared/lib/auth/oauth.ts index fb93bb5..e719229 100644 --- a/shared/lib/auth/oauth.ts +++ b/shared/lib/auth/oauth.ts @@ -4,9 +4,15 @@ import * as WebBrowser from 'expo-web-browser'; import { env } from '@/shared/config/env'; -// 웹(dodo-frontend)의 features/auth/lib/oauth.ts와 동일한 방식. -// 앱이 직접 구글/네이버 인가 서버로 이동하고, 돌아온 인가 코드(code)를 백엔드 -// POST /auth/social-login 으로 넘겨 토큰을 발급받는다 (백엔드가 OAuth를 대행하지 않음). +// 웹(dodo-frontend)의 features/auth/lib/oauth.ts와 동일한 방식으로, 돌아온 인가 코드(code)를 +// 백엔드 POST /auth/social-login 으로 넘겨 토큰을 발급받는다 (백엔드가 OAuth를 대행하지 않음). +// +// Google/Naver 모두 자기 OAuth 서버의 redirect_uri로 커스텀 스킴(dodoapp://)을 직접 받는 걸 +// 정책상 허용하지 않는다 (특히 Android — https://developers.google.com/identity/protocols/oauth2/native-app). +// 그래서 redirect_uri는 웹과 동일한 HTTPS 주소(env.OAUTH_REDIRECT_URI)를 그대로 쓰고, state에 +// 'app_' 접두사를 붙여 이 요청이 앱에서 온 것임을 표시한다. dodo-frontend의 AuthCallbackPage가 +// 이 접두사를 보고 dodoapp://auth/callback/{provider}로 다시 리다이렉트해준다 — 이 리다이렉트는 +// 우리 웹페이지가 하는 것이라 Google/Naver의 정책과 무관하다. export type SocialProvider = 'GOOGLE' | 'NAVER'; interface ProviderOAuthConfig { @@ -32,18 +38,21 @@ export type SocialAuthCodeResult = | { success: false; reason: 'cancelled' | 'error' | 'state_mismatch' }; /** - * 구글/네이버 로그인 화면을 열고, 돌아온 인가 코드(code)를 반환한다. + * 구글/네이버 로그인을 수행하고, 돌아온 인가 코드(code)를 반환한다. * 코드를 실제 토큰으로 교환하는 건 shared/api/authApi.ts의 socialLogin()이 담당. */ export async function requestSocialAuthCode(provider: SocialProvider): Promise { const config = PROVIDER_OAUTH[provider]; - const redirectUri = Linking.createURL(`auth/callback/${provider.toLowerCase()}`); - const state = Crypto.randomUUID(); + const providerPath = provider.toLowerCase(); + + const appRedirectUri = Linking.createURL(`auth/callback/${providerPath}`); + const webRedirectUri = `${env.OAUTH_REDIRECT_URI}/${providerPath}`; + const state = `app_${Crypto.randomUUID()}`; const params = new URLSearchParams({ response_type: 'code', client_id: config.clientId, - redirect_uri: redirectUri, + redirect_uri: webRedirectUri, state, }); if (config.scope) { @@ -51,7 +60,7 @@ export async function requestSocialAuthCode(provider: SocialProvider): Promise Date: Fri, 28 Aug 2026 13:15:20 +0900 Subject: [PATCH 13/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=8F=84(=ED=94=84=EB=A1=9C=ED=95=84=C2=B7=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=C2=B7=ED=83=88=ED=87=B4=C2=B7=EA=B0=80=EC=A1=B1=C2=B7?= =?UTF-8?q?=EB=B0=98=EB=A0=A4=EB=8F=99=EB=AC=BC)=20=ED=99=94=EB=A9=B4=20AP?= =?UTF-8?q?I=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원정보 수정, 알림 설정, 회원 탈퇴(이메일 인증 2단계) 연동 - 반려동물 등록/수정/상세/특이사항/체중 관리 연동, 사진·생년월일 캘린더 입력 추가 - 가족 관리/신청/받은신청 연동, 가족 프로필 사진+이름 그리드로 표시 - 목데이터 파일 삭제 --- app/(tabs)/mypage/_layout.tsx | 6 +- app/(tabs)/mypage/family/_mock.ts | 47 --- app/(tabs)/mypage/family/apply.tsx | 89 +++-- app/(tabs)/mypage/family/index.tsx | 181 +++++++--- app/(tabs)/mypage/family/received.tsx | 160 ++++++++- app/(tabs)/mypage/index.tsx | 80 ++++- app/(tabs)/mypage/notifications.tsx | 115 ++++++- app/(tabs)/mypage/pets/[petId]/edit.tsx | 396 ++++++++++++++++++++++ app/(tabs)/mypage/pets/[petId]/index.tsx | 164 ++++++--- app/(tabs)/mypage/pets/[petId]/notes.tsx | 214 +++++++++--- app/(tabs)/mypage/pets/[petId]/weight.tsx | 365 +++++++++++++------- app/(tabs)/mypage/pets/_mock.ts | 109 ------ app/(tabs)/mypage/pets/index.tsx | 50 ++- app/(tabs)/mypage/pets/new.tsx | 84 +++-- app/(tabs)/mypage/profile-edit.tsx | 130 ++++--- app/(tabs)/mypage/walk-log.tsx | 16 - app/(tabs)/mypage/withdrawal.tsx | 214 ++++++++++++ 17 files changed, 1836 insertions(+), 584 deletions(-) delete mode 100644 app/(tabs)/mypage/family/_mock.ts create mode 100644 app/(tabs)/mypage/pets/[petId]/edit.tsx delete mode 100644 app/(tabs)/mypage/pets/_mock.ts delete mode 100644 app/(tabs)/mypage/walk-log.tsx create mode 100644 app/(tabs)/mypage/withdrawal.tsx diff --git a/app/(tabs)/mypage/_layout.tsx b/app/(tabs)/mypage/_layout.tsx index c1790a8..f55a2a2 100644 --- a/app/(tabs)/mypage/_layout.tsx +++ b/app/(tabs)/mypage/_layout.tsx @@ -33,16 +33,20 @@ export default function MyPageStackLayout() { + - + ); } diff --git a/app/(tabs)/mypage/family/_mock.ts b/app/(tabs)/mypage/family/_mock.ts deleted file mode 100644 index 60c939b..0000000 --- a/app/(tabs)/mypage/family/_mock.ts +++ /dev/null @@ -1,47 +0,0 @@ -// TODO(이슈4): GET /pets/family/* 연동 후 mock 제거 -export type FamilyMember = { - id: string; - name: string; -}; - -export const MOCK_FAMILY_MEMBERS: Record = { - sundubu: [{ id: 'me', name: '조수빈' }], -}; - -export type SentRequestStatus = '승인 대기' | '승인됨' | '거절됨'; - -export type SentRequest = { - id: string; - petName: string; - status: SentRequestStatus; - message: string; - sentAt: string; -}; - -export const MOCK_SENT_REQUESTS: SentRequest[] = [ - { - id: 'sr1', - petName: '보라', - status: '거절됨', - message: '가족 신청을 보낸 반려동물이에요.', - sentAt: '2026-07-08 15:11', - }, -]; - -export type ReceivedRequestStatus = '승인 대기' | '거절됨'; - -export type ReceivedRequest = { - id: string; - petName: string; - status: ReceivedRequestStatus; - sentAt: string; -}; - -export const MOCK_RECEIVED_REQUESTS: ReceivedRequest[] = []; - -export type BlockedEntry = { - id: string; - petName: string; -}; - -export const MOCK_BLOCKED: BlockedEntry[] = []; diff --git a/app/(tabs)/mypage/family/apply.tsx b/app/(tabs)/mypage/family/apply.tsx index b34b7c4..ba409a2 100644 --- a/app/(tabs)/mypage/family/apply.tsx +++ b/app/(tabs)/mypage/family/apply.tsx @@ -1,31 +1,63 @@ import Ionicons from '@expo/vector-icons/Ionicons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from 'expo-router'; import { useState } from 'react'; -import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import { DodoColors } from '@/constants/theme'; +import { getFamilyApplications, joinFamily, type FamilyApplicationStatus } from '@/shared/api/familyApi'; -import { MOCK_SENT_REQUESTS, type SentRequestStatus } from './_mock'; +type FilterLabel = '전체' | '승인 대기' | '거절됨'; +const FILTERS: FilterLabel[] = ['전체', '승인 대기', '거절됨']; +const FILTER_STATUS: Record = { + 전체: undefined, + '승인 대기': 'PENDING', + 거절됨: 'REJECTED', +}; -const FILTERS: ('전체' | SentRequestStatus)[] = ['전체', '승인 대기', '거절됨']; +function statusLabel(status: FamilyApplicationStatus): string { + if (status === 'PENDING') return '승인 대기'; + if (status === 'APPROVED') return '승인됨'; + if (status === 'REJECTED') return '거절됨'; + return '차단됨'; +} -function statusStyle(status: SentRequestStatus) { - if (status === '거절됨') return { bg: '#fee2e2', color: DodoColors.fenceOutside }; - if (status === '승인됨') return { bg: '#dcfce7', color: DodoColors.fenceActiveLabel }; +function statusStyle(status: FamilyApplicationStatus) { + if (status === 'REJECTED' || status === 'BLOCKED') return { bg: '#fee2e2', color: DodoColors.fenceOutside }; + if (status === 'APPROVED') return { bg: '#dcfce7', color: DodoColors.fenceActiveLabel }; return { bg: DodoColors.background, color: DodoColors.fenceIdleLabel }; } export default function FamilyApplyScreen() { + const queryClient = useQueryClient(); const [code, setCode] = useState(''); - const [filter, setFilter] = useState<(typeof FILTERS)[number]>('전체'); + const [filter, setFilter] = useState('전체'); + + const applicationsQuery = useQuery({ + queryKey: ['family', 'applications', filter], + queryFn: () => getFamilyApplications(FILTER_STATUS[filter], 0, 20), + }); - const filteredRequests = MOCK_SENT_REQUESTS.filter((r) => filter === '전체' || r.status === filter); + const applyMutation = useMutation({ + mutationFn: () => joinFamily(code.trim()), + onSuccess: () => { + setCode(''); + queryClient.invalidateQueries({ queryKey: ['family', 'applications'] }); + Alert.alert('가족 신청', '가족 신청을 보냈어요.'); + }, + onError: () => Alert.alert('오류', '가족 신청에 실패했어요. 코드를 다시 확인해주세요.'), + }); - const handleApply = () => { - if (!code.trim()) return; - Alert.alert('가족 신청', '가족 신청 기능은 준비 중이에요.'); - setCode(''); - }; + const applications = applicationsQuery.data?.applications ?? []; return ( @@ -52,10 +84,16 @@ export default function FamilyApplyScreen() { placeholder="가족 코드 입력" placeholderTextColor={DodoColors.fenceIdleLabel} value={code} - onChangeText={setCode} + onChangeText={(text) => setCode(text.toUpperCase())} + autoCapitalize="characters" + maxLength={6} /> - - 가족 신청 + applyMutation.mutate()} + > + {applyMutation.isPending ? '신청 중...' : '가족 신청'} @@ -79,13 +117,15 @@ export default function FamilyApplyScreen() { - {filteredRequests.length === 0 ? ( + {applicationsQuery.isLoading ? ( + + ) : applications.length === 0 ? ( 표시할 신청 내역이 없어요. ) : ( - filteredRequests.map((request) => { + applications.map((request) => { const style = statusStyle(request.status); return ( - + @@ -93,11 +133,12 @@ export default function FamilyApplyScreen() { {request.petName} - {request.status} + + {statusLabel(request.status)} + - {request.message} - {request.sentAt} + {request.appliedAt} ); @@ -264,10 +305,6 @@ const styles = StyleSheet.create({ fontSize: 10, fontWeight: '700', }, - requestMessage: { - fontSize: 12, - color: DodoColors.textSecondary, - }, requestDate: { fontSize: 10, color: DodoColors.fenceIdleLabel, diff --git a/app/(tabs)/mypage/family/index.tsx b/app/(tabs)/mypage/family/index.tsx index 5af63c4..ef979b5 100644 --- a/app/(tabs)/mypage/family/index.tsx +++ b/app/(tabs)/mypage/family/index.tsx @@ -1,25 +1,65 @@ import Ionicons from '@expo/vector-icons/Ionicons'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Image } from 'expo-image'; import { Link } from 'expo-router'; -import { useState } from 'react'; -import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; +import { createInvitationCode, getPendingUsers } from '@/shared/api/familyApi'; +import { getPetDetail, getPetsList } from '@/shared/api/petApi'; +import { getInvitationCode, saveInvitationCode } from '@/shared/lib/family/invitationCodeCache'; -import { MOCK_PETS } from '../pets/_mock'; -import { MOCK_FAMILY_MEMBERS, MOCK_RECEIVED_REQUESTS } from './_mock'; +export default function FamilyScreen() { + const petsQuery = useQuery({ queryKey: ['pets', 'list'], queryFn: () => getPetsList(0, 10) }); + const pets = useMemo(() => petsQuery.data?.pets ?? [], [petsQuery.data]); -function genderLabel(gender: 'F' | 'M' | null) { - if (gender === 'F') return '암컷'; - if (gender === 'M') return '수컷'; - return '미상'; -} + const [selectedPetId, setSelectedPetId] = useState(null); + useEffect(() => { + if (selectedPetId === null && pets.length > 0) setSelectedPetId(pets[0].petId); + }, [pets, selectedPetId]); -export default function FamilyScreen() { - const [selectedPetId, setSelectedPetId] = useState(MOCK_PETS[0]?.id ?? ''); - const selectedPet = MOCK_PETS.find((p) => p.id === selectedPetId) ?? null; - const members = selectedPet ? (MOCK_FAMILY_MEMBERS[selectedPet.id] ?? []) : []; + const petDetailQuery = useQuery({ + queryKey: ['pets', selectedPetId], + queryFn: () => getPetDetail(selectedPetId as number), + enabled: selectedPetId !== null, + }); + + const pendingQuery = useQuery({ + queryKey: ['family', 'pending-users'], + queryFn: () => getPendingUsers('PENDING'), + }); + + const receivedCount = (pendingQuery.data?.users ?? []).filter((u) => u.petId === selectedPetId).length; + + const [cachedCode, setCachedCode] = useState<{ code: string; expiresAt: number } | null>(null); + useEffect(() => { + if (selectedPetId === null) return; + getInvitationCode(selectedPetId).then(setCachedCode); + }, [selectedPetId]); + + const inviteMutation = useMutation({ + mutationFn: () => createInvitationCode(selectedPetId as number), + onSuccess: async ({ code, expiresIn }) => { + await saveInvitationCode(selectedPetId as number, code, expiresIn); + setCachedCode({ code, expiresAt: Date.now() + expiresIn * 1000 }); + Alert.alert('초대 코드 발급', `${selectedPet?.name}의 초대 코드가 발급됐어요.\n\n${code}`); + }, + onError: () => Alert.alert('오류', '초대 코드를 발급하지 못했어요.'), + }); + + const selectedPet = pets.find((p) => p.petId === selectedPetId) ?? null; + const members = petDetailQuery.data?.familyMembers ?? []; + + if (petsQuery.isLoading) { + return ( + + + + ); + } - if (MOCK_PETS.length === 0 || !selectedPet) { + if (pets.length === 0 || !selectedPet) { return ( @@ -33,11 +73,6 @@ export default function FamilyScreen() { ); } - const handleCreateInviteCode = () => { - const code = Math.random().toString(36).slice(2, 8).toUpperCase(); - Alert.alert('초대 코드 발급', `${selectedPet.name}의 초대 코드가 발급됐어요.\n\n${code}\n\n15분간 유효합니다.`); - }; - return ( FAMILY @@ -47,13 +82,13 @@ export default function FamilyScreen() { SELECT PET 가족을 관리할 반려동물을 선택해 주세요 - {MOCK_PETS.map((pet) => { - const selected = pet.id === selectedPetId; + {pets.map((pet) => { + const selected = pet.petId === selectedPetId; return ( setSelectedPetId(pet.id)} + onPress={() => setSelectedPetId(pet.petId)} > {pet.name} @@ -74,15 +109,15 @@ export default function FamilyScreen() { 가족 {members.length}명 + 만 {selectedPet.age}세 - {selectedPet.birthDate} ({selectedPet.ageLabel}) + {selectedPet.species === 'CANINE' ? '강아지' : '고양이'} {selectedPet.breed} - - {selectedPet.species} {selectedPet.breed} - - 성별 {genderLabel(selectedPet.gender)} - + 상세정보 @@ -92,14 +127,26 @@ export default function FamilyScreen() { 가족 구성원 - {members.map((member) => ( - - - - - {member.name} + {members.length === 0 ? ( + 아직 가족 구성원이 없어요. + ) : ( + + {members.map((member) => ( + + {member.profileImageUrl ? ( + + ) : ( + + + + )} + + {member.userName} + + + ))} - ))} + )} @@ -125,9 +172,7 @@ export default function FamilyScreen() { - {MOCK_RECEIVED_REQUESTS.length === 0 - ? '아직 받은 신청이 없어요.' - : `${MOCK_RECEIVED_REQUESTS.length}건의 신청이 있어요.`} + {receivedCount === 0 ? '아직 받은 신청이 없어요.' : `${receivedCount}건의 신청이 있어요.`} @@ -135,11 +180,22 @@ export default function FamilyScreen() { 초대 코드 발급 - 선택한 반려동물 기준으로 초대 코드를 발급할 수 있어요.{'\n'}생성된 코드는 15분 동안 유효하고, 같은 코드로 가족 - 신청을 받을 수 있어요. + 선택한 반려동물 기준으로 초대 코드를 발급할 수 있어요.{'\n'}발급된 코드는 만료 전까지 이 화면에서 다시 볼 수 + 있어요. - - 초대 코드 만들기 + {cachedCode && ( + + {cachedCode.code} + + )} + inviteMutation.mutate()} + > + + {inviteMutation.isPending ? '발급 중...' : cachedCode ? '초대 코드 다시 만들기' : '초대 코드 만들기'} + @@ -272,22 +328,34 @@ const styles = StyleSheet.create({ fontWeight: '600', color: DodoColors.textSecondary, }, - memberRow: { + memberGrid: { flexDirection: 'row', + flexWrap: 'wrap', + gap: 14, + }, + memberItem: { + width: 56, alignItems: 'center', - gap: 8, + gap: 4, }, memberAvatar: { - width: 28, - height: 28, - borderRadius: 14, + width: 44, + height: 44, + borderRadius: 22, backgroundColor: DodoColors.brand, alignItems: 'center', justifyContent: 'center', }, + memberAvatarImage: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: DodoColors.background, + }, memberName: { - fontSize: 13, + fontSize: 11, color: DodoColors.textPrimary, + textAlign: 'center', }, cardHeaderRow: { flexDirection: 'row', @@ -304,6 +372,21 @@ const styles = StyleSheet.create({ lineHeight: 16, color: DodoColors.fenceIdleLabel, }, + codeBox: { + alignSelf: 'flex-start', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: DodoColors.background, + borderWidth: 1, + borderColor: DodoColors.border, + }, + codeText: { + fontSize: 16, + fontWeight: '700', + letterSpacing: 2, + color: DodoColors.textPrimary, + }, primaryButton: { alignSelf: 'flex-start', paddingHorizontal: 16, diff --git a/app/(tabs)/mypage/family/received.tsx b/app/(tabs)/mypage/family/received.tsx index 273858d..8a535d5 100644 --- a/app/(tabs)/mypage/family/received.tsx +++ b/app/(tabs)/mypage/family/received.tsx @@ -1,16 +1,62 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from 'expo-router'; import { useState } from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; +import { + approveFamilyApplication, + getBlockedUsers, + getPendingUsers, + unblockFamilyUser, + type FamilyApprovalAction, +} from '@/shared/api/familyApi'; -import { MOCK_BLOCKED, MOCK_RECEIVED_REQUESTS, type ReceivedRequestStatus } from './_mock'; - -const FILTERS: ReceivedRequestStatus[] = ['승인 대기', '거절됨']; +type FilterLabel = '승인 대기' | '거절됨'; +const FILTERS: FilterLabel[] = ['승인 대기', '거절됨']; export default function FamilyReceivedScreen() { - const [filter, setFilter] = useState('승인 대기'); - const filteredRequests = MOCK_RECEIVED_REQUESTS.filter((r) => r.status === filter); + const queryClient = useQueryClient(); + const [filter, setFilter] = useState('승인 대기'); + + const pendingQuery = useQuery({ + queryKey: ['family', 'pending-users', filter], + queryFn: () => getPendingUsers(filter === '승인 대기' ? 'PENDING' : 'REJECTED', 0, 20), + }); + + const blockedQuery = useQuery({ + queryKey: ['family', 'blocked-users'], + queryFn: () => getBlockedUsers(0, 20), + }); + + const invalidateAll = () => { + queryClient.invalidateQueries({ queryKey: ['family', 'pending-users'] }); + queryClient.invalidateQueries({ queryKey: ['family', 'blocked-users'] }); + }; + + const approvalMutation = useMutation({ + mutationFn: ({ + petId, + targetUserId, + action, + }: { + petId: number; + targetUserId: string; + action: FamilyApprovalAction; + }) => approveFamilyApplication(petId, targetUserId, action), + onSuccess: invalidateAll, + onError: () => Alert.alert('오류', '요청을 처리하지 못했어요.'), + }); + + const unblockMutation = useMutation({ + mutationFn: ({ petId, targetUserId }: { petId: number; targetUserId: string }) => + unblockFamilyUser(petId, targetUserId), + onSuccess: invalidateAll, + onError: () => Alert.alert('오류', '차단을 해제하지 못했어요.'), + }); + + const requests = pendingQuery.data?.users ?? []; + const blocked = blockedQuery.data?.users ?? []; return ( @@ -45,13 +91,55 @@ export default function FamilyReceivedScreen() { - {filteredRequests.length === 0 ? ( + {pendingQuery.isLoading ? ( + + ) : requests.length === 0 ? ( 아직 받은 신청이 없어요. ) : ( - filteredRequests.map((request) => ( - - {request.petName} - {request.sentAt} + requests.map((request) => ( + + + + {request.userName} → {request.petName} + + {request.appliedAt} + + {filter === '승인 대기' && ( + + + approvalMutation.mutate({ + petId: request.petId, + targetUserId: request.userId, + action: 'APPROVED', + }) + } + > + 승인 + + + approvalMutation.mutate({ + petId: request.petId, + targetUserId: request.userId, + action: 'REJECTED', + }) + } + > + 거절 + + + approvalMutation.mutate({ petId: request.petId, targetUserId: request.userId, action: 'BLOCKED' }) + } + > + 차단 + + + )} )) )} @@ -59,12 +147,22 @@ export default function FamilyReceivedScreen() { 차단 목록 - {MOCK_BLOCKED.length === 0 ? ( - 선택한 반려동물의 차단 목록이 비어 있어요. + {blockedQuery.isLoading ? ( + + ) : blocked.length === 0 ? ( + 차단 목록이 비어 있어요. ) : ( - MOCK_BLOCKED.map((entry) => ( - - {entry.petName} + blocked.map((entry) => ( + + + {entry.userName} → {entry.petName} + + unblockMutation.mutate({ petId: entry.petId, targetUserId: entry.userId })} + > + 차단 해제 + )) )} @@ -158,9 +256,15 @@ const styles = StyleSheet.create({ requestRow: { flexDirection: 'row', justifyContent: 'space-between', + alignItems: 'center', paddingTop: 10, borderTopWidth: 1, borderTopColor: DodoColors.background, + gap: 8, + }, + requestTextCol: { + flex: 1, + gap: 2, }, requestPetName: { fontSize: 13, @@ -171,4 +275,28 @@ const styles = StyleSheet.create({ fontSize: 11, color: DodoColors.fenceIdleLabel, }, + requestActions: { + flexDirection: 'row', + gap: 6, + }, + actionButton: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + actionButtonText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + actionButtonDanger: { + borderColor: DodoColors.fenceOutside, + }, + actionButtonDangerText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.fenceOutside, + }, }); diff --git a/app/(tabs)/mypage/index.tsx b/app/(tabs)/mypage/index.tsx index 9ff262a..af00006 100644 --- a/app/(tabs)/mypage/index.tsx +++ b/app/(tabs)/mypage/index.tsx @@ -1,20 +1,16 @@ import Ionicons from '@expo/vector-icons/Ionicons'; -import { Link, useRouter } from 'expo-router'; +import { useQuery } from '@tanstack/react-query'; +import { Link, useRouter, type Href } from 'expo-router'; +import { useEffect } from 'react'; import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { logout } from '@/shared/api/authApi'; +import { getMe } from '@/shared/api/userApi'; import { DodoColors } from '@/constants/theme'; import { useAuthStore } from '@/shared/lib/auth/authStore'; import * as tokenStorage from '@/shared/lib/auth/tokenStorage'; -// TODO(이슈4): GET /users/me 연동 후 mock 제거 -const MOCK_USER = { - nickname: '조펭이', - region: '서울특별시 서대문구', - email: 'csb7543@naver.com', -}; - type MenuItem = { key: string; label: string; @@ -27,21 +23,41 @@ const PET_MENU: MenuItem[] = [ { key: 'pets', label: '반려동물 리스트', icon: 'paw-outline', href: '/(tabs)/mypage/pets' }, { key: 'device', label: '디바이스 관리', icon: 'hardware-chip-outline', href: '/(tabs)/mypage/device' }, { key: 'family', label: '가족 관리', icon: 'people-outline', href: '/(tabs)/mypage/family' }, - { key: 'walk-log', label: '산책 기록', icon: 'walk-outline', href: '/(tabs)/mypage/walk-log' }, + { key: 'walk-log', label: '산책 기록', icon: 'walk-outline', href: '/(tabs)/walk/history' }, { key: 'ai-report', label: 'AI 레포트', icon: 'analytics-outline', href: '/(tabs)/mypage/ai-report' }, ]; const ACCOUNT_MENU: MenuItem[] = [ { key: 'profile-edit', label: '회원정보 수정', icon: 'person-circle-outline', href: '/(tabs)/mypage/profile-edit' }, - { key: 'notifications', label: '알림함', icon: 'notifications-outline', href: '/(tabs)/mypage/notifications' }, + { key: 'notifications', label: '알림 설정', icon: 'notifications-outline', href: '/(tabs)/mypage/notifications' }, { key: 'logout', label: '로그아웃', icon: 'log-out-outline', danger: true }, ]; export default function MyPageScreen() { const router = useRouter(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const clearSession = useAuthStore((state) => state.clearSession); + const updateProfile = useAuthStore((state) => state.updateProfile); const insets = useSafeAreaInsets(); + const meQuery = useQuery({ + queryKey: ['users', 'me'], + queryFn: getMe, + enabled: isAuthenticated, + }); + + // 웹과 동일: 서버 응답으로 로컬 프로필 캐시 동기화 (헤더 등 다른 화면과 공유) + useEffect(() => { + if (meQuery.data) { + updateProfile({ + nickname: meQuery.data.nickname, + region: meQuery.data.region, + profileUrl: meQuery.data.profileUrl, + notificationEnabled: meQuery.data.notificationEnabled, + }); + } + }, [meQuery.data, updateProfile]); + const handleLogout = () => { Alert.alert('로그아웃', '로그아웃 하시겠어요?', [ { text: '취소', style: 'cancel' }, @@ -91,6 +107,24 @@ export default function MyPageScreen() { ); }; + if (!isAuthenticated) { + return ( + + + + + + 로그인이 필요해요 + 로그인하면 반려동물 정보와 회원정보를 관리할 수 있어요. + {/* generated route types omit the bare "/auth" index route (typegen quirk), but it's the only path that resolves at runtime */} + router.push('/auth' as Href)}> + 로그인하기 + + + + ); + } + return ( @@ -98,9 +132,9 @@ export default function MyPageScreen() { - {MOCK_USER.nickname}님 - {MOCK_USER.region} - {MOCK_USER.email} + {meQuery.data?.nickname ?? ' '}님 + {meQuery.data?.region} + {meQuery.data?.email} 반려동물 @@ -122,6 +156,25 @@ const styles = StyleSheet.create({ padding: 16, paddingBottom: 32, }, + guestContent: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingHorizontal: 32, + }, + loginButton: { + marginTop: 16, + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 10, + backgroundColor: DodoColors.brand, + }, + loginButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, profileCard: { backgroundColor: DodoColors.surface, borderRadius: 16, @@ -149,6 +202,7 @@ const styles = StyleSheet.create({ fontSize: 12, color: DodoColors.textSecondary, marginTop: 2, + textAlign: 'center', }, sectionLabel: { fontSize: 12, diff --git a/app/(tabs)/mypage/notifications.tsx b/app/(tabs)/mypage/notifications.tsx index 9ba2eaf..3dd22bf 100644 --- a/app/(tabs)/mypage/notifications.tsx +++ b/app/(tabs)/mypage/notifications.tsx @@ -1,16 +1,123 @@ -import { StyleSheet, Text, View } from 'react-native'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; +import { getMe, updateNotificationSetting } from '@/shared/api/userApi'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; export default function NotificationsScreen() { + const queryClient = useQueryClient(); + const updateProfile = useAuthStore((state) => state.updateProfile); + + const meQuery = useQuery({ queryKey: ['users', 'me'], queryFn: getMe }); + + const mutation = useMutation({ + mutationFn: (enabled: boolean) => updateNotificationSetting(enabled), + onSuccess: (_, enabled) => { + updateProfile({ notificationEnabled: enabled }); + queryClient.invalidateQueries({ queryKey: ['users', 'me'] }); + }, + }); + + if (meQuery.isLoading || !meQuery.data) { + return ( + + + + ); + } + + const enabled = meQuery.data.notificationEnabled; + return ( - 알림함 화면 준비 중 + NOTIFICATIONS + 알림 설정 + 산책, 건강, 가족 관리 등 주요 알림을 받아볼지 선택해주세요. + + mutation.mutate(true)} + > + + + 알림 받기 + 주요 소식과 활동 알림을 받아볼게요. + + + + mutation.mutate(false)} + > + + + 지금은 받지 않기 + 나중에 이 화면에서 다시 켤 수 있어요. + + ); } const styles = StyleSheet.create({ - container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: DodoColors.background }, - text: { color: DodoColors.textSecondary, fontSize: 15 }, + container: { + flex: 1, + backgroundColor: DodoColors.background, + padding: 16, + gap: 12, + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + subtitle: { + fontSize: 12, + color: DodoColors.textSecondary, + marginBottom: 8, + }, + optionCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + backgroundColor: DodoColors.surface, + borderRadius: 14, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + }, + optionCardSelected: { + borderColor: DodoColors.brand, + }, + optionTextCol: { + flex: 1, + gap: 2, + }, + optionTitle: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + optionHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, }); diff --git a/app/(tabs)/mypage/pets/[petId]/edit.tsx b/app/(tabs)/mypage/pets/[petId]/edit.tsx new file mode 100644 index 0000000..c87ad39 --- /dev/null +++ b/app/(tabs)/mypage/pets/[petId]/edit.tsx @@ -0,0 +1,396 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { AvatarImagePicker } from '@/components/common/avatar-image-picker'; +import { DatePickerModal } from '@/components/common/date-picker-modal'; +import { DodoColors } from '@/constants/theme'; +import { getPetDetail, updatePet } from '@/shared/api/petApi'; + +function todayString() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +} + +type Species = '강아지' | '고양이'; +type Gender = '수컷' | '암컷'; + +const SPECIES_MAP: Record = { 강아지: 'CANINE', 고양이: 'FELINE' }; +const SPECIES_REVERSE: Record<'CANINE' | 'FELINE', Species> = { CANINE: '강아지', FELINE: '고양이' }; +const GENDER_MAP: Record = { 수컷: 'MALE', 암컷: 'FEMALE' }; +const GENDER_REVERSE: Record<'MALE' | 'FEMALE' | 'NEUTER', Gender> = { MALE: '수컷', FEMALE: '암컷', NEUTER: '수컷' }; + +function calculateAgeFromBirth(birthDate: string): number { + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(birthDate.trim()); + if (!match) return 0; + const [, y, m, d] = match; + const birth = new Date(Number(y), Number(m) - 1, Number(d)); + const now = new Date(); + let age = now.getFullYear() - birth.getFullYear(); + const hasHadBirthdayThisYear = + now.getMonth() > birth.getMonth() || (now.getMonth() === birth.getMonth() && now.getDate() >= birth.getDate()); + if (!hasHadBirthdayThisYear) age -= 1; + return Math.max(age, 0); +} + +function ChipSelect({ + options, + value, + onChange, +}: { + options: readonly T[]; + value: T | null; + onChange: (v: T) => void; +}) { + return ( + + {options.map((option) => { + const selected = value === option; + return ( + onChange(option)} + > + {option} + + ); + })} + + ); +} + +export default function EditPetScreen() { + const { petId } = useLocalSearchParams<{ petId: string }>(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const petQuery = useQuery({ queryKey: ['pets', petId], queryFn: () => getPetDetail(petId), enabled: !!petId }); + + const [name, setName] = useState(''); + const [breed, setBreed] = useState(''); + const [species, setSpecies] = useState(null); + const [gender, setGender] = useState(null); + const [birthDate, setBirthDate] = useState(''); + const [registrationNumber, setRegistrationNumber] = useState(''); + const [deviceId, setDeviceId] = useState(''); + const [baselineHeartRate, setBaselineHeartRate] = useState(''); + const [imageUrl, setImageUrl] = useState(null); + const [datePickerVisible, setDatePickerVisible] = useState(false); + + useEffect(() => { + if (!petQuery.data) return; + const pet = petQuery.data; + setName(pet.petName); + setBreed(pet.breed); + setSpecies(SPECIES_REVERSE[pet.species]); + setGender(GENDER_REVERSE[pet.sex]); + setBirthDate(pet.birth?.slice(0, 10) ?? ''); + setRegistrationNumber(pet.registrationNumber ?? ''); + setDeviceId(pet.deviceId); + setBaselineHeartRate(String(pet.referenceHeartRate)); + setImageUrl(pet.imageFileUrl ?? null); + }, [petQuery.data]); + + const ageLabel = useMemo(() => (birthDate ? `만 ${calculateAgeFromBirth(birthDate)}세` : null), [birthDate]); + + const canSubmit = + name.trim().length > 0 && + breed.trim().length > 0 && + !!species && + !!gender && + birthDate.trim().length > 0 && + deviceId.trim().length > 0 && + baselineHeartRate.trim().length > 0; + + const updateMutation = useMutation({ + mutationFn: () => { + if (!petQuery.data) throw new Error('not loaded'); + return updatePet(petId, { + imageFileUrl: imageUrl ?? '', + petName: name.trim(), + species: SPECIES_MAP[species as Species], + sex: GENDER_MAP[gender as Gender], + breed: breed.trim(), + birth: `${birthDate.trim()}T00:00:00`, + age: calculateAgeFromBirth(birthDate), + registrationNumber: registrationNumber.trim() || undefined, + referenceHeartRate: Number(baselineHeartRate), + deviceId: deviceId.trim(), + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pets', petId] }); + queryClient.invalidateQueries({ queryKey: ['pets', 'list'] }); + router.back(); + }, + onError: () => Alert.alert('오류', '반려동물 정보를 수정하지 못했어요.'), + }); + + if (petQuery.isLoading || !petQuery.data) { + return ( + + + + ); + } + + return ( + + + + + + + + + + 기본 정보 + + + + 반려동물 이름 * + + + + 품종 * + + + + + + 종 * + + + + + 성별 * + + + + + + 생년월일 * + setDatePickerVisible(true)}> + + {birthDate || '날짜 선택'} + + + + + 만 나이 + + {ageLabel ?? '-'} + + + + + + 등록번호 + + + + + + 디바이스 및 건강 기준값 + + + 디바이스 ID * + + + + 기준 심박수 * + + + + + + * 표시는 필수 입력 항목입니다. + + + + router.back()}> + 취소 + + updateMutation.mutate()} + > + {updateMutation.isPending ? '저장 중...' : '저장하기'} + + + + setDatePickerVisible(false)} + onConfirm={setBirthDate} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 16, + }, + imageRow: { + alignItems: 'center', + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 14, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + fieldRow: { + flexDirection: 'row', + gap: 12, + }, + fieldHalf: { + flex: 1, + gap: 6, + }, + field: { + gap: 6, + }, + label: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + input: { + height: 42, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + fontSize: 13, + color: DodoColors.textPrimary, + }, + readonlyInput: { + height: 42, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + justifyContent: 'center', + }, + readonlyValue: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + placeholderValue: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + }, + chipRow: { + flexDirection: 'row', + gap: 8, + }, + chip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + }, + chipSelected: { + borderColor: DodoColors.brand, + backgroundColor: DodoColors.brand, + }, + chipText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + chipTextSelected: { + color: DodoColors.brandForeground, + }, + requiredHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + actionBar: { + flexDirection: 'row', + gap: 8, + padding: 12, + borderTopWidth: 1, + borderTopColor: DodoColors.border, + backgroundColor: DodoColors.surface, + }, + secondaryButton: { + flex: 1, + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + primaryButton: { + flex: 1.4, + height: 44, + borderRadius: 10, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonDisabled: { + opacity: 0.4, + }, + primaryButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, +}); diff --git a/app/(tabs)/mypage/pets/[petId]/index.tsx b/app/(tabs)/mypage/pets/[petId]/index.tsx index 5cf4945..64285f5 100644 --- a/app/(tabs)/mypage/pets/[petId]/index.tsx +++ b/app/(tabs)/mypage/pets/[petId]/index.tsx @@ -1,26 +1,67 @@ import Ionicons from '@expo/vector-icons/Ionicons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Image } from 'expo-image'; import { Link, useLocalSearchParams, useRouter } from 'expo-router'; -import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; +import { getPetDetail, getWeightHistory, leavePetFamily } from '@/shared/api/petApi'; -import { MOCK_NOTES, MOCK_PETS, MOCK_WEIGHT_RECORDS } from '../_mock'; +const NOTE_TYPE_LABEL: Record = { + ALLERGY: '알레르기', + HOSPITAL: '병원', + MEDICATION: '약물', + FOOD: '음식', + BEHAVIOR: '행동', + SYMPTOM: '증상', + ETC: '기타', +}; + +function speciesLabel(species: 'CANINE' | 'FELINE') { + return species === 'CANINE' ? '강아지' : '고양이'; +} export default function PetDetailScreen() { const { petId } = useLocalSearchParams<{ petId: string }>(); const router = useRouter(); - const pet = MOCK_PETS.find((p) => p.id === petId) ?? MOCK_PETS[0]; - const weightRecords = MOCK_WEIGHT_RECORDS[pet.id] ?? []; - const latestWeight = weightRecords[0]; - const notes = MOCK_NOTES[pet.id] ?? []; + const queryClient = useQueryClient(); + + const petQuery = useQuery({ queryKey: ['pets', petId], queryFn: () => getPetDetail(petId), enabled: !!petId }); + const weightQuery = useQuery({ + queryKey: ['pets', petId, 'weight', 'preview'], + queryFn: () => getWeightHistory(petId, 0, 1), + enabled: !!petId, + }); + + const leaveMutation = useMutation({ + mutationFn: () => leavePetFamily(petId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pets', 'list'] }); + router.replace('/(tabs)/mypage/pets'); + }, + onError: () => Alert.alert('오류', '가족 나가기에 실패했어요.'), + }); const handleLeaveFamily = () => { - Alert.alert('가족 나가기', `${pet.name}의 가족에서 나가시겠어요?`, [ + Alert.alert('가족 나가기', `${pet?.petName}의 가족에서 나가시겠어요?`, [ { text: '취소', style: 'cancel' }, - { text: '나가기', style: 'destructive' }, + { text: '나가기', style: 'destructive', onPress: () => leaveMutation.mutate() }, ]); }; + if (petQuery.isLoading || !petQuery.data) { + return ( + + + + ); + } + + const pet = petQuery.data; + const latestWeight = weightQuery.data?.records?.[0]; + const notes = pet.specialNotes ?? []; + const familyMembers = pet.familyMembers ?? []; + return ( @@ -32,9 +73,11 @@ export default function PetDetailScreen() { router.back()}> 목록으로 - - 정보 수정 - + + + 정보 수정 + + @@ -45,20 +88,20 @@ export default function PetDetailScreen() { - {pet.name} - {pet.gender && ( + {pet.petName} + {pet.sex !== 'NEUTER' && ( )} - {pet.birthDate} ({pet.ageLabel}) + {pet.birth?.slice(0, 10)} (만 {pet.age}세) - {pet.species} {pet.breed} + {speciesLabel(pet.species)} {pet.breed} @@ -76,14 +119,14 @@ export default function PetDetailScreen() { 기준 심박수 - {pet.baselineHeartRate} + {pet.referenceHeartRate} 체중 정보 - + 전체보기 @@ -91,8 +134,8 @@ export default function PetDetailScreen() { {latestWeight ? ( - {latestWeight.weightKg}kg - 최근 측정일 {latestWeight.measuredAt} + {latestWeight.weight}kg + 최근 측정일 {latestWeight.petWeightsMeasuredAt?.slice(0, 10)} ) : ( 등록된 체중 기록이 없어요. @@ -100,43 +143,54 @@ export default function PetDetailScreen() { - - 가족 구성원 - - 전체보기 - - - - - + 가족 구성원 + {familyMembers.length === 0 ? ( + 가족 구성원이 없어요. + ) : ( + + {familyMembers.map((member) => ( + + {member.profileImageUrl ? ( + + ) : ( + + + + )} + + {member.userName} + + + ))} - 가족 구성원 1명과 함께 관리 중이에요. - + )} 최근 활동 - 최근 활동 정보가 없습니다. + + {pet.lastActivity ? JSON.stringify(pet.lastActivity) : '최근 활동 정보가 없습니다.'} + 특이사항 - + 전체보기 - 총 {notes.length}개의 특이사항이 등록되어 있어요. + 총 {pet.specialNotesCount}개의 특이사항이 등록되어 있어요. {notes.slice(0, 3).map((note) => ( - + - {note.tag} + {NOTE_TYPE_LABEL[note.noteType] ?? note.noteType} - {note.date} + {note.createdAt?.slice(0, 10)} - {note.content} + {note.noteContent} ))} @@ -148,7 +202,7 @@ export default function PetDetailScreen() { 가족에서 나가면 이 반려동물의 가족 구성원 목록과 관련 활동 기록을 더 이상 확인할 수 없어요. 다시 참여하려면 초대 코드를 다시 등록해야 해요. - + 가족 나가기 @@ -161,6 +215,10 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: DodoColors.background, }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + }, content: { padding: 16, paddingBottom: 32, @@ -279,15 +337,31 @@ const styles = StyleSheet.create({ fontSize: 11, color: DodoColors.fenceIdleLabel, }, - familyRow: { + familyGrid: { flexDirection: 'row', + flexWrap: 'wrap', + gap: 14, + }, + familyItem: { + width: 56, alignItems: 'center', - gap: 10, + gap: 4, + }, + familyAvatarImage: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: DodoColors.background, + }, + familyName: { + fontSize: 11, + color: DodoColors.textPrimary, + textAlign: 'center', }, familyAvatar: { - width: 32, - height: 32, - borderRadius: 16, + width: 44, + height: 44, + borderRadius: 22, backgroundColor: DodoColors.brand, alignItems: 'center', justifyContent: 'center', diff --git a/app/(tabs)/mypage/pets/[petId]/notes.tsx b/app/(tabs)/mypage/pets/[petId]/notes.tsx index 99d3f5a..3575fd4 100644 --- a/app/(tabs)/mypage/pets/[petId]/notes.tsx +++ b/app/(tabs)/mypage/pets/[petId]/notes.tsx @@ -1,47 +1,105 @@ import Ionicons from '@expo/vector-icons/Ionicons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useState } from 'react'; -import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import { DodoColors } from '@/constants/theme'; +import { + createSignificantNote, + deleteSignificantNote, + getPetDetail, + getSignificantNotes, + NOTE_TYPES, + updateSignificantNote, + type NoteType, +} from '@/shared/api/petApi'; -import { MOCK_NOTES, MOCK_PETS, NOTE_CATEGORIES, type NoteCategory, type SignificantNote } from '../_mock'; +const CATEGORY_LABEL: Record = { + ALLERGY: '알레르기', + HOSPITAL: '병원', + MEDICATION: '약물', + FOOD: '음식', + BEHAVIOR: '행동', + SYMPTOM: '증상', + ETC: '기타', +}; -const TAG_COLORS: Record = { - 알레르기: '#fdead9', - 병원: '#e0f2fe', - 약물: '#fdead9', - 음식: '#fef9c3', - 행동: '#ede9fe', - 증상: '#fee2e2', - 기타: DodoColors.background, +const TAG_COLORS: Record = { + ALLERGY: '#fdead9', + HOSPITAL: '#e0f2fe', + MEDICATION: '#fdead9', + FOOD: '#fef9c3', + BEHAVIOR: '#ede9fe', + SYMPTOM: '#fee2e2', + ETC: DodoColors.background, }; export default function PetNotesScreen() { const { petId } = useLocalSearchParams<{ petId: string }>(); const router = useRouter(); - const pet = MOCK_PETS.find((p) => p.id === petId) ?? MOCK_PETS[0]; - const [notes, setNotes] = useState(MOCK_NOTES[pet.id] ?? []); + const queryClient = useQueryClient(); + + const petQuery = useQuery({ queryKey: ['pets', petId], queryFn: () => getPetDetail(petId), enabled: !!petId }); + const notesQuery = useQuery({ + queryKey: ['pets', petId, 'notes'], + queryFn: () => getSignificantNotes(petId, 0, 10), + enabled: !!petId, + }); + const [content, setContent] = useState(''); - const [category, setCategory] = useState('알레르기'); + const [category, setCategory] = useState('ALLERGY'); const [pickerOpen, setPickerOpen] = useState(false); - const [filter, setFilter] = useState<'전체' | NoteCategory>('전체'); - - const filteredNotes = notes.filter((n) => filter === '전체' || n.tag === filter); + const [filter, setFilter] = useState<'전체' | NoteType>('전체'); + const [editingId, setEditingId] = useState(null); + const [editingContent, setEditingContent] = useState(''); + const [editingCategory, setEditingCategory] = useState('ALLERGY'); - const handleAdd = () => { - if (!content.trim()) return; - const today = new Date().toISOString().slice(0, 10); - setNotes((prev) => [{ id: `note-${Date.now()}`, tag: category, date: today, content: content.trim() }, ...prev]); - setContent(''); + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ['pets', petId, 'notes'] }); + queryClient.invalidateQueries({ queryKey: ['pets', petId] }); }; - const handleDelete = (id: string) => { - setNotes((prev) => prev.filter((n) => n.id !== id)); - }; + const addMutation = useMutation({ + mutationFn: () => createSignificantNote(Number(petId), content.trim(), category), + onSuccess: () => { + setContent(''); + invalidate(); + }, + onError: () => Alert.alert('오류', '특이사항을 추가하지 못했어요.'), + }); + + const updateMutation = useMutation({ + mutationFn: (id: number) => updateSignificantNote(id, editingContent.trim(), editingCategory), + onSuccess: () => { + setEditingId(null); + invalidate(); + }, + onError: () => Alert.alert('오류', '특이사항을 수정하지 못했어요.'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => deleteSignificantNote(id), + onSuccess: invalidate, + onError: () => Alert.alert('오류', '특이사항을 삭제하지 못했어요.'), + }); - const handleEdit = () => { - Alert.alert('수정', '특이사항 수정 기능은 준비 중이에요.'); + const notes = notesQuery.data?.notes ?? []; + const filteredNotes = notes.filter((n) => filter === '전체' || n.noteType === filter); + + const startEdit = (noteId: number, noteContent: string, noteType: NoteType) => { + setEditingId(noteId); + setEditingContent(noteContent); + setEditingCategory(noteType); }; return ( @@ -58,24 +116,26 @@ export default function PetNotesScreen() { - {pet.name} + {petQuery.data?.petName ?? ''} 총 {notes.length}개 - {pet.name}의 특이사항을 정리하고 필요한 메모를 추가하거나 수정할 수 있어요. + + {petQuery.data?.petName ?? '반려동물'}의 특이사항을 정리하고 필요한 메모를 추가하거나 수정할 수 있어요. + setPickerOpen((v) => !v)}> - {category} + {CATEGORY_LABEL[category]} {pickerOpen && ( - {NOTE_CATEGORIES.map((option) => ( + {NOTE_TYPES.map((option) => ( - {option} + {CATEGORY_LABEL[option]} ))} @@ -97,7 +157,11 @@ export default function PetNotesScreen() { value={content} onChangeText={setContent} /> - + addMutation.mutate()} + > 추가 @@ -105,45 +169,80 @@ export default function PetNotesScreen() { - {(['전체', ...NOTE_CATEGORIES] as const).map((f) => { + {(['전체', ...NOTE_TYPES] as const).map((f) => { const selected = f === filter; + const label = f === '전체' ? '전체' : CATEGORY_LABEL[f]; return ( setFilter(f)} > - {f} + {label} ); })} - {filteredNotes.length === 0 ? ( + {notesQuery.isLoading ? ( + + ) : filteredNotes.length === 0 ? ( 등록된 특이사항이 없어요. ) : ( - filteredNotes.map((note, index) => ( - - - {note.tag} - - - {note.date} - {note.content} - - - - 수정 - - handleDelete(note.id)} - > - 삭제 - + filteredNotes.map((note, index) => { + const isEditing = editingId === note.noteId; + return ( + + {isEditing ? ( + <> + + + updateMutation.mutate(note.noteId)} + > + 저장 + + setEditingId(null)}> + 취소 + + + + ) : ( + <> + + {CATEGORY_LABEL[note.noteType]} + + + {note.createdAt?.slice(0, 10)} + {note.noteContent} + + + startEdit(note.noteId, note.noteContent, note.noteType)} + > + 수정 + + deleteMutation.mutate(note.noteId)} + > + 삭제 + + + + )} - - )) + ); + }) )} @@ -279,6 +378,9 @@ const styles = StyleSheet.create({ fontSize: 12, color: DodoColors.textPrimary, }, + editInput: { + marginRight: 8, + }, addButton: { paddingHorizontal: 16, height: 40, diff --git a/app/(tabs)/mypage/pets/[petId]/weight.tsx b/app/(tabs)/mypage/pets/[petId]/weight.tsx index 9adfba7..d417f20 100644 --- a/app/(tabs)/mypage/pets/[petId]/weight.tsx +++ b/app/(tabs)/mypage/pets/[petId]/weight.tsx @@ -1,11 +1,28 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useMemo, useState } from 'react'; -import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import { Circle, Polyline, Svg } from 'react-native-svg'; +import { DatePickerModal } from '@/components/common/date-picker-modal'; import { DodoColors } from '@/constants/theme'; - -import { MOCK_PETS, MOCK_WEIGHT_RECORDS, type WeightRecord } from '../_mock'; +import { + createWeightRecord, + deleteWeightRecord, + getPetDetail, + getWeightHistory, + updateWeightRecord, + type WeightRecord, +} from '@/shared/api/petApi'; const CHART_WIDTH = 300; const CHART_HEIGHT = 140; @@ -13,14 +30,14 @@ const CHART_PADDING = 20; function WeightChart({ records }: { records: WeightRecord[] }) { const oldestFirst = [...records].reverse(); - const values = oldestFirst.map((r) => r.weightKg); + const values = oldestFirst.map((r) => r.weight); const min = Math.min(...values); const max = Math.max(...values); const range = max - min || 1; const points = oldestFirst.map((record, index) => { const x = CHART_PADDING + (index * (CHART_WIDTH - CHART_PADDING * 2)) / Math.max(oldestFirst.length - 1, 1); - const y = CHART_PADDING + (1 - (record.weightKg - min) / range) * (CHART_HEIGHT - CHART_PADDING * 2); + const y = CHART_PADDING + (1 - (record.weight - min) / range) * (CHART_HEIGHT - CHART_PADDING * 2); return { x, y, record }; }); @@ -34,13 +51,13 @@ function WeightChart({ records }: { records: WeightRecord[] }) { strokeWidth={2} /> {points.map((p) => ( - + ))} {oldestFirst.map((r) => ( - - {r.measuredAt.slice(5).replace('. ', '.')} + + {r.petWeightsMeasuredAt?.slice(5, 10).replace('-', '.')} ))} @@ -51,137 +68,232 @@ function WeightChart({ records }: { records: WeightRecord[] }) { export default function PetWeightScreen() { const { petId } = useLocalSearchParams<{ petId: string }>(); const router = useRouter(); - const pet = MOCK_PETS.find((p) => p.id === petId) ?? MOCK_PETS[0]; - const [records, setRecords] = useState(MOCK_WEIGHT_RECORDS[pet.id] ?? []); + const queryClient = useQueryClient(); + + const petQuery = useQuery({ queryKey: ['pets', petId], queryFn: () => getPetDetail(petId), enabled: !!petId }); + const weightQuery = useQuery({ + queryKey: ['pets', petId, 'weight'], + queryFn: () => getWeightHistory(petId, 0, 15), + enabled: !!petId, + }); + const [date, setDate] = useState(''); const [weight, setWeight] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editingDate, setEditingDate] = useState(''); + const [editingWeight, setEditingWeight] = useState(''); + const [datePickerVisible, setDatePickerVisible] = useState(false); + const [editDatePickerVisible, setEditDatePickerVisible] = useState(false); - const latest = records[0]; - const oldest = records[records.length - 1]; - const lowest = useMemo(() => (records.length ? Math.min(...records.map((r) => r.weightKg)) : null), [records]); - const change = latest && oldest ? Math.round((latest.weightKg - oldest.weightKg) * 10) / 10 : null; + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['pets', petId, 'weight'] }); - const handleAdd = () => { - if (!date.trim() || !weight.trim()) return; - const weightKg = Number(weight); - if (Number.isNaN(weightKg)) return; + const addMutation = useMutation({ + mutationFn: () => createWeightRecord(petId, Number(weight), `${date.trim()}T00:00:00`), + onSuccess: () => { + setDate(''); + setWeight(''); + invalidate(); + }, + onError: () => Alert.alert('오류', '체중 기록을 추가하지 못했어요.'), + }); - setRecords((prev) => [{ id: `w-${Date.now()}`, weightKg, measuredAt: date.trim() }, ...prev]); - setDate(''); - setWeight(''); - }; + const updateMutation = useMutation({ + mutationFn: (weightId: number) => + updateWeightRecord(petId, weightId, { + weight: editingWeight ? Number(editingWeight) : undefined, + petWeightsMeasuredAt: editingDate ? `${editingDate.trim()}T00:00:00` : undefined, + }), + onSuccess: () => { + setEditingId(null); + invalidate(); + }, + onError: () => Alert.alert('오류', '체중 기록을 수정하지 못했어요.'), + }); - const handleDelete = (id: string) => { - setRecords((prev) => prev.filter((r) => r.id !== id)); - }; + const deleteMutation = useMutation({ + mutationFn: (weightId: number) => deleteWeightRecord(petId, weightId), + onSuccess: invalidate, + onError: () => Alert.alert('오류', '체중 기록을 삭제하지 못했어요.'), + }); - const handleEdit = () => { - Alert.alert('수정', '체중 기록 수정 기능은 준비 중이에요.'); + const records = useMemo(() => weightQuery.data?.records ?? [], [weightQuery.data]); + const latest = records[0]; + const oldest = records[records.length - 1]; + const lowest = useMemo(() => (records.length ? Math.min(...records.map((r) => r.weight)) : null), [records]); + const change = latest && oldest ? Math.round((latest.weight - oldest.weight) * 10) / 10 : null; + + const startEdit = (record: WeightRecord) => { + setEditingId(record.weightId); + setEditingDate(record.petWeightsMeasuredAt?.slice(0, 10) ?? ''); + setEditingWeight(String(record.weight)); }; return ( - - - - PET WEIGHT - 체중 관리 - - router.back()}> - 상세정보 보기 - - - - - - {pet.name} - - 총 {records.length}개 + <> + + + + PET WEIGHT + 체중 관리 + router.back()}> + 상세정보 보기 + - {latest ? ( - - 최근 체중은 {latest.weightKg}kg이며, 최근 측정일은 {latest.measuredAt}입니다. - - ) : ( - 등록된 체중 기록이 없어요. - )} - - {records.length > 0 && ( - WEIGHT TREND - 체중 추이 그래프 - - - 최신 체중 - {latest.weightKg}kg - - - 최저 체중 - {lowest}kg - - - 변화량 - 0 && styles.statValuePositive]}> - {change != null && change > 0 ? '+' : ''} - {change}kg - + + {petQuery.data?.petName ?? ''} + + 총 {records.length}개 - + {weightQuery.isLoading ? ( + + ) : latest ? ( + + 최근 체중은 {latest.weight}kg이며, 최근 측정일은 {latest.petWeightsMeasuredAt?.slice(0, 10)}입니다. + + ) : ( + 등록된 체중 기록이 없어요. + )} - )} - - WEIGHT RECORDS - 체중 기록 관리 - 기록을 추가하고, 잘못 입력한 값은 바로 수정하거나 삭제할 수 있어요. + {records.length > 0 && ( + + WEIGHT TREND + 체중 추이 그래프 + + + 최신 체중 + {latest.weight}kg + + + 최저 체중 + {lowest}kg + + + 변화량 + 0 && styles.statValuePositive]}> + {change != null && change > 0 ? '+' : ''} + {change}kg + + + + + + )} - - - - - 추가 + + WEIGHT RECORDS + 체중 기록 관리 + 기록을 추가하고, 잘못 입력한 값은 바로 수정하거나 삭제할 수 있어요. + + setDatePickerVisible(true)}> + {date || '측정 날짜 선택'} - + + + addMutation.mutate()} + > + 추가 + + - {records.map((record, index) => ( - - - {record.weightKg}kg - 측정일 {record.measuredAt} - - - - 수정 - - handleDelete(record.id)} + {records.map((record, index) => { + const isEditing = editingId === record.weightId; + return ( + - 삭제 - - - - ))} - - + {isEditing ? ( + <> + + setEditDatePickerVisible(true)}> + + {editingDate || '날짜 선택'} + + + + + + updateMutation.mutate(record.weightId)} + > + 저장 + + setEditingId(null)}> + 취소 + + + + ) : ( + <> + + {record.weight}kg + 측정일 {record.petWeightsMeasuredAt?.slice(0, 10)} + + + startEdit(record)}> + 수정 + + deleteMutation.mutate(record.weightId)} + > + 삭제 + + + + )} + + ); + })} + + + + setDatePickerVisible(false)} + onConfirm={setDate} + /> + setEditDatePickerVisible(false)} + onConfirm={setEditingDate} + /> + ); } +function todayString() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +} + const styles = StyleSheet.create({ container: { flex: 1, @@ -306,10 +418,19 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, fontSize: 12, color: DodoColors.textPrimary, + justifyContent: 'center', }, weightInput: { flex: 1, }, + dateValue: { + fontSize: 12, + color: DodoColors.textPrimary, + }, + datePlaceholder: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, addButton: { paddingHorizontal: 16, height: 40, @@ -344,6 +465,22 @@ const styles = StyleSheet.create({ color: DodoColors.fenceIdleLabel, marginTop: 2, }, + editFields: { + flex: 1, + gap: 6, + marginRight: 8, + }, + editInput: { + height: 36, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 10, + fontSize: 12, + color: DodoColors.textPrimary, + justifyContent: 'center', + }, recordActions: { flexDirection: 'row', gap: 6, diff --git a/app/(tabs)/mypage/pets/_mock.ts b/app/(tabs)/mypage/pets/_mock.ts deleted file mode 100644 index cc86b2b..0000000 --- a/app/(tabs)/mypage/pets/_mock.ts +++ /dev/null @@ -1,109 +0,0 @@ -// TODO(이슈4): GET /pets/list, GET /pets/{petId} 연동 후 mock 제거 -export type Pet = { - id: string; - name: string; - gender: 'F' | 'M' | null; - birthDate: string; - ageLabel: string; - species: string; - breed: string; - deviceId: string; - baselineHeartRate: number; - registrationNumber: string | null; -}; - -export const MOCK_PETS: Pet[] = [ - { - id: 'sundubu', - name: '순두부', - gender: 'F', - birthDate: '2024. 12. 10', - ageLabel: '만 1세', - species: '강아지', - breed: '말티즈', - deviceId: 'ABC123678', - baselineHeartRate: 87, - registrationNumber: null, - }, - { - id: 'godeungeo', - name: '고등어', - gender: 'M', - birthDate: '2025. 01. 07', - ageLabel: '만 1세', - species: '고양이', - breed: '코리안숏헤어', - deviceId: 'DEF456789', - baselineHeartRate: 92, - registrationNumber: null, - }, - { - id: 'maltese', - name: '말티티', - gender: 'F', - birthDate: '2026. 06. 02', - ageLabel: '만 0세', - species: '강아지', - breed: '말티즈', - deviceId: 'GHI789012', - baselineHeartRate: 90, - registrationNumber: null, - }, - { - id: 'pold', - name: '폴드', - gender: null, - birthDate: '2024. 06. 04', - ageLabel: '만 2세', - species: '고양이', - breed: '스코티시폴드', - deviceId: 'JKL012345', - baselineHeartRate: 95, - registrationNumber: null, - }, - { - id: 'puchi', - name: '푸치', - gender: 'M', - birthDate: '2026. 06. 01', - ageLabel: '만 0세', - species: '강아지', - breed: '말티즈', - deviceId: 'MNO345678', - baselineHeartRate: 88, - registrationNumber: null, - }, -]; - -export type WeightRecord = { - id: string; - weightKg: number; - measuredAt: string; -}; - -export const MOCK_WEIGHT_RECORDS: Record = { - sundubu: [ - { id: 'w1', weightKg: 5.6, measuredAt: '2026. 05. 08' }, - { id: 'w2', weightKg: 5.4, measuredAt: '2026. 02. 08' }, - { id: 'w3', weightKg: 5.2, measuredAt: '2026. 01. 08' }, - ], -}; - -export const NOTE_CATEGORIES = ['알레르기', '병원', '약물', '음식', '행동', '증상', '기타'] as const; -export type NoteCategory = (typeof NOTE_CATEGORIES)[number]; - -export type SignificantNote = { - id: string; - tag: NoteCategory; - date: string; - content: string; -}; - -export const MOCK_NOTES: Record = { - sundubu: [ - { id: 'n1', tag: '약물', date: '2026-07-08', content: '감기약 복용' }, - { id: 'n2', tag: '증상', date: '2026-07-08', content: '구토를 안함' }, - { id: 'n3', tag: '음식', date: '2026-07-08', content: '두부를 못 먹음' }, - { id: 'n4', tag: '알레르기', date: '2026-07-08', content: '생선 알레르기 있음' }, - ], -}; diff --git a/app/(tabs)/mypage/pets/index.tsx b/app/(tabs)/mypage/pets/index.tsx index 5bf5823..1fb8343 100644 --- a/app/(tabs)/mypage/pets/index.tsx +++ b/app/(tabs)/mypage/pets/index.tsx @@ -1,20 +1,35 @@ import Ionicons from '@expo/vector-icons/Ionicons'; +import { useQuery } from '@tanstack/react-query'; import { Link } from 'expo-router'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; +import { getPetsList, type PetSex } from '@/shared/api/petApi'; -import { MOCK_PETS, type Pet } from './_mock'; - -function GenderIcon({ gender }: { gender: Pet['gender'] }) { - if (!gender) return null; +function GenderIcon({ sex }: { sex: PetSex }) { + if (sex === 'NEUTER') return null; return ( - + ); } +function speciesLabel(species: 'CANINE' | 'FELINE') { + return species === 'CANINE' ? '강아지' : '고양이'; +} + export default function PetListScreen() { - if (MOCK_PETS.length === 0) { + const petsQuery = useQuery({ queryKey: ['pets', 'list'], queryFn: () => getPetsList(0, 10) }); + const pets = petsQuery.data?.pets ?? []; + + if (petsQuery.isLoading) { + return ( + + + + ); + } + + if (pets.length === 0) { return ( @@ -37,7 +52,7 @@ export default function PetListScreen() { - 총 {MOCK_PETS.length}마리 + 총 {pets.length}마리 @@ -47,30 +62,31 @@ export default function PetListScreen() { - {MOCK_PETS.map((pet) => ( - + {pets.map((pet) => ( + {pet.name} - + + 만 {pet.age}세 - {pet.birthDate} ({pet.ageLabel}) - - - {pet.species} {pet.breed} + {speciesLabel(pet.species)} {pet.breed} - + 상세 정보 - + 체중 관리 diff --git a/app/(tabs)/mypage/pets/new.tsx b/app/(tabs)/mypage/pets/new.tsx index 2a79b06..a242bc3 100644 --- a/app/(tabs)/mypage/pets/new.tsx +++ b/app/(tabs)/mypage/pets/new.tsx @@ -1,13 +1,24 @@ -import Ionicons from '@expo/vector-icons/Ionicons'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; import { useMemo, useState } from 'react'; -import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { AvatarImagePicker } from '@/components/common/avatar-image-picker'; +import { DatePickerModal } from '@/components/common/date-picker-modal'; import { DodoColors } from '@/constants/theme'; +import { createPet } from '@/shared/api/petApi'; + +function todayString() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +} type Species = '강아지' | '고양이'; type Gender = '수컷' | '암컷'; +const SPECIES_MAP: Record = { 강아지: 'CANINE', 고양이: 'FELINE' }; +const GENDER_MAP: Record = { 수컷: 'MALE', 암컷: 'FEMALE' }; + function calculateAgeLabel(birthDate: string): string | null { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(birthDate.trim()); if (!match) return null; @@ -52,14 +63,17 @@ function ChipSelect({ export default function NewPetScreen() { const router = useRouter(); + const queryClient = useQueryClient(); const [name, setName] = useState(''); const [breed, setBreed] = useState(''); const [species, setSpecies] = useState(null); const [gender, setGender] = useState(null); const [birthDate, setBirthDate] = useState(''); + const [datePickerVisible, setDatePickerVisible] = useState(false); const [registrationNumber, setRegistrationNumber] = useState(''); const [deviceId, setDeviceId] = useState(''); const [baselineHeartRate, setBaselineHeartRate] = useState(''); + const [imageUrl, setImageUrl] = useState(null); const ageLabel = useMemo(() => calculateAgeLabel(birthDate), [birthDate]); @@ -72,6 +86,30 @@ export default function NewPetScreen() { deviceId.trim().length > 0 && baselineHeartRate.trim().length > 0; + const createMutation = useMutation({ + mutationFn: () => { + const age = Number(ageLabel?.match(/\d+/)?.[0] ?? 0); + return createPet({ + imageUrl: imageUrl ?? '', + imageFileUrl: imageUrl ?? '', + petName: name.trim(), + species: SPECIES_MAP[species as Species], + sex: GENDER_MAP[gender as Gender], + breed: breed.trim(), + birth: `${birthDate.trim()}T00:00:00`, + age, + registrationNumber: registrationNumber.trim() || undefined, + referenceHeartRate: Number(baselineHeartRate), + deviceId: deviceId.trim(), + }); + }, + onSuccess: ({ petId }) => { + queryClient.invalidateQueries({ queryKey: ['pets', 'list'] }); + router.replace({ pathname: '/(tabs)/mypage/pets/[petId]', params: { petId: String(petId) } }); + }, + onError: () => Alert.alert('오류', '반려동물을 등록하지 못했어요. 잠시 후 다시 시도해주세요.'), + }); + return ( @@ -83,20 +121,7 @@ export default function NewPetScreen() { - - - - - - JPG / PNG - 최대 10MB - - 사진이나 버튼을 눌러 반려동물 프로필 이미지를 등록할 수 있어요. - {/* TODO(이슈7): expo-image-picker 설치 후 실제 첨부 연동 */} - - 이미지 등록 - - + @@ -140,13 +165,11 @@ export default function NewPetScreen() { 생년월일 * - + setDatePickerVisible(true)}> + + {birthDate || '날짜 선택'} + + 만 나이 @@ -207,12 +230,21 @@ export default function NewPetScreen() { 취소 createMutation.mutate()} > - 등록하기 + {createMutation.isPending ? '등록 중...' : '등록하기'} + + setDatePickerVisible(false)} + onConfirm={setBirthDate} + /> ); } diff --git a/app/(tabs)/mypage/profile-edit.tsx b/app/(tabs)/mypage/profile-edit.tsx index 61c9e38..986d0ac 100644 --- a/app/(tabs)/mypage/profile-edit.tsx +++ b/app/(tabs)/mypage/profile-edit.tsx @@ -1,32 +1,74 @@ -import Ionicons from '@expo/vector-icons/Ionicons'; -import { useState } from 'react'; -import { Alert, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { RegionSelectModal } from '@/components/auth/region-select-modal'; +import { AvatarImagePicker } from '@/components/common/avatar-image-picker'; import { DodoColors } from '@/constants/theme'; - -// TODO(이슈4): GET /users/me, PATCH /users/me, PUT /users/me/profile, DELETE /users/me 연동 후 mock 제거 -const MOCK_ACCOUNT = { - email: 'csb7543@naver.com', - name: '조수빈', - nickname: '조펭이', - region: '서울특별시 서대문구', -}; +import { getMe, updateMe } from '@/shared/api/userApi'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; export default function ProfileEditScreen() { + const router = useRouter(); + const queryClient = useQueryClient(); + const updateProfile = useAuthStore((state) => state.updateProfile); const [nickname, setNickname] = useState(''); const [region, setRegion] = useState(''); + const [regionModalVisible, setRegionModalVisible] = useState(false); + const [profileUrl, setProfileUrl] = useState(null); - const handleSave = () => { - Alert.alert('저장 완료', '변경사항이 저장됐어요.'); - }; + const meQuery = useQuery({ queryKey: ['users', 'me'], queryFn: getMe }); + + useEffect(() => { + if (meQuery.data) setProfileUrl(meQuery.data.profileUrl); + }, [meQuery.data]); + + const saveMutation = useMutation({ + mutationFn: () => { + if (!meQuery.data) throw new Error('not loaded'); + return updateMe({ + nickname: nickname.trim() || meQuery.data.nickname, + region: region.trim() || meQuery.data.region, + hasFamily: meQuery.data.hasFamily, + profileUrl, + }); + }, + onSuccess: () => { + updateProfile({ + nickname: nickname.trim() || meQuery.data?.nickname, + region: region.trim() || meQuery.data?.region, + profileUrl, + }); + queryClient.invalidateQueries({ queryKey: ['users', 'me'] }); + Alert.alert('저장 완료', '변경사항이 저장됐어요.'); + setNickname(''); + setRegion(''); + }, + onError: () => Alert.alert('오류', '저장에 실패했어요. 잠시 후 다시 시도해주세요.'), + }); const handleWithdrawal = () => { - Alert.alert('회원 탈퇴', '탈퇴하면 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요. 계속하시겠어요?', [ - { text: '취소', style: 'cancel' }, - { text: '탈퇴하기', style: 'destructive' }, - ]); + router.push('/(tabs)/mypage/withdrawal'); }; + if (meQuery.isLoading || !meQuery.data) { + return ( + + + + ); + } + return ( ACCOUNT @@ -34,20 +76,7 @@ export default function ProfileEditScreen() { - - - - - - 최대 10MB - - 사진을 선택해 프로필 이미지를 변경할 수 있어요. - 변경한 이미지는 저장 후 바로 반영돼요. - {/* TODO(이슈7): expo-image-picker 설치 후 실제 첨부 연동 */} - - 이미지 변경 - - + @@ -61,13 +90,13 @@ export default function ProfileEditScreen() { 이메일 - {MOCK_ACCOUNT.email} + {meQuery.data.email} 이름 - {MOCK_ACCOUNT.name} + {meQuery.data.name} @@ -77,7 +106,7 @@ export default function ProfileEditScreen() { 닉네임 * 활동 지역 * - - 검색해서 활동 지역을 선택해주세요. + setRegionModalVisible(true)}> + {region || meQuery.data.region} + + 눌러서 활동 지역을 선택해주세요. + setRegionModalVisible(false)} + onConfirm={setRegion} + /> + * 표시된 항목은 필수 입력값입니다. - - 변경사항 저장 + saveMutation.mutate()} + > + {saveMutation.isPending ? '저장 중...' : '변경사항 저장'} @@ -120,6 +156,10 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: DodoColors.background, }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + }, content: { padding: 16, paddingBottom: 32, diff --git a/app/(tabs)/mypage/walk-log.tsx b/app/(tabs)/mypage/walk-log.tsx deleted file mode 100644 index 79e767a..0000000 --- a/app/(tabs)/mypage/walk-log.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { StyleSheet, Text, View } from 'react-native'; - -import { DodoColors } from '@/constants/theme'; - -export default function WalkLogScreen() { - return ( - - 산책 기록 화면 준비 중 - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: DodoColors.background }, - text: { color: DodoColors.textSecondary, fontSize: 15 }, -}); diff --git a/app/(tabs)/mypage/withdrawal.tsx b/app/(tabs)/mypage/withdrawal.tsx new file mode 100644 index 0000000..1a0b439 --- /dev/null +++ b/app/(tabs)/mypage/withdrawal.tsx @@ -0,0 +1,214 @@ +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { useEffect, useRef, useState } from 'react'; +import { Alert, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; +import { sendWithdrawalEmail, withdrawUser } from '@/shared/api/userApi'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; + +const COOLDOWN_SECONDS = 60; + +export default function WithdrawalScreen() { + const router = useRouter(); + const clearSession = useAuthStore((state) => state.clearSession); + const [step, setStep] = useState<1 | 2>(1); + const [authCode, setAuthCode] = useState(''); + const [cooldown, setCooldown] = useState(0); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, []); + + const startCooldown = () => { + setCooldown(COOLDOWN_SECONDS); + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setCooldown((c) => { + if (c <= 1) { + if (timerRef.current) clearInterval(timerRef.current); + return 0; + } + return c - 1; + }); + }, 1000); + }; + + const sendEmailMutation = useMutation({ + mutationFn: sendWithdrawalEmail, + onSuccess: () => { + setStep(2); + startCooldown(); + Alert.alert('인증 메일 발송', '입력하신 이메일로 인증 코드를 보냈어요.'); + }, + onError: (error: unknown) => { + const status = (error as { response?: { status?: number } }).response?.status; + if (status === 429) { + Alert.alert('잠시만요', '1분 내 재요청은 제한돼요. 잠시 후 다시 시도해주세요.'); + startCooldown(); + return; + } + Alert.alert('오류', '인증 메일 발송에 실패했어요.'); + }, + }); + + const withdrawMutation = useMutation({ + mutationFn: () => withdrawUser(authCode.trim()), + onSuccess: async () => { + await clearSession(); + router.replace('/(tabs)'); + }, + onError: (error: unknown) => { + const status = (error as { response?: { status?: number } }).response?.status; + if (status === 400 || status === 401) { + Alert.alert('인증 실패', '인증번호가 틀렸습니다.'); + return; + } + Alert.alert('오류', '회원 탈퇴에 실패했어요.'); + }, + }); + + const confirmWithdraw = () => { + if (authCode.trim().length !== 6) return; + Alert.alert('회원 탈퇴', '탈퇴하면 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요. 계속하시겠어요?', [ + { text: '취소', style: 'cancel' }, + { text: '탈퇴하기', style: 'destructive', onPress: () => withdrawMutation.mutate() }, + ]); + }; + + return ( + + ACCOUNT + 회원 탈퇴 + 탈퇴하면 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요. + + + STEP 1 · 이메일 인증 + 가입한 이메일로 인증 코드를 보내드려요. + 0 || sendEmailMutation.isPending) && styles.primaryButtonDisabled]} + disabled={cooldown > 0 || sendEmailMutation.isPending} + onPress={() => sendEmailMutation.mutate()} + > + + {cooldown > 0 + ? `${cooldown}초 후 재발송 가능` + : sendEmailMutation.isPending + ? '발송 중...' + : '인증 메일 발송'} + + + + + {step === 2 && ( + + STEP 2 · 인증 코드 입력 + 메일로 받은 6자리 코드를 입력해주세요. + + + {withdrawMutation.isPending ? '처리 중...' : '회원 탈퇴하기'} + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + padding: 16, + gap: 16, + }, + eyebrow: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.brand, + letterSpacing: 0.5, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + subtitle: { + fontSize: 12, + color: DodoColors.textSecondary, + marginBottom: 4, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 10, + }, + stepLabel: { + fontSize: 11, + fontWeight: '700', + color: DodoColors.fenceIdleLabel, + letterSpacing: 0.3, + }, + stepHint: { + fontSize: 12, + color: DodoColors.textSecondary, + marginTop: -4, + }, + input: { + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + fontSize: 14, + color: DodoColors.textPrimary, + }, + primaryButton: { + height: 44, + borderRadius: 10, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonDisabled: { + opacity: 0.4, + }, + primaryButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, + dangerButton: { + height: 44, + borderRadius: 10, + backgroundColor: DodoColors.fenceOutside, + alignItems: 'center', + justifyContent: 'center', + }, + dangerButtonText: { + fontSize: 13, + fontWeight: '700', + color: '#ffffff', + }, +}); From 0b1ac9d1e716b7e9da5a1235770d99affebaaae6 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 13:16:15 +0900 Subject: [PATCH 14/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=ED=99=94=EB=A9=B4=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 피드/상세/작성/수정/삭제, 댓글 CRUD, 좋아요·싫어요 반응 연동 - 게시글 이미지 여러 장 첨부, 임시저장 및 재진입 시 불러오기 연동 - 내 활동(내 게시글/내 댓글) 연동 --- app/(tabs)/community/[boardId].tsx | 367 +++++++++++++++++++----- app/(tabs)/community/[boardId]/edit.tsx | 216 ++++++++++++++ app/(tabs)/community/_layout.tsx | 11 + app/(tabs)/community/index.tsx | 146 +++++----- app/(tabs)/community/my.tsx | 148 +++++++--- app/(tabs)/community/new.tsx | 102 ++++--- 6 files changed, 771 insertions(+), 219 deletions(-) create mode 100644 app/(tabs)/community/[boardId]/edit.tsx diff --git a/app/(tabs)/community/[boardId].tsx b/app/(tabs)/community/[boardId].tsx index ca2c10b..cd87f4e 100644 --- a/app/(tabs)/community/[boardId].tsx +++ b/app/(tabs)/community/[boardId].tsx @@ -1,7 +1,10 @@ import Ionicons from '@expo/vector-icons/Ionicons'; -import { useLocalSearchParams } from 'expo-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { useState } from 'react'; import { + ActivityIndicator, + Alert, KeyboardAvoidingView, Platform, ScrollView, @@ -13,57 +16,117 @@ import { } from 'react-native'; import { DodoColors } from '@/constants/theme'; - -// TODO(이슈4): GET /boards/{boardId}, GET /comments/{boardId} 연동 후 mock 제거 -const MOCK_POSTS_BY_ID: Record< - string, - { - author: string; - date: string; - viewCount: number; - title: string; - body: string; - likeCount: number; - dislikeCount: number; - isOwner: boolean; - } -> = { - '1': { - author: '조펭이', - date: '2026. 06. 12. 오후 07:33', - viewCount: 5, - title: '안녕', - body: 'ㅎㅇ', - likeCount: 4, - dislikeCount: 2, - isOwner: true, - }, - '2': { - author: '조펭이', - date: '2026. 06. 12. 오후 07:40', - viewCount: 2, - title: '마루는 강쥐', - body: '귀여움', - likeCount: 3, - dislikeCount: 0, - isOwner: true, - }, - '3': { - author: '꾸우', - date: '2026. 06. 15. 오후 03:12', - viewCount: 15, - title: '안녕하세요', - body: '저는 야르릉입니다!!!', - likeCount: 2, - dislikeCount: 0, - isOwner: false, - }, -}; +import { + createBoardReaction, + createComment, + deleteBoard, + deleteBoardReaction, + deleteComment, + getBoardDetail, + getCommentNickname, + getComments, + getCommentTimestamp, + getMyReaction, + updateBoardReaction, + type ReactionType, +} from '@/shared/api/communityApi'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; +import { formatFullDateTime } from '@/shared/lib/format/date'; export default function BoardDetailScreen() { const { boardId } = useLocalSearchParams<{ boardId: string }>(); + const router = useRouter(); + const queryClient = useQueryClient(); + const myNickname = useAuthStore((state) => state.profile.nickname); const [comment, setComment] = useState(''); - const post = MOCK_POSTS_BY_ID[boardId] ?? MOCK_POSTS_BY_ID['1']; + const [commentsPage, setCommentsPage] = useState(0); + + const boardQuery = useQuery({ + queryKey: ['board', boardId], + queryFn: () => getBoardDetail(boardId), + enabled: !!boardId, + }); + + const commentsQuery = useQuery({ + queryKey: ['comments', boardId, commentsPage], + queryFn: () => getComments(boardId, commentsPage, 20), + enabled: !!boardId, + }); + + const invalidateBoard = () => queryClient.invalidateQueries({ queryKey: ['board', boardId] }); + const invalidateComments = () => queryClient.invalidateQueries({ queryKey: ['comments', boardId] }); + + const reactMutation = useMutation({ + mutationFn: async (reactionType: ReactionType) => { + const current = boardQuery.data ? getMyReaction(boardQuery.data) : null; + if (current === reactionType) { + await deleteBoardReaction(boardId); + } else if (current) { + await updateBoardReaction(boardId, reactionType); + } else { + await createBoardReaction(Number(boardId), reactionType); + } + }, + onSuccess: invalidateBoard, + onError: () => { + Alert.alert('오류', '반응을 처리하지 못했어요.'); + invalidateBoard(); + }, + }); + + const commentMutation = useMutation({ + mutationFn: (content: string) => createComment({ boardId: Number(boardId), commentContent: content }), + onSuccess: () => { + setComment(''); + invalidateComments(); + invalidateBoard(); + }, + onError: () => Alert.alert('오류', '댓글을 등록하지 못했어요.'), + }); + + const deleteCommentMutation = useMutation({ + mutationFn: (commentId: number) => deleteComment(commentId), + onSuccess: () => { + invalidateComments(); + invalidateBoard(); + }, + onError: () => Alert.alert('오류', '댓글을 삭제하지 못했어요.'), + }); + + const deleteBoardMutation = useMutation({ + mutationFn: () => deleteBoard(boardId), + onSuccess: () => router.replace('/(tabs)/community'), + onError: () => Alert.alert('오류', '게시글을 삭제하지 못했어요.'), + }); + + const handleDeleteBoard = () => { + Alert.alert('게시글 삭제', '이 게시글을 삭제할까요?', [ + { text: '취소', style: 'cancel' }, + { text: '삭제하기', style: 'destructive', onPress: () => deleteBoardMutation.mutate() }, + ]); + }; + + const handleDeleteComment = (commentId: number) => { + Alert.alert('댓글 삭제', '이 댓글을 삭제할까요?', [ + { text: '취소', style: 'cancel' }, + { text: '삭제', style: 'destructive', onPress: () => deleteCommentMutation.mutate(commentId) }, + ]); + }; + + if (boardQuery.isLoading || !boardQuery.data) { + return ( + + + + ); + } + + const post = boardQuery.data; + // 웹과 동일: 작성자 여부는 서버 플래그가 아니라 닉네임 문자열 비교로 클라이언트에서만 판단 + const isOwner = !!myNickname && post.nickname === myNickname; + const myReaction = getMyReaction(post); + const comments = commentsQuery.data?.data ?? []; + const pageInfo = commentsQuery.data?.pageInfo; return ( @@ -78,9 +141,9 @@ export default function BoardDetailScreen() { - {post.author} + {post.nickname} - {post.date} + {formatFullDateTime(post.boardCreatedAt)} | 조회 {post.viewCount} @@ -88,12 +151,14 @@ export default function BoardDetailScreen() { - {post.isOwner && ( + {isOwner && ( - + router.push({ pathname: '/(tabs)/community/[boardId]/edit', params: { boardId } })} + > 수정 - + 삭제 @@ -101,31 +166,100 @@ export default function BoardDetailScreen() { - {post.title} + {post.boardTitle} - - - + {post.imageFileUrls.length === 0 && ( + + + + )} - {post.body} + {post.boardContent} - + reactMutation.mutate('LIKE')} + > 좋아요 {post.likeCount} - + reactMutation.mutate('DISLIKE')} + > 싫어요 {post.dislikeCount} - 댓글 0 - - 아직 댓글이 없어요. 첫 댓글을 남겨보세요. - + 댓글 {pageInfo?.totalElements ?? comments.length} + {commentsQuery.isLoading ? ( + + ) : comments.length === 0 ? ( + + 아직 댓글이 없어요. 첫 댓글을 남겨보세요. + + ) : ( + comments + .filter((c) => c.parentCommentId === null) + .map((parent) => ( + + handleDeleteComment(parent.commentId)} + /> + {comments + .filter((c) => c.parentCommentId === parent.commentId) + .map((reply) => ( + + handleDeleteComment(reply.commentId)} + /> + + ))} + + )) + )} + + {pageInfo && pageInfo.totalPages > 1 && ( + + setCommentsPage((p) => Math.max(0, p - 1))} + > + 이전 + + + {commentsPage + 1} / {pageInfo.totalPages} + + = pageInfo.totalPages} + onPress={() => setCommentsPage((p) => p + 1)} + > + = pageInfo.totalPages ? styles.pageButtonTextDisabled : styles.pageButtonText + } + > + 다음 + + + + )} @@ -136,7 +270,11 @@ export default function BoardDetailScreen() { value={comment} onChangeText={setComment} /> - + commentMutation.mutate(comment.trim())} + > 등록 @@ -145,11 +283,44 @@ export default function BoardDetailScreen() { ); } +function CommentRow({ + nickname, + content, + date, + canDelete, + onDelete, +}: { + nickname: string; + content: string; + date: string; + canDelete: boolean; + onDelete: () => void; +}) { + return ( + + + {nickname} + {content} + {date} + + {canDelete && ( + + 삭제 + + )} + + ); +} + const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: DodoColors.background, }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + }, flex: { flex: 1, }, @@ -240,6 +411,10 @@ const styles = StyleSheet.create({ borderColor: DodoColors.border, backgroundColor: DodoColors.surface, }, + reactionButtonActive: { + borderColor: DodoColors.brand, + backgroundColor: DodoColors.background, + }, reactionText: { fontSize: 13, fontWeight: '600', @@ -259,6 +434,68 @@ const styles = StyleSheet.create({ fontSize: 13, color: DodoColors.fenceIdleLabel, }, + commentBlock: { + gap: 8, + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: DodoColors.background, + }, + replyRow: { + paddingLeft: 20, + }, + commentRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 8, + }, + commentTextCol: { + flex: 1, + gap: 2, + }, + commentAuthor: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + commentContent: { + fontSize: 13, + color: DodoColors.textPrimary, + }, + commentDate: { + fontSize: 10, + color: DodoColors.fenceIdleLabel, + }, + commentDeleteText: { + fontSize: 11, + color: DodoColors.fenceOutside, + }, + pagination: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + marginTop: 12, + }, + pageButton: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + pageButtonText: { + fontSize: 12, + color: DodoColors.textPrimary, + }, + pageButtonTextDisabled: { + fontSize: 12, + color: DodoColors.border, + }, + pageIndicator: { + fontSize: 12, + color: DodoColors.textSecondary, + }, commentInputRow: { flexDirection: 'row', alignItems: 'center', diff --git a/app/(tabs)/community/[boardId]/edit.tsx b/app/(tabs)/community/[boardId]/edit.tsx new file mode 100644 index 0000000..f9e0d2b --- /dev/null +++ b/app/(tabs)/community/[boardId]/edit.tsx @@ -0,0 +1,216 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { MultiImagePicker } from '@/components/common/multi-image-picker'; +import { DodoColors } from '@/constants/theme'; +import { getBoardDetail, tempSaveBoard, updateBoard } from '@/shared/api/communityApi'; + +export default function EditPostScreen() { + const { boardId } = useLocalSearchParams<{ boardId: string }>(); + const router = useRouter(); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [imageUrls, setImageUrls] = useState([]); + + const boardQuery = useQuery({ + queryKey: ['board', boardId], + queryFn: () => getBoardDetail(boardId), + enabled: !!boardId, + }); + + useEffect(() => { + if (boardQuery.data) { + setTitle(boardQuery.data.boardTitle); + setContent(boardQuery.data.boardContent); + setImageUrls(boardQuery.data.imageFileUrls); + } + }, [boardQuery.data]); + + const canSubmit = title.trim().length > 0 && content.trim().length > 0; + + const submitMutation = useMutation({ + mutationFn: () => + updateBoard(boardId, { + boardTitle: title.trim(), + boardContent: content.trim(), + imageFileUrls: imageUrls, + }), + onSuccess: () => { + router.replace({ pathname: '/(tabs)/community/[boardId]', params: { boardId } }); + }, + onError: () => Alert.alert('오류', '게시글을 수정하지 못했어요. 잠시 후 다시 시도해주세요.'), + }); + + const tempSaveMutation = useMutation({ + mutationFn: () => + tempSaveBoard({ boardTitle: title.trim(), boardContent: content.trim(), imageFileUrls: imageUrls }, boardId), + onSuccess: () => Alert.alert('임시 저장', '수정 중인 내용을 임시 저장했어요.'), + onError: () => Alert.alert('오류', '임시 저장에 실패했어요.'), + }); + + if (boardQuery.isLoading || !boardQuery.data) { + return ( + + + + ); + } + + return ( + + + + 게시글 제목 * + + + + + 게시글 내용 * + + + + + 이미지 첨부 + + + + * 필수 입력 항목입니다. + + + + router.back()}> + 취소 + + tempSaveMutation.mutate()} + > + {tempSaveMutation.isPending ? '저장 중...' : '임시 저장'} + + submitMutation.mutate()} + > + {submitMutation.isPending ? '수정 중...' : '수정하기'} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 20, + }, + field: { + gap: 8, + }, + label: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + input: { + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.surface, + paddingHorizontal: 12, + fontSize: 14, + color: DodoColors.textPrimary, + }, + textarea: { + height: 160, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.surface, + padding: 12, + fontSize: 14, + color: DodoColors.textPrimary, + }, + requiredHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + actionBar: { + flexDirection: 'row', + gap: 8, + padding: 12, + borderTopWidth: 1, + borderTopColor: DodoColors.border, + backgroundColor: DodoColors.surface, + }, + secondaryButton: { + flex: 1, + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + primaryButton: { + flex: 1.4, + height: 44, + borderRadius: 10, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonDisabled: { + opacity: 0.4, + }, + primaryButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, +}); diff --git a/app/(tabs)/community/_layout.tsx b/app/(tabs)/community/_layout.tsx index fe28a41..ee52042 100644 --- a/app/(tabs)/community/_layout.tsx +++ b/app/(tabs)/community/_layout.tsx @@ -33,6 +33,17 @@ export default function CommunityStackLayout() { headerLeft: () => , }} /> + , + }} + /> getBoards({ page: pageParam, size: PAGE_SIZE }), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => (lastPage.boards.length >= PAGE_SIZE ? allPages.length : undefined), + }); + + const boards = useMemo(() => boardsQuery.data?.pages.flatMap((p) => p.boards) ?? [], [boardsQuery.data]); + + // 웹과 동일: 별도 인기글 API 없음 — 이미 불러온 게시글 중 좋아요순 상위 3개만 클라이언트에서 정렬 + const popular = useMemo(() => [...boards].sort((a, b) => b.likeCount - a.likeCount).slice(0, 3), [boards]); + return ( @@ -78,19 +53,37 @@ export default function CommunityListScreen() { - - POPULAR PICKS - 인기 게시물 - 지금 커뮤니티에서 반응이 좋은 이야기를 먼저 만나보세요. - - + {boardsQuery.isLoading ? ( + + ) : ( + <> + + POPULAR PICKS + 인기 게시물 + 지금 커뮤니티에서 반응이 좋은 이야기를 먼저 만나보세요. + + - - COMMUNITY BOARD - 최근 게시물 - 반려생활 속 소소한 기록부터 유용한 팁까지 한눈에 둘러보세요. - - + + COMMUNITY BOARD + 최근 게시물 + 반려생활 속 소소한 기록부터 유용한 팁까지 한눈에 둘러보세요. + + + {boardsQuery.hasNextPage && ( + boardsQuery.fetchNextPage()} + > + + {boardsQuery.isFetchingNextPage ? '불러오는 중...' : '더 보기'} + + + )} + + + )} @@ -112,6 +105,9 @@ const styles = StyleSheet.create({ paddingBottom: 96, gap: 20, }, + loading: { + marginTop: 40, + }, topRow: { flexDirection: 'row', alignItems: 'center', @@ -157,6 +153,20 @@ const styles = StyleSheet.create({ color: DodoColors.textSecondary, marginBottom: 12, }, + moreButton: { + alignSelf: 'center', + marginTop: 12, + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 999, + borderWidth: 1, + borderColor: DodoColors.border, + }, + moreButtonText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, fab: { position: 'absolute', right: 20, diff --git a/app/(tabs)/community/my.tsx b/app/(tabs)/community/my.tsx index 287349e..ac4a9ea 100644 --- a/app/(tabs)/community/my.tsx +++ b/app/(tabs)/community/my.tsx @@ -1,47 +1,50 @@ +import { Link } from 'expo-router'; import { useState } from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useQuery } from '@tanstack/react-query'; import { BoardPost, PostListCard } from '@/components/community/post-row'; import { DodoColors } from '@/constants/theme'; +import { getMyBoards, getMyComments, type BoardListItem } from '@/shared/api/communityApi'; +import { formatShortDate } from '@/shared/lib/format/date'; -// TODO(이슈4): GET /boards/me, GET /comments/me 연동 후 mock 제거 -const MOCK_MY_POSTS: BoardPost[] = [ - { - id: '4', - title: '오늘 산책하다 만난 귀여운 친구들', - preview: '귀여운 아기들을 봤어요~~', - author: '조펭이', - date: '6월 12일', - likeCount: 1, - commentCount: 0, - viewCount: 4, - }, - { - id: '2', - title: '마루는 강쥐', - preview: '귀여움', - author: '조펭이', - date: '6월 12일', - likeCount: 3, - commentCount: 0, - viewCount: 2, - }, - { - id: '1', - title: '안녕', - preview: 'ㅎㅇ', - author: '조펭이', - date: '6월 12일', - likeCount: 4, - commentCount: 0, - viewCount: 5, - }, -]; +function toBoardPost(board: BoardListItem): BoardPost { + return { + id: String(board.boardId), + title: board.boardTitle, + preview: board.boardContentPreview, + author: board.nickname, + date: formatShortDate(board.createdAt), + likeCount: board.likeCount, + commentCount: board.commentCount, + viewCount: board.viewCount, + }; +} type TabKey = 'posts' | 'comments'; export default function MyActivityScreen() { const [activeTab, setActiveTab] = useState('posts'); + const [postsPage, setPostsPage] = useState(0); + const [commentsPage, setCommentsPage] = useState(0); + + const postsQuery = useQuery({ + queryKey: ['boards', 'me', postsPage], + queryFn: () => getMyBoards(postsPage, 10), + enabled: activeTab === 'posts', + }); + + const commentsQuery = useQuery({ + queryKey: ['comments', 'me', commentsPage], + queryFn: () => getMyComments(commentsPage, 10), + enabled: activeTab === 'comments', + }); + + const posts = postsQuery.data?.boards ?? []; + // 웹과 동일: /boards/me 응답엔 totalPages가 없어 length>=size 휴리스틱으로 다음 페이지 여부 판단 + const postsHasNext = posts.length >= 10; + const comments = commentsQuery.data?.data ?? []; + const commentsPageInfo = commentsQuery.data?.pageInfo; return ( @@ -61,21 +64,62 @@ export default function MyActivityScreen() { {activeTab === 'posts' ? ( - - ) : ( + postsQuery.isLoading ? ( + + ) : posts.length === 0 ? ( + + 아직 작성한 게시글이 없어요. + + ) : ( + + ) + ) : commentsQuery.isLoading ? ( + + ) : comments.length === 0 ? ( - 댓글 목록 준비 중 + 아직 작성한 댓글이 없어요. + + ) : ( + + {comments.map((comment, index) => ( + + + + {comment.boardTitle} + + + {comment.commentContent} + + + + ))} )} - + + activeTab === 'posts' ? setPostsPage((p) => Math.max(0, p - 1)) : setCommentsPage((p) => Math.max(0, p - 1)) + } + > 이전 - 1 + {(activeTab === 'posts' ? postsPage : commentsPage) + 1} - + = commentsPageInfo.totalPages + } + onPress={() => (activeTab === 'posts' ? setPostsPage((p) => p + 1) : setCommentsPage((p) => p + 1))} + > 다음 @@ -146,6 +190,30 @@ const styles = StyleSheet.create({ fontSize: 13, color: DodoColors.fenceIdleLabel, }, + commentsCard: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + paddingHorizontal: 16, + }, + commentRow: { + paddingVertical: 14, + gap: 4, + borderBottomWidth: 1, + borderBottomColor: DodoColors.background, + }, + commentRowLast: { + borderBottomWidth: 0, + }, + commentBoardTitle: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + commentContent: { + fontSize: 14, + color: DodoColors.textPrimary, + }, pagination: { flexDirection: 'row', alignItems: 'center', diff --git a/app/(tabs)/community/new.tsx b/app/(tabs)/community/new.tsx index b6d411b..f41be00 100644 --- a/app/(tabs)/community/new.tsx +++ b/app/(tabs)/community/new.tsx @@ -1,7 +1,9 @@ -import Ionicons from '@expo/vector-icons/Ionicons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { + Alert, KeyboardAvoidingView, Platform, ScrollView, @@ -12,14 +14,54 @@ import { View, } from 'react-native'; +import { MultiImagePicker } from '@/components/common/multi-image-picker'; import { DodoColors } from '@/constants/theme'; +import { createBoard, getTempSaveBoard, tempSaveBoard } from '@/shared/api/communityApi'; + +const DRAFT_SESSION_KEY_STORAGE = 'dodo.boardDraft.sessionKey'; export default function NewPostScreen() { const router = useRouter(); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); + const [imageUrls, setImageUrls] = useState([]); const canSubmit = title.trim().length > 0 && content.trim().length > 0; + // 웹과 동일: 임시저장 세션키가 로컬에 있으면 진입 시 자동으로 불러와 폼을 프리필한다. + useEffect(() => { + AsyncStorage.getItem(DRAFT_SESSION_KEY_STORAGE).then(async (sessionKey) => { + if (!sessionKey) return; + try { + const draft = await getTempSaveBoard(sessionKey); + if (draft.boardTitle) setTitle(draft.boardTitle); + if (draft.boardContent) setContent(draft.boardContent); + if (draft.imageFileUrls) setImageUrls(draft.imageFileUrls); + else if (draft.imageFileUrl) setImageUrls([draft.imageFileUrl]); + } catch { + await AsyncStorage.removeItem(DRAFT_SESSION_KEY_STORAGE); + } + }); + }, []); + + const submitMutation = useMutation({ + mutationFn: () => createBoard({ boardTitle: title.trim(), boardContent: content.trim(), imageFileUrls: imageUrls }), + onSuccess: async ({ boardId }) => { + await AsyncStorage.removeItem(DRAFT_SESSION_KEY_STORAGE); + router.replace({ pathname: '/(tabs)/community/[boardId]', params: { boardId: String(boardId) } }); + }, + onError: () => Alert.alert('오류', '게시글을 등록하지 못했어요. 잠시 후 다시 시도해주세요.'), + }); + + const tempSaveMutation = useMutation({ + mutationFn: () => + tempSaveBoard({ boardTitle: title.trim(), boardContent: content.trim(), imageFileUrls: imageUrls }), + onSuccess: async ({ sessionKey }) => { + await AsyncStorage.setItem(DRAFT_SESSION_KEY_STORAGE, sessionKey); + Alert.alert('임시 저장', '작성 중인 내용을 임시 저장했어요.'); + }, + onError: () => Alert.alert('오류', '임시 저장에 실패했어요.'), + }); + return ( 이미지 첨부 - PNG, JPG 형식 / 최대 10MB - {/* TODO(이슈7): expo-image-picker 설치 후 실제 첨부 연동 */} - - - 이미지 추가 - - - 아직 첨부된 이미지가 없어요. - + PNG, JPG 형식 / 장당 최대 10MB · 전체 최대 50MB + * 필수 입력 항목입니다. @@ -71,14 +106,19 @@ export default function NewPostScreen() { router.back()}> 취소 - - 임시 저장 + tempSaveMutation.mutate()} + > + {tempSaveMutation.isPending ? '저장 중...' : '임시 저장'} submitMutation.mutate()} > - 게시하기 + {submitMutation.isPending ? '게시 중...' : '게시하기'} @@ -128,36 +168,6 @@ const styles = StyleSheet.create({ fontSize: 14, color: DodoColors.textPrimary, }, - addImageButton: { - flexDirection: 'row', - alignItems: 'center', - alignSelf: 'flex-start', - gap: 6, - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 8, - borderWidth: 1, - borderColor: DodoColors.border, - backgroundColor: DodoColors.surface, - }, - addImageText: { - fontSize: 12, - fontWeight: '600', - color: DodoColors.textSecondary, - }, - imageEmpty: { - borderRadius: 10, - borderWidth: 1, - borderStyle: 'dashed', - borderColor: DodoColors.border, - paddingVertical: 20, - alignItems: 'center', - justifyContent: 'center', - }, - imageEmptyText: { - fontSize: 12, - color: DodoColors.fenceIdleLabel, - }, requiredHint: { fontSize: 11, color: DodoColors.fenceIdleLabel, From e8bb584a79cfb71a531139fa7c7979673929a84a Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 13:17:15 +0900 Subject: [PATCH 15/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 반려동물 프로필/AI 건강 리포트 요약 연동, 여러 마리 시 선택 전환 지원 --- app/(tabs)/index.tsx | 189 +++++++++++++++++++++++++++++++++---------- 1 file changed, 145 insertions(+), 44 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f56b034..2643318 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,19 +1,15 @@ import Ionicons from '@expo/vector-icons/Ionicons'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter, type Href } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DodoColors } from '@/constants/theme'; +import { getMain } from '@/shared/api/mainApi'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; -// TODO(이슈4): GET /main 연동 후 mock 제거 -const MOCK_PET = { - name: '푸치', - ageLabel: '만 0세', - breed: '말티즈', - weight: '6 kg', -}; - -const MOCK_PET_PHOTOS = [0, 1, 2, 3, 4]; - +// 공지사항 UI는 웹에서도 죽은 데이터(announcement 필드가 화면에 렌더되지 않음)라 목데이터를 그대로 유지한다. const MOCK_NOTICES: { tag: '안내' | '긴급'; title: string }[] = [ { tag: '안내', title: '겨울 시즌 산책 챌린지 오픈!' }, { tag: '긴급', title: '일부 알림 지연 현상 안내' }, @@ -21,8 +17,57 @@ const MOCK_NOTICES: { tag: '안내' | '긴급'; title: string }[] = [ { tag: '안내', title: '반려동물 프로필 개선 업데이트' }, ]; +function sexLabel(sex: 'MALE' | 'FEMALE' | 'NEUTER') { + if (sex === 'MALE') return '수컷'; + if (sex === 'FEMALE') return '암컷'; + return '중성화'; +} + export default function HomeScreen() { const insets = useSafeAreaInsets(); + const router = useRouter(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const [selectedPetId, setSelectedPetId] = useState(null); + + const mainQuery = useQuery({ + queryKey: ['main'], + queryFn: getMain, + enabled: isAuthenticated, + }); + + const petProfiles = mainQuery.data?.petProfiles ?? []; + const activePetId = selectedPetId ?? petProfiles[0]?.petId ?? null; + const selectedPet = petProfiles.find((p) => p.petId === activePetId) ?? null; + + const latestReport = useMemo(() => { + const reports = (mainQuery.data?.healthReports ?? []).filter((r) => r.petId === activePetId); + if (reports.length === 0) return null; + return [...reports].sort((a, b) => (a.checkupDate < b.checkupDate ? 1 : -1))[0]; + }, [mainQuery.data, activePetId]); + + if (!isAuthenticated) { + return ( + + + + DoDo에 오신 걸 환영해요 + 로그인하면 반려동물의 건강 리포트와 산책 정보를 확인할 수 있어요. + router.push('/auth' as Href)}> + 로그인하기 + + + + ); + } + + if (mainQuery.isLoading) { + return ( + + + + ); + } + return ( @@ -37,50 +82,72 @@ export default function HomeScreen() { - {MOCK_PET.name}의 건강 데이터를 분석 중이에요. - - {MOCK_PET.name}의 첫 건강 레포트를 만들 수 있도록 산책과 건강 기록을 조금 더 쌓아보세요. - + {latestReport ? ( + <> + {latestReport.healthReportTitle} + {latestReport.healthReportSummary} + + ) : ( + <> + {selectedPet?.name ?? '반려동물'}의 건강 데이터를 분석 중이에요. + + {selectedPet?.name ?? '반려동물'}의 첫 건강 레포트를 만들 수 있도록 산책과 건강 기록을 조금 더 + 쌓아보세요. + + + )} - 레포트 준비 중 + {latestReport ? '레포트 자세히 보기' : '레포트 준비 중'} {/* 펫 카드 */} - - - - - - - {MOCK_PET.name} - - - {MOCK_PET.ageLabel} + {selectedPet && ( + + + + + + + {selectedPet.name} + + + 만 {selectedPet.age}세 + - - - - 품종 - {MOCK_PET.breed} - - - 체중 - {MOCK_PET.weight} + + + 품종 + {selectedPet.breed} + + + 체중 + {selectedPet.weight} kg + + + 성별 + {sexLabel(selectedPet.sex)} + - - - {MOCK_PET_PHOTOS.map((id, index) => ( - - - - ))} - - + {petProfiles.length > 1 && ( + + {petProfiles.map((pet) => ( + setSelectedPetId(pet.petId)} + > + + + ))} + + )} + + )} {/* 공지사항 */} @@ -117,11 +184,45 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: DodoColors.background, }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + }, content: { padding: 16, gap: 12, paddingBottom: 32, }, + guestContent: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingHorizontal: 32, + }, + guestTitle: { + fontSize: 18, + fontWeight: '700', + color: DodoColors.textPrimary, + marginTop: 8, + }, + guestSubtitle: { + fontSize: 13, + color: DodoColors.textSecondary, + textAlign: 'center', + }, + loginButton: { + marginTop: 16, + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 10, + backgroundColor: DodoColors.brand, + }, + loginButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, card: { backgroundColor: DodoColors.surface, borderRadius: 16, From b18627e461853ca66c35394ad4d5ad400c2f0104 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 13:19:45 +0900 Subject: [PATCH 16/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=82=B0=EC=B1=85(?= =?UTF-8?q?=EC=9A=B8=ED=83=80=EB=A6=AC=C2=B7=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=C2=B7=ED=99=9C=EB=8F=99=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?)=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 울타리 설정을 실제 API 연동 + 생성/수정 UI 신규 구현 - 산책 활동 기록 기능 신규 구현: 시작/일시정지/종료, 기록 목록, 경로 리플레이, 주변 인기 활동 - 실시간 위치 표시용 지도 컴포넌트 및 좌표 방어 로직 추가 --- app/(tabs)/walk.tsx | 10 - app/(tabs)/walk/_layout.tsx | 38 ++ app/(tabs)/walk/history.tsx | 171 +++++++ app/(tabs)/walk/history/[historyId].tsx | 190 ++++++++ app/(tabs)/walk/index.tsx | 16 + app/(tabs)/walk/popular.tsx | 179 +++++++ components/walk/activity-map.tsx | 48 ++ components/walk/activity-map.web.tsx | 46 ++ components/walk/activity-tracking-screen.tsx | 473 +++++++++++++++++++ components/walk/fence-map.tsx | 60 ++- components/walk/fence-map.web.tsx | 20 +- components/walk/fence-settings-screen.tsx | 394 +++++++++++++-- components/walk/walk-tab-switcher.tsx | 58 +++ 13 files changed, 1639 insertions(+), 64 deletions(-) delete mode 100644 app/(tabs)/walk.tsx create mode 100644 app/(tabs)/walk/_layout.tsx create mode 100644 app/(tabs)/walk/history.tsx create mode 100644 app/(tabs)/walk/history/[historyId].tsx create mode 100644 app/(tabs)/walk/index.tsx create mode 100644 app/(tabs)/walk/popular.tsx create mode 100644 components/walk/activity-map.tsx create mode 100644 components/walk/activity-map.web.tsx create mode 100644 components/walk/activity-tracking-screen.tsx create mode 100644 components/walk/walk-tab-switcher.tsx diff --git a/app/(tabs)/walk.tsx b/app/(tabs)/walk.tsx deleted file mode 100644 index 7e91646..0000000 --- a/app/(tabs)/walk.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { FenceMap } from '@/components/walk/fence-map'; -import { FenceSettingsScreen } from '@/components/walk/fence-settings-screen'; - -export default function WalkScreen() { - return ( - } - /> - ); -} diff --git a/app/(tabs)/walk/_layout.tsx b/app/(tabs)/walk/_layout.tsx new file mode 100644 index 0000000..187082d --- /dev/null +++ b/app/(tabs)/walk/_layout.tsx @@ -0,0 +1,38 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { Stack, useRouter } from 'expo-router'; +import { TouchableOpacity } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +function BackButton() { + const router = useRouter(); + return ( + router.back()} hitSlop={8}> + + + ); +} + +const subScreenOptions = { + headerShown: true, + title: '', + headerStyle: { backgroundColor: DodoColors.surface }, + headerShadowVisible: false, + headerLeft: () => , +} as const; + +export default function WalkStackLayout() { + return ( + + + + + + + ); +} diff --git a/app/(tabs)/walk/history.tsx b/app/(tabs)/walk/history.tsx new file mode 100644 index 0000000..f79aa99 --- /dev/null +++ b/app/(tabs)/walk/history.tsx @@ -0,0 +1,171 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { ActivityIndicator, Alert, FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; +import { deleteActivityHistory, getActivityHistory, type ActivityHistoryListItem } from '@/shared/api/activityApi'; +import { formatFullDateTime } from '@/shared/lib/format/date'; + +const PAGE_SIZE = 10; + +const STATUS_LABEL: Record = { + BEFORE: '대기', + IN_PROGRESS: '진행 중', + COMPLETED: '완료', + CANCELED: '중단됨', +}; + +export default function ActivityHistoryScreen() { + const router = useRouter(); + const queryClient = useQueryClient(); + + const historyQuery = useInfiniteQuery({ + queryKey: ['activities', 'history'], + queryFn: ({ pageParam }) => getActivityHistory(pageParam, PAGE_SIZE), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => (lastPage.histories.length >= PAGE_SIZE ? allPages.length : undefined), + }); + + const deleteMutation = useMutation({ + mutationFn: (historyId: number) => deleteActivityHistory(historyId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['activities', 'history'] }), + onError: () => Alert.alert('오류', '활동 기록을 삭제하지 못했어요.'), + }); + + const handleDelete = (historyId: number) => { + Alert.alert('활동 기록 삭제', '이 활동 기록을 삭제할까요?', [ + { text: '취소', style: 'cancel' }, + { text: '삭제', style: 'destructive', onPress: () => deleteMutation.mutate(historyId) }, + ]); + }; + + const items = historyQuery.data?.pages.flatMap((p) => p.histories) ?? []; + + const renderItem = ({ item }: { item: ActivityHistoryListItem }) => ( + + router.push({ pathname: '/(tabs)/walk/history/[historyId]', params: { historyId: String(item.historyId) } }) + } + > + + + + + + {item.pet.name} + + + {STATUS_LABEL[item.activityHistoryStatus] ?? item.activityHistoryStatus} + + + + {formatFullDateTime(item.activityHistoryStartAt)} + + {item.distance}km · 좋아요 {item.reactionCount} + {item.heartAverage != null ? ` · 평균 심박 ${item.heartAverage}` : ''} + + + handleDelete(item.historyId)}> + + + + ); + + return ( + String(item.historyId)} + renderItem={renderItem} + ItemSeparatorComponent={() => } + onEndReachedThreshold={0.4} + onEndReached={() => { + if (historyQuery.hasNextPage && !historyQuery.isFetchingNextPage) historyQuery.fetchNextPage(); + }} + ListEmptyComponent={ + historyQuery.isLoading ? ( + + ) : ( + 아직 산책 기록이 없어요. + ) + } + ListFooterComponent={ + historyQuery.isFetchingNextPage ? : null + } + /> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + flexGrow: 1, + }, + loading: { + marginTop: 40, + }, + emptyText: { + marginTop: 60, + textAlign: 'center', + fontSize: 13, + color: DodoColors.fenceIdleLabel, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 14, + }, + avatar: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + textCol: { + flex: 1, + gap: 3, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + petName: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + backgroundColor: DodoColors.background, + }, + statusBadgeText: { + fontSize: 10, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + meta: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + separator: { + height: 10, + }, +}); diff --git a/app/(tabs)/walk/history/[historyId].tsx b/app/(tabs)/walk/history/[historyId].tsx new file mode 100644 index 0000000..4143c13 --- /dev/null +++ b/app/(tabs)/walk/history/[historyId].tsx @@ -0,0 +1,190 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { ActivityIndicator, Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { ActivityMap } from '@/components/walk/activity-map'; +import { DodoColors } from '@/constants/theme'; +import { deleteActivityHistory, getActivityHistoryDetail, getActivityRoute } from '@/shared/api/activityApi'; +import { durationSecondsBetween, formatDurationSeconds, formatFullDateTime } from '@/shared/lib/format/date'; + +const STATUS_LABEL: Record = { + BEFORE: '대기', + IN_PROGRESS: '진행 중', + COMPLETED: '완료', + CANCELED: '중단됨', +}; + +export default function ActivityHistoryDetailScreen() { + const { historyId } = useLocalSearchParams<{ historyId: string }>(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const routeQuery = useQuery({ + queryKey: ['activities', 'route', historyId], + queryFn: () => getActivityRoute(historyId), + enabled: !!historyId, + }); + + const detailQuery = useQuery({ + queryKey: ['activities', 'detail', historyId], + queryFn: () => getActivityHistoryDetail(historyId), + enabled: !!historyId, + }); + + const deleteMutation = useMutation({ + mutationFn: () => deleteActivityHistory(historyId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['activities', 'history'] }); + router.back(); + }, + onError: () => Alert.alert('오류', '활동 기록을 삭제하지 못했어요.'), + }); + + const handleDelete = () => { + Alert.alert('활동 기록 삭제', '이 활동 기록을 삭제할까요?', [ + { text: '취소', style: 'cancel' }, + { text: '삭제', style: 'destructive', onPress: () => deleteMutation.mutate() }, + ]); + }; + + if (routeQuery.isError && isAxiosError(routeQuery.error) && routeQuery.error.response?.status === 403) { + return ( + + 이 활동 기록을 볼 권한이 없어요. + + ); + } + + if (routeQuery.isLoading || !routeQuery.data) { + return ( + + + + ); + } + + const route = routeQuery.data; + const points = route.routePoints.map((p) => ({ latitude: p.latitude, longitude: p.longitude })); + const center = points[0] ?? { latitude: route.startLatitude, longitude: route.startLongitude }; + const duration = route.activityHistoryEndAt + ? durationSecondsBetween(route.activityHistoryStartAt, route.activityHistoryEndAt) + : null; + + return ( + + + + + {STATUS_LABEL[route.activityHistoryStatus] ?? route.activityHistoryStatus} + + + + {deleteMutation.isPending ? '삭제 중...' : '삭제'} + + + + + + + + 이동 거리 + {route.distance}km + + + 소요 시간 + {duration != null ? formatDurationSeconds(duration) : '-'} + + + 좋아요 + {detailQuery.data?.reactionCount ?? '-'} + + + + + 시작 {formatFullDateTime(route.activityHistoryStartAt)} + {route.activityHistoryEndAt && ( + 종료 {formatFullDateTime(route.activityHistoryEndAt)} + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + statusBadge: { + alignSelf: 'flex-start', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 999, + backgroundColor: DodoColors.surface, + borderWidth: 1, + borderColor: DodoColors.border, + }, + statusBadgeText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + deleteText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.fenceOutside, + }, + statsRow: { + flexDirection: 'row', + justifyContent: 'space-around', + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + }, + statItem: { + alignItems: 'center', + gap: 2, + }, + statLabel: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + statValue: { + fontSize: 16, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 6, + }, + infoRow: { + fontSize: 12, + color: DodoColors.textSecondary, + }, +}); diff --git a/app/(tabs)/walk/index.tsx b/app/(tabs)/walk/index.tsx new file mode 100644 index 0000000..46ed42d --- /dev/null +++ b/app/(tabs)/walk/index.tsx @@ -0,0 +1,16 @@ +import { useState } from 'react'; + +import { ActivityTrackingScreen } from '@/components/walk/activity-tracking-screen'; +import { FenceMap } from '@/components/walk/fence-map'; +import { FenceSettingsScreen } from '@/components/walk/fence-settings-screen'; +import type { WalkTab } from '@/components/walk/walk-tab-switcher'; + +export default function WalkScreen() { + const [tab, setTab] = useState('fence'); + + if (tab === 'activity') { + return ; + } + + return } />; +} diff --git a/app/(tabs)/walk/popular.tsx b/app/(tabs)/walk/popular.tsx new file mode 100644 index 0000000..08b13bd --- /dev/null +++ b/app/(tabs)/walk/popular.tsx @@ -0,0 +1,179 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DEFAULT_CENTER } from '@/components/walk/fence-settings-screen'; +import { DodoColors } from '@/constants/theme'; +import { getPopularActivities, type PopularReactionType } from '@/shared/api/activityApi'; +import { getPetsList } from '@/shared/api/petApi'; +import { formatFullDateTime } from '@/shared/lib/format/date'; +import { useFenceLocationSocket } from '@/shared/lib/ws/useFenceLocationSocket'; + +export default function PopularActivitiesScreen() { + const router = useRouter(); + const [reactionType, setReactionType] = useState('LIKE'); + + // 폰 GPS 대신 반려동물 트래커의 실시간 위치를 "내 주변" 기준 좌표로 재사용 (앱 전체에서 폰 GPS를 쓰지 않는 정책과 통일) + const petsQuery = useQuery({ queryKey: ['pets', 'list'], queryFn: () => getPetsList(0, 1) }); + const firstPetId = petsQuery.data?.pets[0]?.petId ?? null; + const liveLocation = useFenceLocationSocket(firstPetId); + const origin = useMemo( + () => (liveLocation ? { latitude: liveLocation.latitude, longitude: liveLocation.longitude } : DEFAULT_CENTER), + [liveLocation], + ); + + const popularQuery = useQuery({ + queryKey: ['activities', 'popular', reactionType, origin.latitude, origin.longitude], + queryFn: () => + getPopularActivities({ latitude: origin.latitude, longitude: origin.longitude, reactionType, limit: 20 }), + }); + + const items = popularQuery.data?.histories ?? []; + + return ( + + 내 반려동물 트래커 위치를 기준으로 가까운 인기 활동을 보여줘요. + + + setReactionType('LIKE')} + > + + 좋아요순 + + + setReactionType('DISLIKE')} + > + + 싫어요순 + + + + + {popularQuery.isLoading ? ( + + ) : items.length === 0 ? ( + 주변에 표시할 활동이 없어요. + ) : ( + + {items.map((item, index) => ( + + router.push({ + pathname: '/(tabs)/walk/history/[historyId]', + params: { historyId: String(item.historyId) }, + }) + } + > + + + + + {item.pet.name} + {formatFullDateTime(item.activityHistoryStartAt)} + + {item.distance}km · 좋아요 {item.reactionCount} + + + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + hint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + filterRow: { + flexDirection: 'row', + gap: 8, + }, + filterChip: { + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 999, + backgroundColor: DodoColors.surface, + borderWidth: 1, + borderColor: DodoColors.border, + }, + filterChipSelected: { + backgroundColor: DodoColors.textPrimary, + borderColor: DodoColors.textPrimary, + }, + filterChipText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + filterChipTextSelected: { + color: DodoColors.brandForeground, + }, + loading: { + marginTop: 40, + }, + emptyText: { + marginTop: 40, + textAlign: 'center', + fontSize: 13, + color: DodoColors.fenceIdleLabel, + }, + list: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + paddingHorizontal: 14, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: DodoColors.background, + }, + rowLast: { + borderBottomWidth: 0, + }, + avatar: { + width: 40, + height: 40, + borderRadius: 10, + backgroundColor: DodoColors.secondary, + alignItems: 'center', + justifyContent: 'center', + }, + textCol: { + flex: 1, + gap: 2, + }, + petName: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + meta: { + fontSize: 11, + color: DodoColors.textSecondary, + }, +}); diff --git a/components/walk/activity-map.tsx b/components/walk/activity-map.tsx new file mode 100644 index 0000000..362493a --- /dev/null +++ b/components/walk/activity-map.tsx @@ -0,0 +1,48 @@ +import { NaverMapMarkerOverlay, NaverMapPolylineOverlay, NaverMapView } from '@mj-studio/react-native-naver-map'; +import { StyleSheet } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +type Coord = { latitude: number; longitude: number }; + +type Props = { + center: Coord; + points: Coord[]; + currentLocation?: Coord | null; + startPoint?: Coord | null; +}; + +export function ActivityMap({ center, points, currentLocation, startPoint }: Props) { + return ( + + {points.length > 1 && } + {startPoint && ( + + )} + {currentLocation && ( + + )} + + ); +} + +const styles = StyleSheet.create({ + map: { + height: 280, + borderRadius: 12, + overflow: 'hidden', + }, +}); diff --git a/components/walk/activity-map.web.tsx b/components/walk/activity-map.web.tsx new file mode 100644 index 0000000..85e19cc --- /dev/null +++ b/components/walk/activity-map.web.tsx @@ -0,0 +1,46 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +type Coord = { latitude: number; longitude: number }; + +type Props = { + center: Coord; + points: Coord[]; + currentLocation?: Coord | null; + startPoint?: Coord | null; +}; + +// 네이버 지도 SDK는 네이티브 전용이라 웹 프리뷰에서는 안내 플레이스홀더만 보여준다. +export function ActivityMap(_props: Props) { + return ( + + + 네이버 지도는 웹 프리뷰 미지원 + 실시간 산책 경로는 Android 에뮬레이터/실기기 빌드에서 확인하세요 + + ); +} + +const styles = StyleSheet.create({ + placeholder: { + height: 280, + borderRadius: 12, + backgroundColor: DodoColors.background, + alignItems: 'center', + justifyContent: 'center', + gap: 6, + }, + placeholderText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + placeholderHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + paddingHorizontal: 32, + textAlign: 'center', + }, +}); diff --git a/components/walk/activity-tracking-screen.tsx b/components/walk/activity-tracking-screen.tsx new file mode 100644 index 0000000..d45662c --- /dev/null +++ b/components/walk/activity-tracking-screen.tsx @@ -0,0 +1,473 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; +import { useRouter } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { DodoColors } from '@/constants/theme'; +import { + cancelActivityHistory, + createActivityHistory, + finishActivityHistory, + getActivityHistoryDetail, + getPetActivityStatus, + inferActivityStatus, + startActivityHistory, +} from '@/shared/api/activityApi'; +import { getPetsList } from '@/shared/api/petApi'; +import { durationSecondsBetween, formatDurationSeconds } from '@/shared/lib/format/date'; +import { totalRouteDistanceMeters } from '@/shared/lib/geo/haversine'; +import { useActivityLocationSocket } from '@/shared/lib/ws/useActivityLocationSocket'; +import { useFenceLocationSocket } from '@/shared/lib/ws/useFenceLocationSocket'; + +import { ActivityMap } from './activity-map'; +import { DEFAULT_CENTER } from './fence-settings-screen'; +import { WalkTabSwitcher, type WalkTab } from './walk-tab-switcher'; + +type Coord = { latitude: number; longitude: number }; +type TrackingState = 'idle' | 'in_progress' | 'paused'; + +type Props = { + activeTab: WalkTab; + onChangeTab: (tab: WalkTab) => void; +}; + +export function ActivityTrackingScreen({ activeTab, onChangeTab }: Props) { + const insets = useSafeAreaInsets(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const petsQuery = useQuery({ queryKey: ['pets', 'list'], queryFn: () => getPetsList(0, 10) }); + const pets = useMemo(() => petsQuery.data?.pets ?? [], [petsQuery.data]); + + const [selectedPetId, setSelectedPetId] = useState(null); + useEffect(() => { + if (selectedPetId === null && pets.length > 0) setSelectedPetId(pets[0].petId); + }, [pets, selectedPetId]); + + const [trackingState, setTrackingState] = useState('idle'); + const [liveHistoryId, setLiveHistoryId] = useState(null); + const [routePoints, setRoutePoints] = useState([]); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + // 펫 트래커 위치(시작 좌표 확보용) — 울타리 기능과 같은 채널, petId 기준 + const liveLocation = useFenceLocationSocket(selectedPetId); + // 산책 활동 실시간 경로 — historyId 기준 별도 채널(백엔드 확인됨). 진행 중일 때만 구독한다. + const activityRoutePoint = useActivityLocationSocket(trackingState === 'in_progress' ? liveHistoryId : null); + const activityCoord: Coord | null = activityRoutePoint + ? { latitude: activityRoutePoint.latitude, longitude: activityRoutePoint.longitude } + : null; + + // 펫을 바꾸면 새로 시작하는 상태로 초기화 — 진행 중인 산책은 status 조회가 다시 복원해준다 + useEffect(() => { + setTrackingState('idle'); + setLiveHistoryId(null); + setRoutePoints([]); + setElapsedSeconds(0); + }, [selectedPetId]); + + const statusQuery = useQuery({ + queryKey: ['activities', 'status', selectedPetId], + queryFn: () => getPetActivityStatus(selectedPetId as number), + enabled: selectedPetId !== null && trackingState === 'idle', + }); + + // 앱을 재시작해도 이미 진행 중/일시정지 중인 산책이 있으면 이어서 보여준다 (경과 시간은 서버 시작 시각 기준으로 복원) + useEffect(() => { + if (!statusQuery.data || trackingState !== 'idle') return; + const inferred = inferActivityStatus(statusQuery.data.message); + const historyId = statusQuery.data.historyId; + if (!historyId) return; + + if (inferred === 'BEFORE') { + setLiveHistoryId(historyId); + return; + } + if (inferred !== 'IN_PROGRESS' && inferred !== 'CANCELED') return; + + setLiveHistoryId(historyId); + setTrackingState(inferred === 'IN_PROGRESS' ? 'in_progress' : 'paused'); + + getActivityHistoryDetail(historyId) + .then((detail) => { + const endReference = + inferred === 'IN_PROGRESS' + ? new Date().toISOString() + : (detail.activityHistoryEndAt ?? new Date().toISOString()); + setElapsedSeconds(durationSecondsBetween(detail.activityHistoryStartAt, endReference)); + }) + .catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [statusQuery.data]); + + // 진행 중일 때만 매초 경과 시간 증가 + useEffect(() => { + if (trackingState !== 'in_progress') return; + const id = setInterval(() => setElapsedSeconds((s) => s + 1), 1000); + return () => clearInterval(id); + }, [trackingState]); + + // 진행 중일 때만 활동 전용 WS(historyId 채널)로 들어오는 경로 포인트를 누적 + useEffect(() => { + if (trackingState !== 'in_progress' || !activityCoord) return; + setRoutePoints((prev) => { + const last = prev[prev.length - 1]; + if (last && last.latitude === activityCoord.latitude && last.longitude === activityCoord.longitude) return prev; + return [...prev, activityCoord]; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activityRoutePoint, trackingState]); + + const liveDistanceKm = useMemo(() => totalRouteDistanceMeters(routePoints) / 1000, [routePoints]); + + const startMutation = useMutation({ + mutationFn: async () => { + if (!selectedPetId) throw new Error('no pet selected'); + let historyId = liveHistoryId; + if (!historyId) { + const created = await createActivityHistory({ petId: selectedPetId, activityType: 'WALKING' }); + historyId = created.historyId; + } + const coord: Coord = liveLocation + ? { latitude: liveLocation.latitude, longitude: liveLocation.longitude } + : DEFAULT_CENTER; + await startActivityHistory(historyId, { startLatitude: coord.latitude, startLongitude: coord.longitude }); + return { historyId, coord }; + }, + onSuccess: ({ historyId, coord }) => { + setLiveHistoryId(historyId); + setTrackingState('in_progress'); + setRoutePoints((prev) => (prev.length === 0 ? [coord] : prev)); + queryClient.invalidateQueries({ queryKey: ['activities', 'status', selectedPetId] }); + }, + onError: (error: unknown) => { + if (isAxiosError(error) && error.response?.status === 409) { + Alert.alert('알림', '이미 진행 중인 산책이 있어요.'); + statusQuery.refetch(); + return; + } + Alert.alert('오류', '산책을 시작하지 못했어요. 잠시 후 다시 시도해주세요.'); + }, + }); + + const pauseMutation = useMutation({ + mutationFn: () => cancelActivityHistory(liveHistoryId as number), + onSuccess: () => setTrackingState('paused'), + onError: () => Alert.alert('오류', '산책을 일시정지하지 못했어요.'), + }); + + const finishMutation = useMutation({ + mutationFn: () => finishActivityHistory(liveHistoryId as number), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['activities', 'history'] }); + queryClient.invalidateQueries({ queryKey: ['activities', 'status', selectedPetId] }); + const duration = formatDurationSeconds( + durationSecondsBetween(result.activityHistoryStartAt, result.activityHistoryEndAt), + ); + Alert.alert('산책 완료', `이동 거리 ${result.distance}km, 소요 시간 ${duration}`); + setTrackingState('idle'); + setLiveHistoryId(null); + setRoutePoints([]); + setElapsedSeconds(0); + }, + onError: () => Alert.alert('오류', '산책을 종료하지 못했어요.'), + }); + + const handleFinish = () => { + Alert.alert('산책 종료', '산책을 종료할까요?', [ + { text: '취소', style: 'cancel' }, + { text: '종료', onPress: () => finishMutation.mutate() }, + ]); + }; + + const handleQuit = () => { + setTrackingState('idle'); + setLiveHistoryId(null); + setRoutePoints([]); + setElapsedSeconds(0); + }; + + const selectedPet = pets.find((p) => p.petId === selectedPetId) ?? null; + const isTracking = trackingState !== 'idle'; + const mapCenter = activityCoord ?? routePoints[routePoints.length - 1] ?? liveLocation ?? DEFAULT_CENTER; + + if (petsQuery.isLoading) { + return ( + + + + + ); + } + + return ( + + + 산책 + 반려동물과의 산책을 실시간으로 기록해보세요. + + + {pets.length === 0 ? ( + + 산책을 기록하려면 먼저 반려동물을 등록해주세요. + + ) : ( + <> + + 반려동물 선택 + + {pets.map((pet) => { + const selected = pet.petId === selectedPetId; + return ( + setSelectedPetId(pet.petId)} + > + {pet.name} + + ); + })} + + + {isTracking && ( + + )} + + {isTracking && ( + + + 경과 시간 + {formatDurationSeconds(elapsedSeconds)} + + + 이동 거리 + {liveDistanceKm.toFixed(2)}km + + + 상태 + {trackingState === 'in_progress' ? '진행 중' : '일시정지'} + + + )} + + {!isTracking ? ( + startMutation.mutate()} + > + + {startMutation.isPending ? '시작하는 중...' : `${selectedPet?.name ?? ''} 산책 시작`} + + + ) : trackingState === 'in_progress' ? ( + + pauseMutation.mutate()} + > + + {pauseMutation.isPending ? '처리 중...' : '일시정지'} + + + + + {finishMutation.isPending ? '종료하는 중...' : '산책 종료'} + + + + ) : ( + + + 그만하기 + + startMutation.mutate()} + > + + {startMutation.isPending ? '재개하는 중...' : '다시 시작'} + + + + )} + + + + router.push('/(tabs)/walk/history')}> + 내 활동 기록 + 지난 산책 목록과 경로를 확인해요 + + router.push('/(tabs)/walk/popular')}> + 주변 인기 활동 + 근처 반려동물의 산책을 둘러봐요 + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + content: { + padding: 16, + paddingBottom: 32, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + subtitle: { + fontSize: 12, + color: DodoColors.textSecondary, + marginTop: 2, + marginBottom: 16, + }, + card: { + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 16, + gap: 14, + }, + emptyText: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + textAlign: 'center', + }, + label: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + chipRow: { + marginTop: -6, + }, + chip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + marginRight: 8, + }, + chipSelected: { + borderColor: DodoColors.brand, + backgroundColor: DodoColors.brand, + }, + chipDisabled: { + opacity: 0.5, + }, + chipText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + chipTextSelected: { + color: DodoColors.brandForeground, + }, + statsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + statItem: { + alignItems: 'center', + gap: 2, + }, + statLabel: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + statValue: { + fontSize: 16, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + primaryButton: { + height: 46, + borderRadius: 12, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.brandForeground, + }, + buttonDisabled: { + opacity: 0.5, + }, + actionsRow: { + flexDirection: 'row', + gap: 8, + }, + flexButton: { + flex: 1, + }, + secondaryButton: { + flex: 1, + height: 46, + borderRadius: 12, + borderWidth: 1, + borderColor: DodoColors.border, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + navRow: { + flexDirection: 'row', + gap: 12, + marginTop: 16, + }, + navCard: { + flex: 1, + backgroundColor: DodoColors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: DodoColors.border, + padding: 14, + gap: 4, + }, + navCardTitle: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + navCardHint: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, +}); diff --git a/components/walk/fence-map.tsx b/components/walk/fence-map.tsx index b8246a7..e18dd92 100644 --- a/components/walk/fence-map.tsx +++ b/components/walk/fence-map.tsx @@ -1,27 +1,44 @@ -import { NaverMapCircleOverlay, NaverMapView } from '@mj-studio/react-native-naver-map'; +import { NaverMapCircleOverlay, NaverMapMarkerOverlay, NaverMapView } from '@mj-studio/react-native-naver-map'; import { DodoColors } from '@/constants/theme'; -import type { PetFence } from './fence-settings-screen'; +import type { Fence } from './fence-settings-screen'; import { styles } from './fence-settings-screen'; +import type { FenceLocationPayload } from '@/shared/lib/ws/useFenceLocationSocket'; + +type Coord = { latitude: number; longitude: number }; type Props = { - selectedFence: PetFence; - fences: PetFence[]; + center: Coord; + fences: Fence[]; + selectedPetId: number | null; + liveLocation: FenceLocationPayload | null; + pendingCenter: Coord | null; + pendingRadius: number | null; + onTapMap: (coord: Coord) => void; }; -export function FenceMap({ selectedFence, fences }: Props) { +export function FenceMap({ + center, + fences, + selectedPetId, + liveLocation, + pendingCenter, + pendingRadius, + onTapMap, +}: Props) { return ( onTapMap({ latitude, longitude })} > {fences.map((fence) => { - const selected = fence.petId === selectedFence.petId; + const selected = fence.petId === selectedPetId; return ( ); })} + + {pendingCenter && ( + + )} + {pendingCenter && ( + + )} + + {liveLocation && ( + + )} ); } diff --git a/components/walk/fence-map.web.tsx b/components/walk/fence-map.web.tsx index d6200a8..6039a3e 100644 --- a/components/walk/fence-map.web.tsx +++ b/components/walk/fence-map.web.tsx @@ -3,23 +3,33 @@ import { StyleSheet, Text, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; -import type { PetFence } from './fence-settings-screen'; +import type { Fence } from './fence-settings-screen'; import { styles as screenStyles } from './fence-settings-screen'; +import type { FenceLocationPayload } from '@/shared/lib/ws/useFenceLocationSocket'; + +type Coord = { latitude: number; longitude: number }; type Props = { - selectedFence: PetFence; - fences: PetFence[]; + center: Coord; + fences: Fence[]; + selectedPetId: number | null; + liveLocation: FenceLocationPayload | null; + pendingCenter: Coord | null; + pendingRadius: number | null; + onTapMap: (coord: Coord) => void; }; // 네이버 지도 SDK는 네이티브 전용 모듈이라 react-native-web에서 렌더링이 불가능하다. // 웹 프리뷰에서는 안내 플레이스홀더만 보여주고, 실제 지도는 Android/iOS 빌드에서 확인한다. -export function FenceMap({ selectedFence }: Props) { +export function FenceMap({ fences, selectedPetId }: Props) { + const selectedFence = fences.find((f) => f.petId === selectedPetId); return ( 네이버 지도는 웹 프리뷰 미지원 - {selectedFence.petName}의 울타리는 Android 에뮬레이터/실기기 빌드에서 확인하세요 + {selectedFence ? `${selectedFence.fenceName}은(는)` : '이 반려동물의 울타리는'} Android 에뮬레이터/실기기 + 빌드에서 확인하세요 ); diff --git a/components/walk/fence-settings-screen.tsx b/components/walk/fence-settings-screen.tsx index d865c42..1b0f853 100644 --- a/components/walk/fence-settings-screen.tsx +++ b/components/walk/fence-settings-screen.tsx @@ -1,30 +1,48 @@ -import { ReactNode, useState } from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ReactNode, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DodoColors } from '@/constants/theme'; +import { createFence, getFenceBoundaries, toggleFence, updateFence } from '@/shared/api/fenceApi'; +import { getPetsList } from '@/shared/api/petApi'; +import { useFenceLocationSocket, type FenceLocationPayload } from '@/shared/lib/ws/useFenceLocationSocket'; -// TODO(이슈4/§6): GET /pets/list, GET /fence/boundaries 연동 후 mock 제거 -export type PetFence = { - petId: string; - petName: string; - radiusMeters: number; - active: boolean; +import { WalkTabSwitcher, type WalkTab } from './walk-tab-switcher'; + +// 웹(dodo-frontend)과 동일하게 울타리는 원(중심+반경)만 지원하고 pet당 1개만 존재한다. +export type Fence = { + fenceId: number; + petId: number; + fenceName: string; latitude: number; longitude: number; + radiusMeters: number; + active: boolean; }; -export const MOCK_FENCES: PetFence[] = [ - { petId: 'sundubu', petName: '순두부', radiusMeters: 200, active: true, latitude: 37.5665, longitude: 126.978 }, - { petId: 'godeungeo', petName: '고등어', radiusMeters: 400, active: true, latitude: 37.5796, longitude: 126.977 }, - { petId: 'maltese', petName: '말티즈', radiusMeters: 250, active: true, latitude: 37.5636, longitude: 126.9834 }, - { petId: 'pold', petName: '폴드', radiusMeters: 180, active: false, latitude: 37.5711, longitude: 126.988 }, - { petId: 'puchi', petName: '푸치', radiusMeters: 300, active: true, latitude: 37.5704, longitude: 127.001 }, -]; +type Coord = { latitude: number; longitude: number }; + +// 아직 실시간 위치/펫 위치를 모를 때 지도 초기 중심 — 서울시청 좌표 +export const DEFAULT_CENTER: Coord = { latitude: 37.5665, longitude: 126.978 }; type RenderMapParams = { - selectedFence: PetFence; - fences: PetFence[]; + center: Coord; + fences: Fence[]; + selectedPetId: number | null; + liveLocation: FenceLocationPayload | null; + pendingCenter: Coord | null; + pendingRadius: number | null; + onTapMap: (coord: Coord) => void; }; type Props = { @@ -33,58 +51,254 @@ type Props = { * 화면을 분리하고, 그 차이만 이 render prop으로 주입한다. */ renderMap: (params: RenderMapParams) => ReactNode; + activeTab: WalkTab; + onChangeTab: (tab: WalkTab) => void; }; -export function FenceSettingsScreen({ renderMap }: Props) { - const [selectedPetId, setSelectedPetId] = useState(MOCK_FENCES[1].petId); - const selectedFence = MOCK_FENCES.find((f) => f.petId === selectedPetId) ?? MOCK_FENCES[0]; +export function FenceSettingsScreen({ renderMap, activeTab, onChangeTab }: Props) { const insets = useSafeAreaInsets(); + const queryClient = useQueryClient(); + + const petsQuery = useQuery({ queryKey: ['pets', 'list'], queryFn: () => getPetsList(0, 10) }); + const fencesQuery = useQuery({ queryKey: ['fence', 'boundaries'], queryFn: getFenceBoundaries }); + + const pets = useMemo(() => petsQuery.data?.pets ?? [], [petsQuery.data]); + // 네이티브 지도(NaverMap)는 좌표가 NaN이면 앱이 통째로 죽으므로(JS 에러가 아니라 네이티브 크래시), + // 백엔드 필드가 예상과 달라 undefined가 섞여도 절대 지도로 넘어가지 않도록 여기서 걸러낸다. + const fences: Fence[] = (fencesQuery.data?.boundaries ?? []) + .map((b) => ({ + fenceId: b.fenceId, + petId: b.petId, + fenceName: b.fenceName, + latitude: b.center?.latitude, + longitude: b.center?.longitude, + radiusMeters: b.radius, + active: b.isActive, + })) + .filter( + (f): f is Fence => Number.isFinite(f.latitude) && Number.isFinite(f.longitude) && Number.isFinite(f.radiusMeters), + ); + + const [selectedPetId, setSelectedPetId] = useState(null); + useEffect(() => { + if (selectedPetId === null && pets.length > 0) setSelectedPetId(pets[0].petId); + }, [pets, selectedPetId]); + + const selectedFence = fences.find((f) => f.petId === selectedPetId) ?? null; + const selectedPet = pets.find((p) => p.petId === selectedPetId) ?? null; + const liveLocation = useFenceLocationSocket(selectedPetId); + + const [isEditing, setIsEditing] = useState(false); + const [fenceNameInput, setFenceNameInput] = useState(''); + const [radiusInput, setRadiusInput] = useState(200); + const [pendingCenter, setPendingCenter] = useState(null); + + const startEditing = () => { + setFenceNameInput(selectedFence?.fenceName ?? (selectedPet ? `${selectedPet.name}의 울타리` : '')); + setRadiusInput(selectedFence?.radiusMeters ?? 200); + setPendingCenter( + selectedFence + ? { latitude: selectedFence.latitude, longitude: selectedFence.longitude } + : liveLocation + ? { latitude: liveLocation.latitude, longitude: liveLocation.longitude } + : DEFAULT_CENTER, + ); + setIsEditing(true); + }; + + const toggleMutation = useMutation({ + mutationFn: ({ fenceId, active }: { fenceId: number; active: boolean }) => toggleFence(fenceId, active), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['fence', 'boundaries'] }), + onError: () => Alert.alert('오류', '울타리 상태를 변경하지 못했어요.'), + }); + + const saveMutation = useMutation({ + mutationFn: async () => { + if (!pendingCenter || !selectedPetId) return; + if (selectedFence) { + await updateFence(selectedFence.fenceId, { + centerLatitude: pendingCenter.latitude, + centerLongitude: pendingCenter.longitude, + fenceName: fenceNameInput.trim(), + radius: radiusInput, + }); + } else { + await createFence({ + petId: selectedPetId, + centerLatitude: pendingCenter.latitude, + centerLongitude: pendingCenter.longitude, + radius: radiusInput, + fenceName: fenceNameInput.trim(), + }); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fence', 'boundaries'] }); + setIsEditing(false); + }, + onError: () => Alert.alert('오류', '울타리를 저장하지 못했어요. 잠시 후 다시 시도해주세요.'), + }); + + const rawMapCenter: Coord = + pendingCenter ?? + (selectedFence + ? { latitude: selectedFence.latitude, longitude: selectedFence.longitude } + : liveLocation + ? { latitude: liveLocation.latitude, longitude: liveLocation.longitude } + : DEFAULT_CENTER); + // 네이티브 지도 크래시(NaN 좌표) 방지용 최종 안전망 + const mapCenter: Coord = + Number.isFinite(rawMapCenter.latitude) && Number.isFinite(rawMapCenter.longitude) ? rawMapCenter : DEFAULT_CENTER; + + // 웹과 동일하게 서버의 insideFence 값을 그대로 쓰지 않고 fenceActive와 조합해 클라이언트에서 재계산 + const showOutsideBanner = !!selectedFence?.active && !!liveLocation && !liveLocation.insideFence; + + if (petsQuery.isLoading || fencesQuery.isLoading) { + return ( + + + + + ); + } + + if (pets.length === 0) { + return ( + + + + 산책 울타리를 설정하려면 먼저 반려동물을 등록해주세요. + + + ); + } return ( 산책 반려동물 안전 울타리를 설정하세요. + 울타리 설정 반려동물 선택 - {MOCK_FENCES.map((fence) => { - const selected = fence.petId === selectedPetId; + {pets.map((pet) => { + const selected = pet.petId === selectedPetId; return ( setSelectedPetId(fence.petId)} + onPress={() => { + setSelectedPetId(pet.petId); + setIsEditing(false); + }} > - {fence.petName} + {pet.name} ); })} - {renderMap({ selectedFence, fences: MOCK_FENCES })} + {renderMap({ + center: mapCenter, + fences, + selectedPetId, + liveLocation, + pendingCenter: isEditing ? pendingCenter : null, + pendingRadius: isEditing ? radiusInput : null, + onTapMap: (coord) => { + if (isEditing) setPendingCenter(coord); + }, + })} - - - - {selectedFence.petName} - - - {selectedFence.active ? '사용 중' : '미사용'} - + {showOutsideBanner && ( + + + {selectedPet?.name}이(가) 울타리를 벗어났어요! (약 {Math.round(liveLocation?.distanceMeter ?? 0)}m) + + + )} + + {!isEditing ? ( + selectedFence ? ( + + + + {selectedFence.fenceName} + + toggleMutation.mutate({ fenceId: selectedFence.fenceId, active: !selectedFence.active }) + } + style={[ + styles.statusBadge, + selectedFence.active ? styles.statusBadgeActive : styles.statusBadgeIdle, + ]} + > + + {selectedFence.active ? '사용 중' : '미사용'} + + + + 반경 {selectedFence.radiusMeters}m + + 울타리 수정 + + + ) : ( + + {selectedPet?.name}에게 설정된 울타리가 없어요. + + 울타리 생성 + + + ) + ) : ( + + 울타리 이름 + + + 반경 {radiusInput}m · 지도를 눌러 중심을 옮길 수 있어요 + + setRadiusInput((r) => Math.max(50, r - 50))} + > + -50m + + {radiusInput}m + setRadiusInput((r) => Math.min(2000, r + 50))} + > + +50m + + + + + setIsEditing(false)}> + 취소 + + saveMutation.mutate()} + > + {saveMutation.isPending ? '저장 중...' : '저장'} + - 반경 {selectedFence.radiusMeters}m - - 울타리 수정 - - + )} @@ -96,6 +310,16 @@ export const styles = StyleSheet.create({ flex: 1, backgroundColor: DodoColors.background, }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + padding: 32, + }, + emptyText: { + fontSize: 13, + color: DodoColors.fenceIdleLabel, + textAlign: 'center', + }, content: { padding: 16, paddingBottom: 32, @@ -160,6 +384,17 @@ export const styles = StyleSheet.create({ overflow: 'hidden', marginBottom: 16, }, + outsideBanner: { + backgroundColor: '#fee2e2', + borderRadius: 10, + padding: 12, + marginBottom: 16, + }, + outsideBannerText: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.fenceOutside, + }, fenceInfoRow: { flexDirection: 'row', alignItems: 'center', @@ -178,6 +413,11 @@ export const styles = StyleSheet.create({ fontWeight: '700', color: DodoColors.textPrimary, }, + emptyFenceText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + flex: 1, + }, statusBadge: { paddingHorizontal: 8, paddingVertical: 2, @@ -213,4 +453,76 @@ export const styles = StyleSheet.create({ fontWeight: '600', color: DodoColors.textSecondary, }, + editForm: { + gap: 8, + }, + input: { + height: 42, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + backgroundColor: DodoColors.background, + paddingHorizontal: 12, + fontSize: 13, + color: DodoColors.textPrimary, + marginBottom: 4, + }, + radiusStepperRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginBottom: 4, + }, + stepperButton: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: DodoColors.border, + }, + stepperButtonText: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + radiusValue: { + fontSize: 14, + fontWeight: '700', + color: DodoColors.textPrimary, + }, + editActionsRow: { + flexDirection: 'row', + gap: 8, + marginTop: 8, + }, + secondaryButton: { + flex: 1, + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: DodoColors.border, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + primaryButton: { + flex: 1.4, + height: 44, + borderRadius: 10, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonDisabled: { + opacity: 0.5, + }, + primaryButtonText: { + fontSize: 13, + fontWeight: '700', + color: DodoColors.brandForeground, + }, }); diff --git a/components/walk/walk-tab-switcher.tsx b/components/walk/walk-tab-switcher.tsx new file mode 100644 index 0000000..ece6b0b --- /dev/null +++ b/components/walk/walk-tab-switcher.tsx @@ -0,0 +1,58 @@ +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +export type WalkTab = 'fence' | 'activity'; + +type Props = { + value: WalkTab; + onChange: (tab: WalkTab) => void; +}; + +export function WalkTabSwitcher({ value, onChange }: Props) { + return ( + + onChange('fence')}> + 울타리 + + onChange('activity')} + > + 산책 활동 + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + backgroundColor: DodoColors.background, + borderRadius: 999, + padding: 3, + marginBottom: 16, + }, + item: { + flex: 1, + paddingVertical: 9, + borderRadius: 999, + alignItems: 'center', + }, + itemActive: { + backgroundColor: DodoColors.surface, + shadowColor: '#000', + shadowOpacity: 0.08, + shadowRadius: 4, + shadowOffset: { width: 0, height: 1 }, + elevation: 1, + }, + text: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.fenceIdleLabel, + }, + textActive: { + color: DodoColors.textPrimary, + }, +}); From b64bddea0acd53d02093b5c5da7c82ff6c562758 Mon Sep 17 00:00:00 2001 From: sooloin Date: Wed, 9 Sep 2026 22:46:10 +0900 Subject: [PATCH 17/24] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Chore:=20expo-locati?= =?UTF-8?q?on=C2=B7expo-dev-client=C2=B7fast-text-encoding=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.json | 6 ++++ package.json | 3 ++ yarn.lock | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/app.json b/app.json index 787249d..728e079 100644 --- a/app.json +++ b/app.json @@ -56,6 +56,12 @@ "microphonePermission": false } ], + [ + "expo-location", + { + "locationAlwaysAndWhenInUsePermission": "산책 중 현재 위치를 지도에 표시하려면 위치 접근 권한이 필요해요." + } + ], "./plugins/withNaverMapsRepository.js" ], "experiments": { diff --git a/package.json b/package.json index 7285b40..4ad0606 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,13 @@ "expo-auth-session": "~7.0.11", "expo-constants": "~18.0.13", "expo-crypto": "~15.0.9", + "expo-dev-client": "~6.0.21", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.11", + "expo-location": "~19.0.8", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", @@ -41,6 +43,7 @@ "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", "expo-web-browser": "~15.0.10", + "fast-text-encoding": "^1.0.6", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", diff --git a/yarn.lock b/yarn.lock index 95bfc3f..204f549 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2218,6 +2218,16 @@ ajv@^6.14.0: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.11.0: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + anser@^1.4.9: version "1.4.10" resolved "https://registry.yarnpkg.com/anser/-/anser-1.4.10.tgz#befa3eddf282684bd03b63dcda3927aef8c2e35b" @@ -3681,6 +3691,38 @@ expo-crypto@~15.0.9: dependencies: base64-js "^1.3.0" +expo-dev-client@~6.0.21: + version "6.0.21" + resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-6.0.21.tgz#244f2c6b618e0a3eead8cc25af3583dad30864f4" + integrity sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ== + dependencies: + expo-dev-launcher "6.0.21" + expo-dev-menu "7.0.19" + expo-dev-menu-interface "2.0.0" + expo-manifests "~1.0.11" + expo-updates-interface "~2.0.0" + +expo-dev-launcher@6.0.21: + version "6.0.21" + resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-6.0.21.tgz#66e4ce9f5c0f35cd359f3f27831fb0ca39cb7e16" + integrity sha512-QZ9gcKMZbp6EsIhzS0QoGB8Cf4xeVJhjbNgWUwcoBIk8gshoFz8CkCQOnX+HNv2sSY3rdCaNpx3Xo0Rflyq7rA== + dependencies: + ajv "^8.11.0" + expo-dev-menu "7.0.19" + expo-manifests "~1.0.11" + +expo-dev-menu-interface@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz#c0d6db65eb4abc44a2701bc2303744619ad05ca6" + integrity sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw== + +expo-dev-menu@7.0.19: + version "7.0.19" + resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-7.0.19.tgz#0a95758e99a857d2a63858a47b3d6ab0bcd7120a" + integrity sha512-ju5MZiBCPhUKKvHy0ElZdnlhq01mkEEiR8jfrgQVvW26aWjzjLiOhppNAyXtvGbhk7WxJim3wYMiqFFrjGdfKA== + dependencies: + expo-dev-menu-interface "2.0.0" + expo-file-system@~19.0.22: version "19.0.22" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b" @@ -3715,6 +3757,11 @@ expo-image@~3.0.11: resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-3.0.11.tgz#54195565dc710e632c10414c3609deebb7149ac5" integrity sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA== +expo-json-utils@~0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-0.15.0.tgz#6723574814b9e6b0a90e4e23662be76123ab6ae9" + integrity sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ== + expo-keep-awake@~15.0.8: version "15.0.8" resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d" @@ -3728,6 +3775,19 @@ expo-linking@~8.0.11, expo-linking@~8.0.12: expo-constants "~18.0.13" invariant "^2.2.4" +expo-location@~19.0.8: + version "19.0.8" + resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-19.0.8.tgz#1805393151b1286021c1ad36246b6fd095d09b55" + integrity sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA== + +expo-manifests@~1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.11.tgz#ffb21e433526a4daa7b3709b852df436e8fe46d1" + integrity sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw== + dependencies: + "@expo/config" "~12.0.13" + expo-json-utils "~0.15.0" + expo-modules-autolinking@3.0.25: version "3.0.25" resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz#3cb6e88fb491f15e3152c03eb64964e0b22fb707" @@ -3814,6 +3874,11 @@ expo-system-ui@~6.0.9: "@react-native/normalize-colors" "0.81.5" debug "^4.3.2" +expo-updates-interface@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz#7721cb64c37bcb46b23827b2717ef451a9378749" + integrity sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg== + expo-web-browser@~15.0.10, expo-web-browser@~15.0.11: version "15.0.11" resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-15.0.11.tgz#6a8134e2398031ef79c89f516b15a18103903ef5" @@ -3866,6 +3931,16 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-text-encoding@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867" + integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w== + +fast-uri@^3.0.1: + version "3.1.7" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a" + integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg== + fb-watchman@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" @@ -4772,6 +4847,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" From 160ab051a9b769d185e7cb8524d214714c672bf8 Mon Sep 17 00:00:00 2001 From: sooloin Date: Wed, 9 Sep 2026 22:47:13 +0900 Subject: [PATCH 18/24] =?UTF-8?q?=F0=9F=90=9E=20Fix:=20=EB=B0=98=EB=A0=A4?= =?UTF-8?q?=EB=8F=99=EB=AC=BC=20=EC=9D=B4=EB=A6=84=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B6=88=ED=99=95=EC=8B=A4=EC=84=B1=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/api/petApi.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/shared/api/petApi.ts b/shared/api/petApi.ts index e710181..f14c094 100644 --- a/shared/api/petApi.ts +++ b/shared/api/petApi.ts @@ -15,7 +15,9 @@ export interface FamilyMember { export interface PetListItem { petId: number; - name: string; + /** 실제 필드명이 name/petName 중 무엇인지 응답으로 재확인되지 않아 둘 다 방어적으로 받는다 — getPetListName()으로 읽을 것 */ + name?: string; + petName?: string; imageFileUrl: string | null; breed: string; age: number; @@ -23,6 +25,10 @@ export interface PetListItem { sex: PetSex; } +export function getPetListName(pet: PetListItem): string { + return pet.petName ?? pet.name ?? ''; +} + export interface GetPetsListResponse { pets: PetListItem[]; } From a4dc18db042c8c9229d9302865d7030a2058a619 Mon Sep 17 00:00:00 2001 From: sooloin Date: Wed, 9 Sep 2026 22:48:16 +0900 Subject: [PATCH 19/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=8F=84=20=ED=8E=AB=20=EC=9D=B4=EB=A6=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=A9=EC=96=B4=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B5=9C=EA=B7=BC=20=ED=99=9C=EB=8F=99=20=EC=9A=94=EC=95=BD?= =?UTF-8?q?=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/mypage/family/index.tsx | 11 +++-- app/(tabs)/mypage/pets/[petId]/index.tsx | 63 ++++++++++++++++++++++-- app/(tabs)/mypage/pets/index.tsx | 4 +- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/app/(tabs)/mypage/family/index.tsx b/app/(tabs)/mypage/family/index.tsx index ef979b5..8177ec2 100644 --- a/app/(tabs)/mypage/family/index.tsx +++ b/app/(tabs)/mypage/family/index.tsx @@ -7,7 +7,7 @@ import { ActivityIndicator, Alert, ScrollView, StyleSheet, Text, TouchableOpacit import { DodoColors } from '@/constants/theme'; import { createInvitationCode, getPendingUsers } from '@/shared/api/familyApi'; -import { getPetDetail, getPetsList } from '@/shared/api/petApi'; +import { getPetDetail, getPetListName, getPetsList } from '@/shared/api/petApi'; import { getInvitationCode, saveInvitationCode } from '@/shared/lib/family/invitationCodeCache'; export default function FamilyScreen() { @@ -43,7 +43,10 @@ export default function FamilyScreen() { onSuccess: async ({ code, expiresIn }) => { await saveInvitationCode(selectedPetId as number, code, expiresIn); setCachedCode({ code, expiresAt: Date.now() + expiresIn * 1000 }); - Alert.alert('초대 코드 발급', `${selectedPet?.name}의 초대 코드가 발급됐어요.\n\n${code}`); + Alert.alert( + '초대 코드 발급', + `${selectedPet ? getPetListName(selectedPet) : ''}의 초대 코드가 발급됐어요.\n\n${code}`, + ); }, onError: () => Alert.alert('오류', '초대 코드를 발급하지 못했어요.'), }); @@ -90,7 +93,7 @@ export default function FamilyScreen() { style={[styles.chip, selected && styles.chipSelected]} onPress={() => setSelectedPetId(pet.petId)} > - {pet.name} + {getPetListName(pet)} ); })} @@ -104,7 +107,7 @@ export default function FamilyScreen() { - {selectedPet.name} + {getPetListName(selectedPet)} 가족 {members.length}명 diff --git a/app/(tabs)/mypage/pets/[petId]/index.tsx b/app/(tabs)/mypage/pets/[petId]/index.tsx index 64285f5..1262c3f 100644 --- a/app/(tabs)/mypage/pets/[petId]/index.tsx +++ b/app/(tabs)/mypage/pets/[petId]/index.tsx @@ -5,7 +5,8 @@ import { Link, useLocalSearchParams, useRouter } from 'expo-router'; import { ActivityIndicator, Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; -import { getPetDetail, getWeightHistory, leavePetFamily } from '@/shared/api/petApi'; +import { getPetDetail, getWeightHistory, leavePetFamily, type PetActivity } from '@/shared/api/petApi'; +import { formatFullDateTime } from '@/shared/lib/format/date'; const NOTE_TYPE_LABEL: Record = { ALLERGY: '알레르기', @@ -17,10 +18,42 @@ const NOTE_TYPE_LABEL: Record = { ETC: '기타', }; +// 백엔드가 measuredAt 외에 어떤 필드를 더 주는지 확정되지 않아, 알려진 키는 한글 라벨로 보여주고 나머지는 원래 키를 그대로 보여준다. +const ACTIVITY_FIELD_LABEL: Record = { + activityType: '활동 종류', + distance: '이동 거리', + duration: '소요 시간', + steps: '걸음 수', + calories: '소모 칼로리', + heartRate: '심박수', + avgHeartRate: '평균 심박수', +}; + function speciesLabel(species: 'CANINE' | 'FELINE') { return species === 'CANINE' ? '강아지' : '고양이'; } +function LastActivitySummary({ activity }: { activity: PetActivity }) { + const { measuredAt, ...rest } = activity; + const entries = Object.entries(rest).filter(([, value]) => value !== null && value !== undefined && value !== ''); + + return ( + + {measuredAt && {formatFullDateTime(measuredAt)}} + {entries.length === 0 ? ( + 세부 활동 정보가 없어요. + ) : ( + entries.map(([key, value]) => ( + + {ACTIVITY_FIELD_LABEL[key] ?? key} + {String(value)} + + )) + )} + + ); +} + export default function PetDetailScreen() { const { petId } = useLocalSearchParams<{ petId: string }>(); const router = useRouter(); @@ -168,9 +201,11 @@ export default function PetDetailScreen() { 최근 활동 - - {pet.lastActivity ? JSON.stringify(pet.lastActivity) : '최근 활동 정보가 없습니다.'} - + {pet.lastActivity ? ( + + ) : ( + 최근 활동 정보가 없습니다. + )} @@ -370,6 +405,26 @@ const styles = StyleSheet.create({ fontSize: 12, color: DodoColors.fenceIdleLabel, }, + activityCol: { + gap: 6, + }, + activityDate: { + fontSize: 11, + color: DodoColors.fenceIdleLabel, + }, + activityRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + activityKey: { + fontSize: 12, + color: DodoColors.textSecondary, + }, + activityValue: { + fontSize: 12, + fontWeight: '600', + color: DodoColors.textPrimary, + }, notesSummary: { fontSize: 12, color: DodoColors.textSecondary, diff --git a/app/(tabs)/mypage/pets/index.tsx b/app/(tabs)/mypage/pets/index.tsx index 1fb8343..a21aa67 100644 --- a/app/(tabs)/mypage/pets/index.tsx +++ b/app/(tabs)/mypage/pets/index.tsx @@ -4,7 +4,7 @@ import { Link } from 'expo-router'; import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { DodoColors } from '@/constants/theme'; -import { getPetsList, type PetSex } from '@/shared/api/petApi'; +import { getPetListName, getPetsList, type PetSex } from '@/shared/api/petApi'; function GenderIcon({ sex }: { sex: PetSex }) { if (sex === 'NEUTER') return null; @@ -69,7 +69,7 @@ export default function PetListScreen() { - {pet.name} + {getPetListName(pet)} 만 {pet.age}세 From f0c74e1153761ff8ce199db4fbc7ddd7991d4083 Mon Sep 17 00:00:00 2001 From: sooloin Date: Wed, 9 Sep 2026 22:49:24 +0900 Subject: [PATCH 20/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=82=B0=EC=B1=85=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=EC=97=90=20=EC=8B=A4=EC=A0=9C=20GPS=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=C2=B7=EC=9A=B8=ED=83=80=EB=A6=AC=20=EC=83=89=EC=83=81?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/walk/activity-map.tsx | 8 +++-- components/walk/activity-tracking-screen.tsx | 30 +++++++++++-------- components/walk/fence-map.tsx | 18 ++++++++++-- components/walk/fence-settings-screen.tsx | 19 +++++++----- shared/lib/geo/useCurrentLocation.ts | 31 ++++++++++++++++++++ 5 files changed, 82 insertions(+), 24 deletions(-) create mode 100644 shared/lib/geo/useCurrentLocation.ts diff --git a/components/walk/activity-map.tsx b/components/walk/activity-map.tsx index 362493a..52e6ec3 100644 --- a/components/walk/activity-map.tsx +++ b/components/walk/activity-map.tsx @@ -16,7 +16,7 @@ export function ActivityMap({ center, points, currentLocation, startPoint }: Pro return ( {points.length > 1 && } @@ -25,7 +25,9 @@ export function ActivityMap({ center, points, currentLocation, startPoint }: Pro latitude={startPoint.latitude} longitude={startPoint.longitude} image={{ symbol: 'green' }} - caption={{ text: '출발' }} + width={22} + height={30} + caption={{ text: '출발', textSize: 11 }} /> )} {currentLocation && ( @@ -33,6 +35,8 @@ export function ActivityMap({ center, points, currentLocation, startPoint }: Pro latitude={currentLocation.latitude} longitude={currentLocation.longitude} image={{ symbol: 'red' }} + width={22} + height={30} /> )} diff --git a/components/walk/activity-tracking-screen.tsx b/components/walk/activity-tracking-screen.tsx index d45662c..1935508 100644 --- a/components/walk/activity-tracking-screen.tsx +++ b/components/walk/activity-tracking-screen.tsx @@ -15,9 +15,10 @@ import { inferActivityStatus, startActivityHistory, } from '@/shared/api/activityApi'; -import { getPetsList } from '@/shared/api/petApi'; +import { getPetListName, getPetsList } from '@/shared/api/petApi'; import { durationSecondsBetween, formatDurationSeconds } from '@/shared/lib/format/date'; import { totalRouteDistanceMeters } from '@/shared/lib/geo/haversine'; +import { useCurrentLocation } from '@/shared/lib/geo/useCurrentLocation'; import { useActivityLocationSocket } from '@/shared/lib/ws/useActivityLocationSocket'; import { useFenceLocationSocket } from '@/shared/lib/ws/useFenceLocationSocket'; @@ -53,6 +54,8 @@ export function ActivityTrackingScreen({ activeTab, onChangeTab }: Props) { // 펫 트래커 위치(시작 좌표 확보용) — 울타리 기능과 같은 채널, petId 기준 const liveLocation = useFenceLocationSocket(selectedPetId); + // 폰의 실제 GPS 위치 — 펫 트래커 위치가 아직 없을 때 지도/시작 좌표의 대체값으로 쓴다 + const currentUserLocation = useCurrentLocation(); // 산책 활동 실시간 경로 — historyId 기준 별도 채널(백엔드 확인됨). 진행 중일 때만 구독한다. const activityRoutePoint = useActivityLocationSocket(trackingState === 'in_progress' ? liveHistoryId : null); const activityCoord: Coord | null = activityRoutePoint @@ -131,7 +134,7 @@ export function ActivityTrackingScreen({ activeTab, onChangeTab }: Props) { } const coord: Coord = liveLocation ? { latitude: liveLocation.latitude, longitude: liveLocation.longitude } - : DEFAULT_CENTER; + : (currentUserLocation ?? DEFAULT_CENTER); await startActivityHistory(historyId, { startLatitude: coord.latitude, startLongitude: coord.longitude }); return { historyId, coord }; }, @@ -190,7 +193,8 @@ export function ActivityTrackingScreen({ activeTab, onChangeTab }: Props) { const selectedPet = pets.find((p) => p.petId === selectedPetId) ?? null; const isTracking = trackingState !== 'idle'; - const mapCenter = activityCoord ?? routePoints[routePoints.length - 1] ?? liveLocation ?? DEFAULT_CENTER; + const mapCenter = + activityCoord ?? routePoints[routePoints.length - 1] ?? liveLocation ?? currentUserLocation ?? DEFAULT_CENTER; if (petsQuery.isLoading) { return ( @@ -226,20 +230,18 @@ export function ActivityTrackingScreen({ activeTab, onChangeTab }: Props) { style={[styles.chip, selected && styles.chipSelected, isTracking && styles.chipDisabled]} onPress={() => setSelectedPetId(pet.petId)} > - {pet.name} + {getPetListName(pet)} ); })} - {isTracking && ( - - )} + {isTracking && ( @@ -265,7 +267,9 @@ export function ActivityTrackingScreen({ activeTab, onChangeTab }: Props) { onPress={() => startMutation.mutate()} > - {startMutation.isPending ? '시작하는 중...' : `${selectedPet?.name ?? ''} 산책 시작`} + {startMutation.isPending + ? '시작하는 중...' + : `${selectedPet ? getPetListName(selectedPet) : ''} 산책 시작`} ) : trackingState === 'in_progress' ? ( diff --git a/components/walk/fence-map.tsx b/components/walk/fence-map.tsx index e18dd92..99b444c 100644 --- a/components/walk/fence-map.tsx +++ b/components/walk/fence-map.tsx @@ -36,15 +36,29 @@ export function FenceMap({ > {fences.map((fence) => { const selected = fence.petId === selectedPetId; + // 미사용 울타리는 선택 여부와 무관하게 항상 회색으로 표시 — active 상태가 색상의 1순위 기준 + if (!fence.active) { + return ( + + ); + } return ( ); })} diff --git a/components/walk/fence-settings-screen.tsx b/components/walk/fence-settings-screen.tsx index 1b0f853..d185cc2 100644 --- a/components/walk/fence-settings-screen.tsx +++ b/components/walk/fence-settings-screen.tsx @@ -14,7 +14,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DodoColors } from '@/constants/theme'; import { createFence, getFenceBoundaries, toggleFence, updateFence } from '@/shared/api/fenceApi'; -import { getPetsList } from '@/shared/api/petApi'; +import { getPetListName, getPetsList } from '@/shared/api/petApi'; +import { useCurrentLocation } from '@/shared/lib/geo/useCurrentLocation'; import { useFenceLocationSocket, type FenceLocationPayload } from '@/shared/lib/ws/useFenceLocationSocket'; import { WalkTabSwitcher, type WalkTab } from './walk-tab-switcher'; @@ -87,6 +88,7 @@ export function FenceSettingsScreen({ renderMap, activeTab, onChangeTab }: Props const selectedFence = fences.find((f) => f.petId === selectedPetId) ?? null; const selectedPet = pets.find((p) => p.petId === selectedPetId) ?? null; const liveLocation = useFenceLocationSocket(selectedPetId); + const currentUserLocation = useCurrentLocation(); const [isEditing, setIsEditing] = useState(false); const [fenceNameInput, setFenceNameInput] = useState(''); @@ -94,14 +96,14 @@ export function FenceSettingsScreen({ renderMap, activeTab, onChangeTab }: Props const [pendingCenter, setPendingCenter] = useState(null); const startEditing = () => { - setFenceNameInput(selectedFence?.fenceName ?? (selectedPet ? `${selectedPet.name}의 울타리` : '')); + setFenceNameInput(selectedFence?.fenceName ?? (selectedPet ? `${getPetListName(selectedPet)}의 울타리` : '')); setRadiusInput(selectedFence?.radiusMeters ?? 200); setPendingCenter( selectedFence ? { latitude: selectedFence.latitude, longitude: selectedFence.longitude } : liveLocation ? { latitude: liveLocation.latitude, longitude: liveLocation.longitude } - : DEFAULT_CENTER, + : (currentUserLocation ?? DEFAULT_CENTER), ); setIsEditing(true); }; @@ -145,7 +147,7 @@ export function FenceSettingsScreen({ renderMap, activeTab, onChangeTab }: Props ? { latitude: selectedFence.latitude, longitude: selectedFence.longitude } : liveLocation ? { latitude: liveLocation.latitude, longitude: liveLocation.longitude } - : DEFAULT_CENTER); + : (currentUserLocation ?? DEFAULT_CENTER)); // 네이티브 지도 크래시(NaN 좌표) 방지용 최종 안전망 const mapCenter: Coord = Number.isFinite(rawMapCenter.latitude) && Number.isFinite(rawMapCenter.longitude) ? rawMapCenter : DEFAULT_CENTER; @@ -196,7 +198,7 @@ export function FenceSettingsScreen({ renderMap, activeTab, onChangeTab }: Props setIsEditing(false); }} > - {pet.name} + {getPetListName(pet)} ); })} @@ -217,7 +219,8 @@ export function FenceSettingsScreen({ renderMap, activeTab, onChangeTab }: Props {showOutsideBanner && ( - {selectedPet?.name}이(가) 울타리를 벗어났어요! (약 {Math.round(liveLocation?.distanceMeter ?? 0)}m) + {selectedPet ? getPetListName(selectedPet) : ''}이(가) 울타리를 벗어났어요! (약{' '} + {Math.round(liveLocation?.distanceMeter ?? 0)}m) )} @@ -251,7 +254,9 @@ export function FenceSettingsScreen({ renderMap, activeTab, onChangeTab }: Props ) : ( - {selectedPet?.name}에게 설정된 울타리가 없어요. + + {selectedPet ? getPetListName(selectedPet) : ''}에게 설정된 울타리가 없어요. + 울타리 생성 diff --git a/shared/lib/geo/useCurrentLocation.ts b/shared/lib/geo/useCurrentLocation.ts new file mode 100644 index 0000000..7527feb --- /dev/null +++ b/shared/lib/geo/useCurrentLocation.ts @@ -0,0 +1,31 @@ +import * as Location from 'expo-location'; +import { useEffect, useState } from 'react'; + +type Coord = { latitude: number; longitude: number }; + +/** 사용자(폰) 실제 GPS 위치. 권한이 없거나 조회 실패 시 null — 호출부에서 기본 좌표로 대체할 것. */ +export function useCurrentLocation(): Coord | null { + const [location, setLocation] = useState(null); + + useEffect(() => { + let cancelled = false; + + (async () => { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted' || cancelled) return; + + const position = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }).catch( + () => null, + ); + if (!cancelled && position) { + setLocation({ latitude: position.coords.latitude, longitude: position.coords.longitude }); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + return location; +} From 6534e874492deb8c4adcd3b7639cc6ac61225f2c Mon Sep 17 00:00:00 2001 From: sooloin Date: Wed, 9 Sep 2026 22:50:23 +0900 Subject: [PATCH 21/24] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=C2=B7=EC=83=81=EC=84=B8=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=20=ED=9B=84=20=EB=AA=A9=EB=A1=9D=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/community/[boardId].tsx | 20 +++++++++++++------- app/(tabs)/community/index.tsx | 1 + app/(tabs)/community/my.tsx | 1 + app/(tabs)/community/new.tsx | 4 +++- components/community/post-row.tsx | 12 +++++++++--- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/app/(tabs)/community/[boardId].tsx b/app/(tabs)/community/[boardId].tsx index cd87f4e..8d9485c 100644 --- a/app/(tabs)/community/[boardId].tsx +++ b/app/(tabs)/community/[boardId].tsx @@ -1,5 +1,6 @@ import Ionicons from '@expo/vector-icons/Ionicons'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Image } from 'expo-image'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useState } from 'react'; import { @@ -168,10 +169,12 @@ export default function BoardDetailScreen() { {post.boardTitle} - {post.imageFileUrls.length === 0 && ( - - - + {post.imageFileUrls.length > 0 && ( + + {post.imageFileUrls.map((url) => ( + + ))} + )} {post.boardContent} @@ -381,15 +384,18 @@ const styles = StyleSheet.create({ fontWeight: '700', color: DodoColors.textPrimary, }, + photoRow: { + marginHorizontal: -16, + paddingHorizontal: 16, + }, photo: { - width: '100%', + width: 260, height: 220, borderRadius: 16, backgroundColor: DodoColors.surface, borderWidth: 1, borderColor: DodoColors.border, - alignItems: 'center', - justifyContent: 'center', + marginRight: 8, }, body: { fontSize: 14, diff --git a/app/(tabs)/community/index.tsx b/app/(tabs)/community/index.tsx index 3ca66b7..e54d194 100644 --- a/app/(tabs)/community/index.tsx +++ b/app/(tabs)/community/index.tsx @@ -20,6 +20,7 @@ function toBoardPost(board: BoardListItem): BoardPost { likeCount: board.likeCount, commentCount: board.commentCount, viewCount: board.viewCount, + thumbnailImageUrl: board.thumbnailImageUrl, }; } diff --git a/app/(tabs)/community/my.tsx b/app/(tabs)/community/my.tsx index ac4a9ea..eda778e 100644 --- a/app/(tabs)/community/my.tsx +++ b/app/(tabs)/community/my.tsx @@ -18,6 +18,7 @@ function toBoardPost(board: BoardListItem): BoardPost { likeCount: board.likeCount, commentCount: board.commentCount, viewCount: board.viewCount, + thumbnailImageUrl: board.thumbnailImageUrl, }; } diff --git a/app/(tabs)/community/new.tsx b/app/(tabs)/community/new.tsx index f41be00..7c64453 100644 --- a/app/(tabs)/community/new.tsx +++ b/app/(tabs)/community/new.tsx @@ -1,5 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { @@ -22,6 +22,7 @@ const DRAFT_SESSION_KEY_STORAGE = 'dodo.boardDraft.sessionKey'; export default function NewPostScreen() { const router = useRouter(); + const queryClient = useQueryClient(); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [imageUrls, setImageUrls] = useState([]); @@ -47,6 +48,7 @@ export default function NewPostScreen() { mutationFn: () => createBoard({ boardTitle: title.trim(), boardContent: content.trim(), imageFileUrls: imageUrls }), onSuccess: async ({ boardId }) => { await AsyncStorage.removeItem(DRAFT_SESSION_KEY_STORAGE); + queryClient.invalidateQueries({ queryKey: ['boards'] }); router.replace({ pathname: '/(tabs)/community/[boardId]', params: { boardId: String(boardId) } }); }, onError: () => Alert.alert('오류', '게시글을 등록하지 못했어요. 잠시 후 다시 시도해주세요.'), diff --git a/components/community/post-row.tsx b/components/community/post-row.tsx index be58379..ae8b393 100644 --- a/components/community/post-row.tsx +++ b/components/community/post-row.tsx @@ -1,4 +1,5 @@ import Ionicons from '@expo/vector-icons/Ionicons'; +import { Image } from 'expo-image'; import { Link } from 'expo-router'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; @@ -13,6 +14,7 @@ export type BoardPost = { likeCount: number; commentCount: number; viewCount: number; + thumbnailImageUrl: string | null; }; export function PostRow({ post }: { post: BoardPost }) { @@ -44,9 +46,13 @@ export function PostRow({ post }: { post: BoardPost }) { 조회 {post.viewCount} - - - + {post.thumbnailImageUrl ? ( + + ) : ( + + + + )} ); From 924ec325928427a042f234237de7e751f2a03a46 Mon Sep 17 00:00:00 2001 From: sooloin Date: Wed, 9 Sep 2026 22:51:25 +0900 Subject: [PATCH 22/24] =?UTF-8?q?=F0=9F=90=9E=20Fix:=20=EC=95=88=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=EC=9D=B4=EB=93=9C=20STOMP=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=ED=94=84=EB=A0=88=EC=9E=84=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20TextEncoder=20=ED=8F=B4=EB=A6=AC=ED=95=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/_layout.tsx | 3 +++ shared/lib/ws/useStompTopic.ts | 45 ++++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 4b980ed..33da527 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,3 +1,6 @@ +// Hermes에는 TextEncoder/TextDecoder가 내장되어 있지 않음 — @stomp/stompjs가 프레임 인코딩에 필수로 사용하므로 +// STOMP 관련 코드가 로드되기 전, 앱 진입 시점에 가장 먼저 폴리필해야 한다. +import 'fast-text-encoding'; import 'react-native-url-polyfill/auto'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; diff --git a/shared/lib/ws/useStompTopic.ts b/shared/lib/ws/useStompTopic.ts index a1aa9a1..eb77f8e 100644 --- a/shared/lib/ws/useStompTopic.ts +++ b/shared/lib/ws/useStompTopic.ts @@ -4,9 +4,6 @@ import { useEffect, useState } from 'react'; import { env } from '@/shared/config'; import * as tokenStorage from '@/shared/lib/auth/tokenStorage'; -// 공통 STOMP 구독 로직. destination이 null이면 연결하지 않고, 값이 바뀌면 기존 연결을 정리하고 새로 만든다. -// 서버 프레임은 전부 { type, payload } 모양을 공유한다는 전제(웹/백엔드 공통 계약). - interface Envelope { type: string; payload: T; @@ -22,23 +19,38 @@ export function useStompTopic(destination: string | null): T | null { } let cancelled = false; - // TODO: 실시간 안 뜨는 문제 진단용 임시 로그 — 확인되면 제거 + console.log('[stomp] connecting…', destination); + const client = new Client({ brokerURL: env.WS_URL, + + // Android React Native의 STOMP 프레임 처리 문제 우회 + forceBinaryWSFrames: true, + appendMissingNULLonIncoming: true, + reconnectDelay: 5000, heartbeatIncoming: 10000, heartbeatOutgoing: 10000, - debug: (str) => console.log('[stomp:frame]', destination, str), + + debug: (str) => { + const masked = str.replace(/Authorization:[^\r\n]*/gi, 'Authorization:[REDACTED]'); + console.log('[stomp:frame]', destination, masked); + }, + beforeConnect: async () => { const accessToken = await tokenStorage.getAccessToken(); client.connectHeaders = accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; }, + onConnect: () => { - console.log('[stomp] connected, subscribing', destination); if (cancelled) return; + + console.log('[stomp] connected, subscribing', destination); + client.subscribe(destination, (message: IMessage) => { console.log('[stomp] message', destination, message.body); + try { const parsed = JSON.parse(message.body) as Envelope; if (!cancelled) setData(parsed.payload); @@ -46,10 +58,23 @@ export function useStompTopic(destination: string | null): T | null { console.log('[stomp] parse error', destination, err); } }); + + client.subscribe('/user/queue/reply', (message: IMessage) => { + console.log('[stomp] /user/queue/reply message', message.body); + }); + }, + + onStompError: (frame) => { + console.log('[stomp] STOMP error', destination, frame.headers, frame.body); + }, + + onWebSocketError: (event) => { + console.log('[stomp] WS error', destination, event); + }, + + onWebSocketClose: (event) => { + console.log('[stomp] WS closed', destination, event.code, event.reason); }, - onStompError: (frame) => console.log('[stomp] STOMP error', destination, frame.headers, frame.body), - onWebSocketError: (event) => console.log('[stomp] WS error', destination, event), - onWebSocketClose: (event) => console.log('[stomp] WS closed', destination, event?.code, event?.reason), }); client.activate(); @@ -57,7 +82,7 @@ export function useStompTopic(destination: string | null): T | null { return () => { cancelled = true; setData(null); - client.deactivate(); + void client.deactivate(); }; }, [destination]); From ed6aedbf2e383962ac4231cc90f73ca07dc02edd Mon Sep 17 00:00:00 2001 From: sooloin Date: Wed, 9 Sep 2026 22:56:23 +0900 Subject: [PATCH 23/24] =?UTF-8?q?=F0=9F=90=9E=20Fix:=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=ED=8F=AC=ED=86=A0=20=ED=94=BC=EC=BB=A4=20=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EA=B8=B0=EA=B8=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=A0=ED=83=9D=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/lib/files/pickImage.ts | 42 ++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/shared/lib/files/pickImage.ts b/shared/lib/files/pickImage.ts index 9479e01..665e37c 100644 --- a/shared/lib/files/pickImage.ts +++ b/shared/lib/files/pickImage.ts @@ -11,14 +11,31 @@ async function ensureLibraryPermission(): Promise { } } +// 안드로이드 시스템 포토 피커(android.provider.action.PICK_IMAGES)가 없는 기기/에뮬레이터(Play 스토어 미포함 이미지 등)에서 +// launchImageLibraryAsync가 던지는 네이티브 에러를 사용자가 이해할 수 있는 메시지로 바꿔준다. +function toFriendlyPickerError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('PICK_IMAGES') || message.includes('ActivityNotFoundException')) { + return new Error( + '이 기기에서 사진 선택 기능을 사용할 수 없어요. Google Play 스토어가 포함된 기기/에뮬레이터인지, Google 포토(미디어 선택 도구)가 최신 버전인지 확인해주세요.', + ); + } + return error instanceof Error ? error : new Error(message); +} + /** 사진 1장 선택 (프로필/펫 등록 사진용). 취소 시 null. */ export async function pickSingleImage(): Promise { await ensureLibraryPermission(); - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ['images'], - quality: 0.8, - }); + let result; + try { + result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 0.8, + }); + } catch (error) { + throw toFriendlyPickerError(error); + } if (result.canceled || result.assets.length === 0) return null; @@ -31,12 +48,17 @@ export async function pickSingleImage(): Promise { export async function pickMultipleImages(maxCount: number): Promise { await ensureLibraryPermission(); - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ['images'], - quality: 0.8, - allowsMultipleSelection: true, - selectionLimit: maxCount, - }); + let result; + try { + result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 0.8, + allowsMultipleSelection: true, + selectionLimit: maxCount, + }); + } catch (error) { + throw toFriendlyPickerError(error); + } if (result.canceled || result.assets.length === 0) return []; From 459492cb0a91340851f8a56cb07801855874a5e7 Mon Sep 17 00:00:00 2001 From: sooloin Date: Wed, 9 Sep 2026 22:56:57 +0900 Subject: [PATCH 24/24] =?UTF-8?q?=F0=9F=90=9E=20Fix:=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=20=EB=AA=A8=EB=8B=AC=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=ED=96=89=20=EC=88=98=20=EA=B3=A0=EC=A0=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=86=92=EC=9D=B4=20=ED=9D=94=EB=93=A4=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/common/date-picker-modal.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/components/common/date-picker-modal.tsx b/components/common/date-picker-modal.tsx index 2de94e5..db607b6 100644 --- a/components/common/date-picker-modal.tsx +++ b/components/common/date-picker-modal.tsx @@ -59,10 +59,11 @@ export function DatePickerModal({ visible, initialDate, maxDate, minDate, onClos const startWeekday = firstOfMonth.getDay(); const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); - const cells: (number | null)[] = [ - ...Array.from({ length: startWeekday }, () => null), - ...Array.from({ length: daysInMonth }, (_, i) => i + 1), - ]; + // 항상 6행(42칸)으로 고정해서 달마다 일수가 달라져도 캘린더 높이가 흔들리지 않게 한다 + const leadingCells = Array.from({ length: startWeekday }, () => null); + const dayCells = Array.from({ length: daysInMonth }, (_, i) => i + 1); + const trailingCount = Math.max(0, 42 - leadingCells.length - dayCells.length); + const cells: (number | null)[] = [...leadingCells, ...dayCells, ...Array.from({ length: trailingCount }, () => null)]; const goPrevMonth = () => { if (viewMonth === 0) {