diff --git a/app.json b/app.json index 690818b..728e079 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,29 @@ "backgroundColor": "#000000" } } - ] + ], + [ + "@mj-studio/react-native-naver-map", + { + "client_id": "furxnmcb69" + } + ], + "expo-secure-store", + [ + "expo-image-picker", + { + "photosPermission": "사진을 선택하려면 앨범 접근 권한이 필요해요.", + "cameraPermission": false, + "microphonePermission": false + } + ], + [ + "expo-location", + { + "locationAlwaysAndWhenInUsePermission": "산책 중 현재 위치를 지도에 표시하려면 위치 접근 권한이 필요해요." + } + ], + "./plugins/withNaverMapsRepository.js" ], "experiments": { "typedRoutes": true, diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 495ca3a..ef1307f 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,34 +1,72 @@ +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 { 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(); + const insets = useSafeAreaInsets(); return ( , + title: '홈', + tabBarIcon: ({ color, focused }) => ( + + ), }} /> , + title: '산책', + tabBarIcon: ({ color, focused }) => ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), }} /> diff --git a/app/(tabs)/community/[boardId].tsx b/app/(tabs)/community/[boardId].tsx new file mode 100644 index 0000000..8d9485c --- /dev/null +++ b/app/(tabs)/community/[boardId].tsx @@ -0,0 +1,536 @@ +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 { + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { DodoColors } from '@/constants/theme'; +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 [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 ( + + + + + + + + + {post.nickname} + + {formatFullDateTime(post.boardCreatedAt)} + | + + 조회 {post.viewCount} + + + + + {isOwner && ( + + router.push({ pathname: '/(tabs)/community/[boardId]/edit', params: { boardId } })} + > + 수정 + + + 삭제 + + + )} + + + + {post.boardTitle} + + {post.imageFileUrls.length > 0 && ( + + {post.imageFileUrls.map((url) => ( + + ))} + + )} + + {post.boardContent} + + + + + reactMutation.mutate('LIKE')} + > + + 좋아요 {post.likeCount} + + reactMutation.mutate('DISLIKE')} + > + + 싫어요 {post.dislikeCount} + + + + 댓글 {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 + } + > + 다음 + + + + )} + + + + + commentMutation.mutate(comment.trim())} + > + 등록 + + + + + ); +} + +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, + }, + 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, + }, + photoRow: { + marginHorizontal: -16, + paddingHorizontal: 16, + }, + photo: { + width: 260, + height: 220, + borderRadius: 16, + backgroundColor: DodoColors.surface, + borderWidth: 1, + borderColor: DodoColors.border, + marginRight: 8, + }, + 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, + }, + reactionButtonActive: { + borderColor: DodoColors.brand, + backgroundColor: DodoColors.background, + }, + 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, + }, + 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', + 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/[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 new file mode 100644 index 0000000..ee52042 --- /dev/null +++ b/app/(tabs)/community/_layout.tsx @@ -0,0 +1,70 @@ +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..e54d194 --- /dev/null +++ b/app/(tabs)/community/index.tsx @@ -0,0 +1,187 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { Link } from 'expo-router'; +import { useMemo } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { BoardPost, PostListCard } from '@/components/community/post-row'; +import { DodoColors } from '@/constants/theme'; +import { getBoards, type BoardListItem } from '@/shared/api/communityApi'; +import { formatShortDate } from '@/shared/lib/format/date'; + +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, + thumbnailImageUrl: board.thumbnailImageUrl, + }; +} + +const PAGE_SIZE = 12; + +export default function CommunityListScreen() { + const insets = useSafeAreaInsets(); + + const boardsQuery = useInfiniteQuery({ + queryKey: ['boards'], + queryFn: ({ pageParam }) => 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 ( + + + + 커뮤니티 + + + + 내 활동 + + + + + {boardsQuery.isLoading ? ( + + ) : ( + <> + + POPULAR PICKS + 인기 게시물 + 지금 커뮤니티에서 반응이 좋은 이야기를 먼저 만나보세요. + + + + + COMMUNITY BOARD + 최근 게시물 + 반려생활 속 소소한 기록부터 유용한 팁까지 한눈에 둘러보세요. + + + {boardsQuery.hasNextPage && ( + boardsQuery.fetchNextPage()} + > + + {boardsQuery.isFetchingNextPage ? '불러오는 중...' : '더 보기'} + + + )} + + + )} + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + padding: 16, + paddingBottom: 96, + gap: 20, + }, + loading: { + marginTop: 40, + }, + 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, + }, + 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, + 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/my.tsx b/app/(tabs)/community/my.tsx new file mode 100644 index 0000000..eda778e --- /dev/null +++ b/app/(tabs)/community/my.tsx @@ -0,0 +1,249 @@ +import { Link } from 'expo-router'; +import { useState } from 'react'; +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'; + +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, + thumbnailImageUrl: board.thumbnailImageUrl, + }; +} + +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 ( + + MY COMMUNITY + 내 활동 + 내가 남긴 게시글과 댓글 활동을 한곳에서 확인해보세요. + + + setActiveTab('posts')}> + 내 게시글 + {activeTab === 'posts' && } + + setActiveTab('comments')}> + 내 댓글 + {activeTab === 'comments' && } + + + + {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)) + } + > + 이전 + + + {(activeTab === 'posts' ? postsPage : commentsPage) + 1} + + = commentsPageInfo.totalPages + } + onPress={() => (activeTab === 'posts' ? setPostsPage((p) => p + 1) : setCommentsPage((p) => p + 1))} + > + 다음 + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + 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, + 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, + }, + 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', + 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 new file mode 100644 index 0000000..7c64453 --- /dev/null +++ b/app/(tabs)/community/new.tsx @@ -0,0 +1,215 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { + 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 { createBoard, getTempSaveBoard, tempSaveBoard } from '@/shared/api/communityApi'; + +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([]); + 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); + queryClient.invalidateQueries({ queryKey: ['boards'] }); + 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 · 전체 최대 50MB + + + + * 필수 입력 항목입니다. + + + + router.back()}> + 취소 + + tempSaveMutation.mutate()} + > + {tempSaveMutation.isPending ? '저장 중...' : '임시 저장'} + + submitMutation.mutate()} + > + {submitMutation.isPending ? '게시 중...' : '게시하기'} + + + + ); +} + +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, + }, + 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)/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..2643318 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,82 +1,387 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; +import Ionicons from '@expo/vector-icons/Ionicons'; +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 { 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'; +import { getMain } from '@/shared/api/mainApi'; +import { useAuthStore } from '@/shared/lib/auth/authStore'; + +// 공지사항 UI는 웹에서도 죽은 데이터(announcement 필드가 화면에 렌더되지 않음)라 목데이터를 그대로 유지한다. +const MOCK_NOTICES: { tag: '안내' | '긴급'; title: string }[] = [ + { tag: '안내', title: '겨울 시즌 산책 챌린지 오픈!' }, + { tag: '긴급', title: '일부 알림 지연 현상 안내' }, + { tag: '안내', title: '산책 중 위험 지역 사용자 제보 요청' }, + { 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 ( - } - > - - 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')} /> - - - - - {`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. - - - + + + {/* AI 건강 레포트 */} + + + + AI 건강 레포트 + + + + + + + {latestReport ? ( + <> + {latestReport.healthReportTitle} + {latestReport.healthReportSummary} + + ) : ( + <> + {selectedPet?.name ?? '반려동물'}의 건강 데이터를 분석 중이에요. + + {selectedPet?.name ?? '반려동물'}의 첫 건강 레포트를 만들 수 있도록 산책과 건강 기록을 조금 더 + 쌓아보세요. + + + )} + + + {latestReport ? '레포트 자세히 보기' : '레포트 준비 중'} + + + + + {/* 펫 카드 */} + {selectedPet && ( + + + + + + + {selectedPet.name} + + + 만 {selectedPet.age}세 + + + + + + 품종 + {selectedPet.breed} + + + 체중 + {selectedPet.weight} kg + + + 성별 + {sexLabel(selectedPet.sex)} + + + + {petProfiles.length > 1 && ( + + {petProfiles.map((pet) => ( + setSelectedPetId(pet.petId)} + > + + + ))} + + )} + + )} + + {/* 공지사항 */} + + + 공지사항 + + + + + + {MOCK_NOTICES.map((notice, index) => ( + + + + {notice.tag} + + + + {notice.title} + + + ))} + + + ); } const styles = StyleSheet.create({ - titleContainer: { + container: { + 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, + 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/_layout.tsx b/app/(tabs)/mypage/_layout.tsx new file mode 100644 index 0000000..f55a2a2 --- /dev/null +++ b/app/(tabs)/mypage/_layout.tsx @@ -0,0 +1,52 @@ +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/apply.tsx b/app/(tabs)/mypage/family/apply.tsx new file mode 100644 index 0000000..ba409a2 --- /dev/null +++ b/app/(tabs)/mypage/family/apply.tsx @@ -0,0 +1,312 @@ +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 { + 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'; + +type FilterLabel = '전체' | '승인 대기' | '거절됨'; +const FILTERS: FilterLabel[] = ['전체', '승인 대기', '거절됨']; +const FILTER_STATUS: Record = { + 전체: undefined, + '승인 대기': 'PENDING', + 거절됨: 'REJECTED', +}; + +function statusLabel(status: FamilyApplicationStatus): string { + if (status === 'PENDING') return '승인 대기'; + if (status === 'APPROVED') return '승인됨'; + if (status === 'REJECTED') return '거절됨'; + return '차단됨'; +} + +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('전체'); + + const applicationsQuery = useQuery({ + queryKey: ['family', 'applications', filter], + queryFn: () => getFamilyApplications(FILTER_STATUS[filter], 0, 20), + }); + + const applyMutation = useMutation({ + mutationFn: () => joinFamily(code.trim()), + onSuccess: () => { + setCode(''); + queryClient.invalidateQueries({ queryKey: ['family', 'applications'] }); + Alert.alert('가족 신청', '가족 신청을 보냈어요.'); + }, + onError: () => Alert.alert('오류', '가족 신청에 실패했어요. 코드를 다시 확인해주세요.'), + }); + + const applications = applicationsQuery.data?.applications ?? []; + + return ( + + + + FAMILY + 가족 신청 관리 + + + + 메인 보기 + + + + + + 가족 신청 + + 가족 코드를 입력하면 신청할 수 있어요. 신청 상태는 내 신청 내역에서 확인해요. + + + setCode(text.toUpperCase())} + autoCapitalize="characters" + maxLength={6} + /> + applyMutation.mutate()} + > + {applyMutation.isPending ? '신청 중...' : '가족 신청'} + + + + + + + 내 신청 내역 + + {FILTERS.map((f) => { + const selected = f === filter; + return ( + setFilter(f)} + > + {f} + + ); + })} + + + + {applicationsQuery.isLoading ? ( + + ) : applications.length === 0 ? ( + 표시할 신청 내역이 없어요. + ) : ( + applications.map((request) => { + const style = statusStyle(request.status); + return ( + + + + + + + {request.petName} + + + {statusLabel(request.status)} + + + + {request.appliedAt} + + + ); + }) + )} + + + ); +} + +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', + }, + 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..8177ec2 --- /dev/null +++ b/app/(tabs)/mypage/family/index.tsx @@ -0,0 +1,429 @@ +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 { 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, getPetListName, getPetsList } from '@/shared/api/petApi'; +import { getInvitationCode, saveInvitationCode } from '@/shared/lib/family/invitationCodeCache'; + +export default function FamilyScreen() { + 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 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 ? getPetListName(selectedPet) : ''}의 초대 코드가 발급됐어요.\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 (pets.length === 0 || !selectedPet) { + return ( + + + 가족을 관리하려면 먼저 반려동물을 등록해주세요. + + + 반려동물 추가하기 + + + + ); + } + + return ( + + FAMILY + 반려동물 가족 관리 + + + SELECT PET + 가족을 관리할 반려동물을 선택해 주세요 + + {pets.map((pet) => { + const selected = pet.petId === selectedPetId; + return ( + setSelectedPetId(pet.petId)} + > + {getPetListName(pet)} + + ); + })} + + + + + + + + + + + {getPetListName(selectedPet)} + + 가족 {members.length}명 + + + 만 {selectedPet.age}세 + + {selectedPet.species === 'CANINE' ? '강아지' : '고양이'} {selectedPet.breed} + + + + + 상세정보 + + + + + + + 가족 구성원 + {members.length === 0 ? ( + 아직 가족 구성원이 없어요. + ) : ( + + {members.map((member) => ( + + {member.profileImageUrl ? ( + + ) : ( + + + + )} + + {member.userName} + + + ))} + + )} + + + + + + 가족 신청 + + + 전체보기 + + + + 새로운 가족을 만나보세요! 전체보기에서 확인할 수 있어요. + + + + + 받은 신청 목록 + + + 전체보기 + + + + + {receivedCount === 0 ? '아직 받은 신청이 없어요.' : `${receivedCount}건의 신청이 있어요.`} + + + + + + 초대 코드 발급 + + 선택한 반려동물 기준으로 초대 코드를 발급할 수 있어요.{'\n'}발급된 코드는 만료 전까지 이 화면에서 다시 볼 수 + 있어요. + + {cachedCode && ( + + {cachedCode.code} + + )} + inviteMutation.mutate()} + > + + {inviteMutation.isPending ? '발급 중...' : cachedCode ? '초대 코드 다시 만들기' : '초대 코드 만들기'} + + + + + ); +} + +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, + }, + memberGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 14, + }, + memberItem: { + width: 56, + alignItems: 'center', + gap: 4, + }, + memberAvatar: { + 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: 11, + color: DodoColors.textPrimary, + textAlign: 'center', + }, + cardHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + linkText: { + fontSize: 11, + fontWeight: '600', + color: DodoColors.textSecondary, + }, + hintText: { + fontSize: 11, + 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, + 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..8a535d5 --- /dev/null +++ b/app/(tabs)/mypage/family/received.tsx @@ -0,0 +1,302 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link } from 'expo-router'; +import { useState } from 'react'; +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'; + +type FilterLabel = '승인 대기' | '거절됨'; +const FILTERS: FilterLabel[] = ['승인 대기', '거절됨']; + +export default function FamilyReceivedScreen() { + 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 ( + + + + FAMILY + 받은 신청 목록 + + + + 메인 보기 + + + + + + + 받은 신청 목록 + + {FILTERS.map((f) => { + const selected = f === filter; + return ( + setFilter(f)} + > + {f} + + ); + })} + + + + {pendingQuery.isLoading ? ( + + ) : requests.length === 0 ? ( + 아직 받은 신청이 없어요. + ) : ( + 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' }) + } + > + 차단 + + + )} + + )) + )} + + + + 차단 목록 + {blockedQuery.isLoading ? ( + + ) : blocked.length === 0 ? ( + 차단 목록이 비어 있어요. + ) : ( + blocked.map((entry) => ( + + + {entry.userName} → {entry.petName} + + unblockMutation.mutate({ petId: entry.petId, targetUserId: entry.userId })} + > + 차단 해제 + + + )) + )} + + + ); +} + +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', + alignItems: 'center', + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: DodoColors.background, + gap: 8, + }, + requestTextCol: { + flex: 1, + gap: 2, + }, + requestPetName: { + fontSize: 13, + fontWeight: '600', + color: DodoColors.textPrimary, + }, + requestDate: { + 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 new file mode 100644 index 0000000..af00006 --- /dev/null +++ b/app/(tabs)/mypage/index.tsx @@ -0,0 +1,242 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +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'; + +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)/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: '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' }, + { + text: '로그아웃', + style: 'destructive', + onPress: async () => { + const refreshToken = await tokenStorage.getRefreshToken(); + if (refreshToken) { + // 서버 로그아웃 실패해도 로컬 세션은 정리한다 (토큰이 이미 무효했을 수도 있음) + await logout(refreshToken).catch(() => {}); + } + await clearSession(); + router.replace('/(tabs)'); + }, + }, + ]); + }; + + const renderRow = (item: MenuItem) => { + const content = ( + + + + {item.label} + + {!item.danger && } + + ); + + if (item.key === 'logout') { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); + }; + + 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 ( + + + + + + + {meQuery.data?.nickname ?? ' '}님 + {meQuery.data?.region} + {meQuery.data?.email} + + + 반려동물 + {PET_MENU.map(renderRow)} + + 회원정보 + {ACCOUNT_MENU.map(renderRow)} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + content: { + 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, + 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, + textAlign: 'center', + }, + 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..3dd22bf --- /dev/null +++ b/app/(tabs)/mypage/notifications.tsx @@ -0,0 +1,123 @@ +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, + 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 new file mode 100644 index 0000000..1262c3f --- /dev/null +++ b/app/(tabs)/mypage/pets/[petId]/index.tsx @@ -0,0 +1,475 @@ +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 { ActivityIndicator, Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; +import { getPetDetail, getWeightHistory, leavePetFamily, type PetActivity } from '@/shared/api/petApi'; +import { formatFullDateTime } from '@/shared/lib/format/date'; + +const NOTE_TYPE_LABEL: Record = { + ALLERGY: '알레르기', + HOSPITAL: '병원', + MEDICATION: '약물', + FOOD: '음식', + BEHAVIOR: '행동', + SYMPTOM: '증상', + 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(); + 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?.petName}의 가족에서 나가시겠어요?`, [ + { text: '취소', style: 'cancel' }, + { 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 ( + + + + PET DETAIL + 반려동물 상세정보 + + + router.back()}> + 목록으로 + + + + 정보 수정 + + + + + + + + + + + + + {pet.petName} + {pet.sex !== 'NEUTER' && ( + + )} + + + {pet.birth?.slice(0, 10)} (만 {pet.age}세) + + + {speciesLabel(pet.species)} {pet.breed} + + + + + + + 기본 정보 + + 동물등록번호 + {pet.registrationNumber ?? '미등록'} + + + 디바이스 ID + {pet.deviceId} + + + 기준 심박수 + {pet.referenceHeartRate} + + + + + + 체중 정보 + + + 전체보기 + + + + {latestWeight ? ( + + {latestWeight.weight}kg + 최근 측정일 {latestWeight.petWeightsMeasuredAt?.slice(0, 10)} + + ) : ( + 등록된 체중 기록이 없어요. + )} + + + + 가족 구성원 + {familyMembers.length === 0 ? ( + 가족 구성원이 없어요. + ) : ( + + {familyMembers.map((member) => ( + + {member.profileImageUrl ? ( + + ) : ( + + + + )} + + {member.userName} + + + ))} + + )} + + + + 최근 활동 + {pet.lastActivity ? ( + + ) : ( + 최근 활동 정보가 없습니다. + )} + + + + + 특이사항 + + + 전체보기 + + + + 총 {pet.specialNotesCount}개의 특이사항이 등록되어 있어요. + {notes.slice(0, 3).map((note) => ( + + + {NOTE_TYPE_LABEL[note.noteType] ?? note.noteType} + + {note.createdAt?.slice(0, 10)} + + {note.noteContent} + + + ))} + + + + 펫 가족 나가기 + + 가족에서 나가면 이 반려동물의 가족 구성원 목록과 관련 활동 기록을 더 이상 확인할 수 없어요. 다시 참여하려면 + 초대 코드를 다시 등록해야 해요. + + + 가족 나가기 + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + }, + 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, + }, + familyGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 14, + }, + familyItem: { + width: 56, + alignItems: 'center', + gap: 4, + }, + familyAvatarImage: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: DodoColors.background, + }, + familyName: { + fontSize: 11, + color: DodoColors.textPrimary, + textAlign: 'center', + }, + familyAvatar: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: DodoColors.brand, + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + 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, + }, + 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..3575fd4 --- /dev/null +++ b/app/(tabs)/mypage/pets/[petId]/notes.tsx @@ -0,0 +1,479 @@ +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 { + 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'; + +const CATEGORY_LABEL: Record = { + ALLERGY: '알레르기', + HOSPITAL: '병원', + MEDICATION: '약물', + FOOD: '음식', + BEHAVIOR: '행동', + SYMPTOM: '증상', + ETC: '기타', +}; + +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 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('ALLERGY'); + const [pickerOpen, setPickerOpen] = useState(false); + const [filter, setFilter] = useState<'전체' | NoteType>('전체'); + const [editingId, setEditingId] = useState(null); + const [editingContent, setEditingContent] = useState(''); + const [editingCategory, setEditingCategory] = useState('ALLERGY'); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ['pets', petId, 'notes'] }); + queryClient.invalidateQueries({ queryKey: ['pets', petId] }); + }; + + 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 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 ( + + + + PET NOTES + 특이사항 관리 + + router.back()}> + 상세정보 보기 + + + + + + {petQuery.data?.petName ?? ''} + + 총 {notes.length}개 + + + + {petQuery.data?.petName ?? '반려동물'}의 특이사항을 정리하고 필요한 메모를 추가하거나 수정할 수 있어요. + + + + + + + setPickerOpen((v) => !v)}> + {CATEGORY_LABEL[category]} + + + {pickerOpen && ( + + {NOTE_TYPES.map((option) => ( + { + setCategory(option); + setPickerOpen(false); + }} + > + {CATEGORY_LABEL[option]} + + ))} + + )} + + + addMutation.mutate()} + > + 추가 + + + + + + + {(['전체', ...NOTE_TYPES] as const).map((f) => { + const selected = f === filter; + const label = f === '전체' ? '전체' : CATEGORY_LABEL[f]; + return ( + setFilter(f)} + > + {label} + + ); + })} + + + {notesQuery.isLoading ? ( + + ) : filteredNotes.length === 0 ? ( + 등록된 특이사항이 없어요. + ) : ( + 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)} + > + 삭제 + + + + )} + + ); + }) + )} + + + ); +} + +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, + }, + editInput: { + marginRight: 8, + }, + 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..d417f20 --- /dev/null +++ b/app/(tabs)/mypage/pets/[petId]/weight.tsx @@ -0,0 +1,508 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +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 { + createWeightRecord, + deleteWeightRecord, + getPetDetail, + getWeightHistory, + updateWeightRecord, + type WeightRecord, +} from '@/shared/api/petApi'; + +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.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.weight - 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.petWeightsMeasuredAt?.slice(5, 10).replace('-', '.')} + + ))} + + + ); +} + +export default function PetWeightScreen() { + const { petId } = useLocalSearchParams<{ petId: string }>(); + const router = useRouter(); + 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 invalidate = () => queryClient.invalidateQueries({ queryKey: ['pets', petId, 'weight'] }); + + const addMutation = useMutation({ + mutationFn: () => createWeightRecord(petId, Number(weight), `${date.trim()}T00:00:00`), + onSuccess: () => { + setDate(''); + setWeight(''); + invalidate(); + }, + onError: () => Alert.alert('오류', '체중 기록을 추가하지 못했어요.'), + }); + + 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 deleteMutation = useMutation({ + mutationFn: (weightId: number) => deleteWeightRecord(petId, weightId), + onSuccess: invalidate, + onError: () => 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()}> + 상세정보 보기 + + + + + + {petQuery.data?.petName ?? ''} + + 총 {records.length}개 + + + {weightQuery.isLoading ? ( + + ) : latest ? ( + + 최근 체중은 {latest.weight}kg이며, 최근 측정일은 {latest.petWeightsMeasuredAt?.slice(0, 10)}입니다. + + ) : ( + 등록된 체중 기록이 없어요. + )} + + + {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) => { + 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, + 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, + justifyContent: 'center', + }, + weightInput: { + flex: 1, + }, + dateValue: { + fontSize: 12, + color: DodoColors.textPrimary, + }, + datePlaceholder: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + }, + 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, + }, + 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, + }, + 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/index.tsx b/app/(tabs)/mypage/pets/index.tsx new file mode 100644 index 0000000..a21aa67 --- /dev/null +++ b/app/(tabs)/mypage/pets/index.tsx @@ -0,0 +1,217 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'expo-router'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; +import { getPetListName, getPetsList, type PetSex } from '@/shared/api/petApi'; + +function GenderIcon({ sex }: { sex: PetSex }) { + if (sex === 'NEUTER') return null; + return ( + + ); +} + +function speciesLabel(species: 'CANINE' | 'FELINE') { + return species === 'CANINE' ? '강아지' : '고양이'; +} + +export default function PetListScreen() { + const petsQuery = useQuery({ queryKey: ['pets', 'list'], queryFn: () => getPetsList(0, 10) }); + const pets = petsQuery.data?.pets ?? []; + + if (petsQuery.isLoading) { + return ( + + + + ); + } + + if (pets.length === 0) { + return ( + + + 아직 등록된 반려동물이 없어요. + + + 반려동물 추가하기 + + + + ); + } + + return ( + + + + PET LIST + 반려동물 리스트 + + + + 총 {pets.length}마리 + + + + 추가하기 + + + + + + {pets.map((pet) => ( + + + + + + + {getPetListName(pet)} + + + 만 {pet.age}세 + + {speciesLabel(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..a242bc3 --- /dev/null +++ b/app/(tabs)/mypage/pets/new.tsx @@ -0,0 +1,448 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +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; + 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 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]); + + const canSubmit = + name.trim().length > 0 && + breed.trim().length > 0 && + !!species && + !!gender && + !!ageLabel && + 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 ( + + + 새 반려동물 등록하기 + + 반려동물의 기본 정보와 디바이스 정보를 입력해 등록을 시작해보세요. 생년월일을 입력하면 만 나이는 자동으로 + 계산돼요. + + + + + + + + + + 기본 정보 + 이름, 종, 성별, 생년월일 같은 기본 프로필을 입력해 주세요. + + + + 반려동물 이름 * + + + + 품종 * + + + + + + 종 * + + + + + 성별 * + + + + + + 생년월일 * + setDatePickerVisible(true)}> + + {birthDate || '날짜 선택'} + + + + + 만 나이 + + + {ageLabel ?? '생년월일을 입력하면 자동 계산돼요.'} + + + + + + + 등록번호 + + + + + + 디바이스 및 건강 기준값 + 디바이스 ID와 기준 심박수를 입력해 이후 기능과 연결할 수 있어요. + + + + 디바이스 ID * + + + + 기준 심박수 * + + + + + + * 표시는 필수 입력 항목입니다. + + + + router.back()}> + 취소 + + createMutation.mutate()} + > + {createMutation.isPending ? '등록 중...' : '등록하기'} + + + + setDatePickerVisible(false)} + onConfirm={setBirthDate} + /> + + ); +} + +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..986d0ac --- /dev/null +++ b/app/(tabs)/mypage/profile-edit.tsx @@ -0,0 +1,315 @@ +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'; +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 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 = () => { + router.push('/(tabs)/mypage/withdrawal'); + }; + + if (meQuery.isLoading || !meQuery.data) { + return ( + + + + ); + } + + return ( + + ACCOUNT + 회원정보 수정 + + + + + + + + + 기본 정보 + + 이메일과 이름은 조회만 가능하고, 닉네임과 활동 지역은 이 화면에서 수정할 수 있어요. + + + + + 이메일 + + {meQuery.data.email} + + + + 이름 + + {meQuery.data.name} + + + + + + + 닉네임 * + + 비워두면 현재 닉네임을 그대로 유지해요. + + + 활동 지역 * + setRegionModalVisible(true)}> + {region || meQuery.data.region} + + 눌러서 활동 지역을 선택해주세요. + + + + setRegionModalVisible(false)} + onConfirm={setRegion} + /> + + * 표시된 항목은 필수 입력값입니다. + + saveMutation.mutate()} + > + {saveMutation.isPending ? '저장 중...' : '변경사항 저장'} + + + + + 회원 탈퇴 + 탈퇴하면 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요. + + 회원 탈퇴 + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DodoColors.background, + }, + centerContent: { + alignItems: 'center', + justifyContent: 'center', + }, + 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/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', + }, +}); 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/app/_layout.tsx b/app/_layout.tsx index 7e3ff3f..33da527 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,10 +1,19 @@ +// Hermes에는 TextEncoder/TextDecoder가 내장되어 있지 않음 — @stomp/stompjs가 프레임 인코딩에 필수로 사용하므로 +// STOMP 관련 코드가 로드되기 전, 앱 진입 시점에 가장 먼저 폴리필해야 한다. +import 'fast-text-encoding'; +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 { 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'; export const unstable_settings = { @@ -13,16 +22,24 @@ export const unstable_settings = { export default function RootLayout() { const colorScheme = useColorScheme(); + const hydrate = useAuthStore((state) => state.hydrate); + + useEffect(() => { + hydrate(); + }, [hydrate]); return ( - - - - - - - - - + + + + + + + + + + + + ); } 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/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..db607b6 --- /dev/null +++ b/components/common/date-picker-modal.tsx @@ -0,0 +1,248 @@ +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(); + + // 항상 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) { + 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/components/community/post-row.tsx b/components/community/post-row.tsx new file mode 100644 index 0000000..ae8b393 --- /dev/null +++ b/components/community/post-row.tsx @@ -0,0 +1,131 @@ +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'; + +import { DodoColors } from '@/constants/theme'; + +export type BoardPost = { + id: string; + title: string; + preview: string; + author: string; + date: string; + likeCount: number; + commentCount: number; + viewCount: number; + thumbnailImageUrl: string | null; +}; + +export function PostRow({ post }: { post: BoardPost }) { + return ( + + + + + {post.title} + + + {post.preview} + + + + {post.likeCount} + + {post.commentCount} + | + {post.date} + | + {post.author} + | + 조회 {post.viewCount} + + + {post.thumbnailImageUrl ? ( + + ) : ( + + + + )} + + + ); +} + +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, + }, +}); diff --git a/components/walk/activity-map.tsx b/components/walk/activity-map.tsx new file mode 100644 index 0000000..52e6ec3 --- /dev/null +++ b/components/walk/activity-map.tsx @@ -0,0 +1,52 @@ +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..1935508 --- /dev/null +++ b/components/walk/activity-tracking-screen.tsx @@ -0,0 +1,477 @@ +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 { 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'; + +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); + // 폰의 실제 GPS 위치 — 펫 트래커 위치가 아직 없을 때 지도/시작 좌표의 대체값으로 쓴다 + const currentUserLocation = useCurrentLocation(); + // 산책 활동 실시간 경로 — 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 } + : (currentUserLocation ?? 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 ?? currentUserLocation ?? DEFAULT_CENTER; + + if (petsQuery.isLoading) { + return ( + + + + + ); + } + + return ( + + + 산책 + 반려동물과의 산책을 실시간으로 기록해보세요. + + + {pets.length === 0 ? ( + + 산책을 기록하려면 먼저 반려동물을 등록해주세요. + + ) : ( + <> + + 반려동물 선택 + + {pets.map((pet) => { + const selected = pet.petId === selectedPetId; + return ( + setSelectedPetId(pet.petId)} + > + {getPetListName(pet)} + + ); + })} + + + + + {isTracking && ( + + + 경과 시간 + {formatDurationSeconds(elapsedSeconds)} + + + 이동 거리 + {liveDistanceKm.toFixed(2)}km + + + 상태 + {trackingState === 'in_progress' ? '진행 중' : '일시정지'} + + + )} + + {!isTracking ? ( + startMutation.mutate()} + > + + {startMutation.isPending + ? '시작하는 중...' + : `${selectedPet ? getPetListName(selectedPet) : ''} 산책 시작`} + + + ) : 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 new file mode 100644 index 0000000..99b444c --- /dev/null +++ b/components/walk/fence-map.tsx @@ -0,0 +1,94 @@ +import { NaverMapCircleOverlay, NaverMapMarkerOverlay, NaverMapView } from '@mj-studio/react-native-naver-map'; + +import { DodoColors } from '@/constants/theme'; + +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 = { + center: Coord; + fences: Fence[]; + selectedPetId: number | null; + liveLocation: FenceLocationPayload | null; + pendingCenter: Coord | null; + pendingRadius: number | null; + onTapMap: (coord: Coord) => void; +}; + +export function FenceMap({ + center, + fences, + selectedPetId, + liveLocation, + pendingCenter, + pendingRadius, + onTapMap, +}: Props) { + return ( + onTapMap({ latitude, longitude })} + > + {fences.map((fence) => { + const selected = fence.petId === selectedPetId; + // 미사용 울타리는 선택 여부와 무관하게 항상 회색으로 표시 — active 상태가 색상의 1순위 기준 + if (!fence.active) { + return ( + + ); + } + return ( + + ); + })} + + {pendingCenter && ( + + )} + {pendingCenter && ( + + )} + + {liveLocation && ( + + )} + + ); +} diff --git a/components/walk/fence-map.web.tsx b/components/walk/fence-map.web.tsx new file mode 100644 index 0000000..6039a3e --- /dev/null +++ b/components/walk/fence-map.web.tsx @@ -0,0 +1,56 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { StyleSheet, Text, View } from 'react-native'; + +import { DodoColors } from '@/constants/theme'; + +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 = { + 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({ fences, selectedPetId }: Props) { + const selectedFence = fences.find((f) => f.petId === selectedPetId); + return ( + + + 네이버 지도는 웹 프리뷰 미지원 + + {selectedFence ? `${selectedFence.fenceName}은(는)` : '이 반려동물의 울타리는'} 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..d185cc2 --- /dev/null +++ b/components/walk/fence-settings-screen.tsx @@ -0,0 +1,533 @@ +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 { 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'; + +// 웹(dodo-frontend)과 동일하게 울타리는 원(중심+반경)만 지원하고 pet당 1개만 존재한다. +export type Fence = { + fenceId: number; + petId: number; + fenceName: string; + latitude: number; + longitude: number; + radiusMeters: number; + active: boolean; +}; + +type Coord = { latitude: number; longitude: number }; + +// 아직 실시간 위치/펫 위치를 모를 때 지도 초기 중심 — 서울시청 좌표 +export const DEFAULT_CENTER: Coord = { latitude: 37.5665, longitude: 126.978 }; + +type RenderMapParams = { + center: Coord; + fences: Fence[]; + selectedPetId: number | null; + liveLocation: FenceLocationPayload | null; + pendingCenter: Coord | null; + pendingRadius: number | null; + onTapMap: (coord: Coord) => void; +}; + +type Props = { + /** + * 네이버 지도는 네이티브 전용 모듈이라 walk.tsx(native)/walk.web.tsx(web)로 + * 화면을 분리하고, 그 차이만 이 render prop으로 주입한다. + */ + renderMap: (params: RenderMapParams) => ReactNode; + activeTab: WalkTab; + onChangeTab: (tab: WalkTab) => void; +}; + +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 currentUserLocation = useCurrentLocation(); + + 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 ? `${getPetListName(selectedPet)}의 울타리` : '')); + setRadiusInput(selectedFence?.radiusMeters ?? 200); + setPendingCenter( + selectedFence + ? { latitude: selectedFence.latitude, longitude: selectedFence.longitude } + : liveLocation + ? { latitude: liveLocation.latitude, longitude: liveLocation.longitude } + : (currentUserLocation ?? 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 } + : (currentUserLocation ?? 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 ( + + + 산책 + 반려동물 안전 울타리를 설정하세요. + + + + 울타리 설정 + + 반려동물 선택 + + {pets.map((pet) => { + const selected = pet.petId === selectedPetId; + return ( + { + setSelectedPetId(pet.petId); + setIsEditing(false); + }} + > + {getPetListName(pet)} + + ); + })} + + + {renderMap({ + center: mapCenter, + fences, + selectedPetId, + liveLocation, + pendingCenter: isEditing ? pendingCenter : null, + pendingRadius: isEditing ? radiusInput : null, + onTapMap: (coord) => { + if (isEditing) setPendingCenter(coord); + }, + })} + + {showOutsideBanner && ( + + + {selectedPet ? getPetListName(selectedPet) : ''}이(가) 울타리를 벗어났어요! (약{' '} + {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 ? getPetListName(selectedPet) : ''}에게 설정된 울타리가 없어요. + + + 울타리 생성 + + + ) + ) : ( + + 울타리 이름 + + + 반경 {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 ? '저장 중...' : '저장'} + + + + )} + + + + ); +} + +export const styles = StyleSheet.create({ + container: { + 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, + }, + 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, + }, + outsideBanner: { + backgroundColor: '#fee2e2', + borderRadius: 10, + padding: 12, + marginBottom: 16, + }, + outsideBannerText: { + fontSize: 12, + fontWeight: '700', + color: DodoColors.fenceOutside, + }, + 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, + }, + emptyFenceText: { + fontSize: 12, + color: DodoColors.fenceIdleLabel, + flex: 1, + }, + 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, + }, + 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, + }, +}); 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` */ diff --git a/package.json b/package.json index 4f9b953..4ad0606 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", @@ -17,23 +17,33 @@ }, "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", + "@stomp/stompjs": "^7.3.0", "@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-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", "expo-status-bar": "~3.0.9", "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", @@ -41,6 +51,8 @@ "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", "zustand": "^5.0.13" 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; + }); +}; 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/authApi.ts b/shared/api/authApi.ts new file mode 100644 index 0000000..b71d25c --- /dev/null +++ b/shared/api/authApi.ts @@ -0,0 +1,90 @@ +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 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; +} + +/** + * 로그아웃 (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 4a5cbdd..bcf9e7f 100644 --- a/shared/api/axios.ts +++ b/shared/api/axios.ts @@ -1,6 +1,26 @@ -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'; + +// 웹(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, @@ -10,14 +30,53 @@ export const apiClient = axios.create({ }, }); -apiClient.interceptors.request.use( - (config) => { +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), ); apiClient.interceptors.response.use( (response) => response, - (error) => Promise.reject(error), + async (error) => { + const originalRequest = error.config as RetryableRequestConfig | undefined; + + 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/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..f14c094 --- /dev/null +++ b/shared/api/petApi.ts @@ -0,0 +1,212 @@ +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/petName 중 무엇인지 응답으로 재확인되지 않아 둘 다 방어적으로 받는다 — getPetListName()으로 읽을 것 */ + name?: string; + petName?: string; + imageFileUrl: string | null; + breed: string; + age: number; + species: PetSpecies; + sex: PetSex; +} + +export function getPetListName(pet: PetListItem): string { + return pet.petName ?? pet.name ?? ''; +} + +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/config/env.ts b/shared/config/env.ts index 9053895..d80c5b6 100644 --- a/shared/config/env.ts +++ b/shared/config/env.ts @@ -1,9 +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/authStore.ts b/shared/lib/auth/authStore.ts new file mode 100644 index 0000000..24bb82b --- /dev/null +++ b/shared/lib/auth/authStore.ts @@ -0,0 +1,76 @@ +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: (login: LoginSessionData) => 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 (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 }); + }, + + 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..e719229 --- /dev/null +++ b/shared/lib/auth/oauth.ts @@ -0,0 +1,85 @@ +import * as Crypto from 'expo-crypto'; +import * as Linking from 'expo-linking'; +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를 대행하지 않음). +// +// 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 { + 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 SocialAuthCodeResult = + | { success: true; code: string } + | { success: false; reason: 'cancelled' | 'error' | 'state_mismatch' }; + +/** + * 구글/네이버 로그인을 수행하고, 돌아온 인가 코드(code)를 반환한다. + * 코드를 실제 토큰으로 교환하는 건 shared/api/authApi.ts의 socialLogin()이 담당. + */ +export async function requestSocialAuthCode(provider: SocialProvider): Promise { + const config = PROVIDER_OAUTH[provider]; + 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: webRedirectUri, + state, + }); + if (config.scope) { + params.set('scope', config.scope); + } + + const authorizeUrl = `${config.authorizeUrl}?${params.toString()}`; + const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, appRedirectUri); + + 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'); + const code = callbackUrl.searchParams.get('code'); + const oauthError = callbackUrl.searchParams.get('error'); + + if (returnedState !== state) { + throw new Error('OAuth state mismatch — 위조된 콜백일 수 있어 로그인을 중단합니다.'); + } + if (oauthError) { + return { success: false, reason: oauthError === 'access_denied' ? 'cancelled' : 'error' }; + } + if (!code) { + return { success: false, reason: 'error' }; + } + + return { success: true, code }; +} diff --git a/shared/lib/auth/refreshAccessToken.ts b/shared/lib/auth/refreshAccessToken.ts new file mode 100644 index 0000000..a3148ac --- /dev/null +++ b/shared/lib/auth/refreshAccessToken.ts @@ -0,0 +1,56 @@ +import axios from 'axios'; + +import { apiConfig } from '@/shared/config'; + +import { useAuthStore } from './authStore'; +import * as tokenStorage from './tokenStorage'; + +// 웹(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; +} + +// 동시에 여러 요청이 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 requestReissue(refreshToken); + const accessTokenTtlMs = result.accessTokenExpiresIn * 1000; + await tokenStorage.saveTokens({ + accessToken: result.accessToken, + refreshToken: result.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/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..665e37c --- /dev/null +++ b/shared/lib/files/pickImage.ts @@ -0,0 +1,75 @@ +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('사진 접근 권한이 필요해요. 설정에서 권한을 허용해주세요.'); + } +} + +// 안드로이드 시스템 포토 피커(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(); + + 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; + + const asset = result.assets[0]; + validateImageFile(asset); + return asset; +} + +/** 사진 여러 장 선택 (게시글 첨부용). 취소 시 빈 배열. */ +export async function pickMultipleImages(maxCount: number): Promise { + await ensureLibraryPermission(); + + 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 []; + + 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/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; +} 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..eb77f8e --- /dev/null +++ b/shared/lib/ws/useStompTopic.ts @@ -0,0 +1,90 @@ +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'; + +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; + + 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) => { + 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: () => { + 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); + } catch (err) { + 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); + }, + }); + + client.activate(); + + return () => { + cancelled = true; + setData(null); + void client.deactivate(); + }; + }, [destination]); + + return data; +} diff --git a/yarn.lock b/yarn.lock index c3de72e..204f549 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" @@ -1798,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" @@ -2199,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" @@ -2572,7 +2601,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== @@ -2594,6 +2623,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" @@ -2973,6 +3007,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" @@ -3108,6 +3166,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" @@ -3164,6 +3252,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" @@ -3558,6 +3651,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 +3664,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 +3684,45 @@ 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-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" @@ -3591,17 +3740,34 @@ 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" 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" 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== @@ -3609,6 +3775,19 @@ expo-linking@~8.0.11: 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" @@ -3656,6 +3835,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 +3874,12 @@ expo-system-ui@~6.0.9: "@react-native/normalize-colors" "0.81.5" debug "^4.3.2" -expo-web-browser@~15.0.10: +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" integrity sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw== @@ -3742,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" @@ -4388,6 +4587,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" @@ -4643,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" @@ -4900,6 +5109,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" @@ -4910,6 +5124,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" @@ -5496,6 +5717,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" @@ -5520,6 +5748,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 +6230,20 @@ 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" + 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" @@ -6558,7 +6805,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== @@ -7151,7 +7398,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==