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 (
+
+
+
+ {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==