From b2beb77d92d89214af4cb4cb3bc16b48fc28e5a4 Mon Sep 17 00:00:00 2001 From: manNomi Date: Wed, 2 Sep 2026 00:02:28 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=95=B1=20=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=9C=EC=B6=9C=20=ED=99=94=EB=A9=B4=20=ED=92=88?= =?UTF-8?q?=EC=A7=88=EA=B3=BC=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/music.tsx | 126 +++- package-lock.json | 625 +++++++++++++----- package.json | 22 +- scripts/check-image-components.js | 78 +++ scripts/check-live-store-release.js | 123 ++++ src/components/MiniPlayer.tsx | 44 +- src/components/dev/DevTestManager.tsx | 5 +- src/components/home/CurrentSoundtrackCard.tsx | 22 +- src/components/home/FeaturedPlaylistCard.tsx | 33 +- .../home/HomeSoundtrackBottomSheet.tsx | 73 +- .../home/MoodRecommendationCard.tsx | 18 +- src/components/home/MusicLogCard.tsx | 32 +- .../home/RecommendationFeedbackSheet.tsx | 201 ++++++ .../home/TravelModeSuggestionSheet.tsx | 28 +- src/components/library/LibraryScreen.tsx | 16 +- src/components/library/LibraryTrackRow.tsx | 20 +- src/components/media/ResilientImage.tsx | 155 +++++ .../moment-capture/MomentPhotoCanvas.tsx | 4 +- .../playlist/PlaylistBackground.tsx | 26 +- .../playlist/PlaylistBottomSheet.tsx | 86 ++- src/components/playlist/TrackActionMenu.tsx | 48 +- src/components/playlist/TrackRow.tsx | 19 +- .../recap-share/RecapPreviewCard.tsx | 8 +- src/components/recap-share/RecapRouteMap.tsx | 8 +- .../recap-share/RecapSoundLogList.tsx | 5 +- src/components/recap-share/RecordDisc.tsx | 5 +- src/components/recap/RecapListCard.tsx | 5 +- src/components/recap/RecapListScreen.tsx | 5 +- .../travel/TravelModeBottomSheet.tsx | 28 +- src/components/travel/TravelReportModal.tsx | 8 +- .../recap-map/SelectedRecapPinPanel.tsx | 5 +- .../recap-map/SelectedTourPlacePanel.tsx | 5 +- src/components/travel/travelData.ts | 7 +- src/hooks/useBottomSheetGesture.ts | 161 +++++ src/mocks/homeMocks.ts | 28 +- src/mocks/imageAssets.ts | 25 + src/mocks/playlistMocks.ts | 24 +- src/mocks/recapMocks.ts | 30 +- src/mocks/tourMocks.ts | 10 +- src/store/recommendationEventStore.ts | 1 + src/test/__tests__/bottomSheetGesture.test.ts | 59 ++ src/test/__tests__/mockImageAssets.test.ts | 26 + src/utils/authenticatedImageSource.test.ts | 2 +- src/utils/bottomSheetGesture.ts | 61 ++ 44 files changed, 1828 insertions(+), 492 deletions(-) create mode 100644 scripts/check-image-components.js create mode 100644 scripts/check-live-store-release.js create mode 100644 src/components/home/RecommendationFeedbackSheet.tsx create mode 100644 src/components/media/ResilientImage.tsx create mode 100644 src/hooks/useBottomSheetGesture.ts create mode 100644 src/mocks/imageAssets.ts create mode 100644 src/test/__tests__/bottomSheetGesture.test.ts create mode 100644 src/test/__tests__/mockImageAssets.test.ts create mode 100644 src/utils/bottomSheetGesture.ts diff --git a/app/(tabs)/music.tsx b/app/(tabs)/music.tsx index 246c75e..2e6fa82 100644 --- a/app/(tabs)/music.tsx +++ b/app/(tabs)/music.tsx @@ -1,5 +1,5 @@ import { Redirect, router } from "expo-router"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -27,6 +27,10 @@ import { CurrentSoundtrackCard } from "@/components/home/CurrentSoundtrackCard"; import { HomeSoundtrackBottomSheet } from "@/components/home/HomeSoundtrackBottomSheet"; import { HomeHeader } from "@/components/home/HomeHeader"; import { LocationContextCard } from "@/components/home/LocationContextCard"; +import { + RecommendationFeedbackSheet, + type RecommendationFeedbackRating, +} from "@/components/home/RecommendationFeedbackSheet"; import { MoodRecommendationSection, isMoodRecommendationFilter, @@ -145,16 +149,18 @@ function HomeContent() { const [actionMessage, setActionMessage] = useState(); const [isSoundtrackSheetVisible, setIsSoundtrackSheetVisible] = useState(false); + const [feedbackPlaylist, setFeedbackPlaylist] = useState(); + const feedbackPromptTimerRef = useRef< + ReturnType | undefined + >(undefined); + const promptedFeedbackPlaylistIdsRef = useRef(new Set()); const [selectedMusicPlaylistId, setSelectedMusicPlaylistId] = useState(); const [ selectedMusicPlaylistEyebrowLabel, setSelectedMusicPlaylistEyebrowLabel, ] = useState("Music Playlist"); - const { - selectedMoodFilter, - setSelectedMoodFilter, - } = useHomeFilterStore(); + const { selectedMoodFilter, setSelectedMoodFilter } = useHomeFilterStore(); const { currentTrack, setTrack } = usePlayerStore(); const { isLiked, @@ -386,6 +392,14 @@ function HomeContent() { } }, [selectedMoodFilter, setSelectedMoodFilter]); + useEffect(function clearFeedbackPromptTimerOnUnmount() { + return () => { + if (feedbackPromptTimerRef.current) { + clearTimeout(feedbackPromptTimerRef.current); + } + }; + }, []); + useEffect(() => { if (!currentLocation) { return; @@ -399,7 +413,9 @@ function HomeContent() { } const nextPlace = - nearbyPlacesQuery.data?.[0] ?? reverseGeocodedPlaceQuery.data ?? undefined; + nearbyPlacesQuery.data?.[0] ?? + reverseGeocodedPlaceQuery.data ?? + undefined; if (nextPlace?.id !== currentPlace?.id) { setPlace(nextPlace); @@ -536,6 +552,28 @@ function HomeContent() { }, [addRecommendationEvent, selectedMoodFilter, setSelectedMoodFilter], ); + const queueRecommendationFeedback = useCallback( + (playlist?: PlaylistCuration) => { + if ( + !playlist || + promptedFeedbackPlaylistIdsRef.current.has(playlist.id) + ) { + return; + } + + promptedFeedbackPlaylistIdsRef.current.add(playlist.id); + + if (feedbackPromptTimerRef.current) { + clearTimeout(feedbackPromptTimerRef.current); + } + + feedbackPromptTimerRef.current = setTimeout(() => { + setFeedbackPlaylist(playlist); + feedbackPromptTimerRef.current = undefined; + }, 220); + }, + [], + ); const handleEnableLocationRecommendation = useCallback(async () => { const nextProfile = { companionType: profile.companionType, @@ -581,13 +619,7 @@ function HomeContent() { } catch { setLocationStatus("unavailable"); } - }, [ - clearLocation, - locationStatus, - setLocation, - setLocationStatus, - setPlace, - ]); + }, [clearLocation, locationStatus, setLocation, setLocationStatus, setPlace]); const handleSetCurrentLocation = useCallback(async () => { if (!profile.locationRecommendationEnabled) { const didEnable = await handleEnableLocationRecommendation(); @@ -666,6 +698,9 @@ function HomeContent() { const handleCloseCurrentSoundtrack = useCallback(() => { setIsSoundtrackSheetVisible(false); }, []); + const handleDismissCurrentSoundtrack = useCallback(() => { + queueRecommendationFeedback(recommendedPlaylist); + }, [queueRecommendationFeedback, recommendedPlaylist]); const handleSelectCurrentSoundtrackTrack = useCallback( (track: Track) => { if (!recommendedPlaylist) { @@ -803,7 +838,61 @@ function HomeContent() { ); const handleCloseMusicPlaylistSheet = useCallback(() => { setSelectedMusicPlaylistId(undefined); + queueRecommendationFeedback(selectedMusicPlaylist); + }, [queueRecommendationFeedback, selectedMusicPlaylist]); + const handleCloseRecommendationFeedback = useCallback(() => { + setFeedbackPlaylist(undefined); }, []); + const handleSubmitRecommendationFeedback = useCallback( + (rating: RecommendationFeedbackRating, moodFilter?: string) => { + if (!feedbackPlaylist) { + return; + } + + const context = createRecommendationEventContext({ + moodFilter: moodFilter ?? selectedMoodFilter, + source: feedbackPlaylist.context?.source, + }); + + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: feedbackPlaylist.id, + type: "recommendation_feedback", + value: moodFilter ? `${rating}:${moodFilter}` : rating, + }), + ); + + if (moodFilter) { + setSelectedMoodFilter(moodFilter); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: feedbackPlaylist.id, + type: "mood_adjusted", + value: moodFilter, + }), + ); + setActionMessage( + `${moodFilter} 무드로 바꿨어요. 다음 추천에 바로 반영할게요.`, + ); + } else if (rating === "great") { + setActionMessage("좋아요. 비슷한 장소와 무드 추천에 반영할게요."); + } else if (rating === "okay") { + setActionMessage("피드백을 저장했어요. 다음 추천을 더 잘 맞춰볼게요."); + } else { + setActionMessage("다음 추천에서는 다른 느낌을 더 살펴볼게요."); + } + + setFeedbackPlaylist(undefined); + }, + [ + addRecommendationEvent, + feedbackPlaylist, + selectedMoodFilter, + setSelectedMoodFilter, + ], + ); const handleSelectMusicPlaylistTrack = useCallback( (track: Track) => { if (!selectedMusicPlaylist) { @@ -961,8 +1050,7 @@ function HomeContent() { enabled={profile.locationRecommendationEnabled} isLoading={locationStatus === "loading"} isPlaceLoading={ - nearbyPlacesQuery.isFetching || - reverseGeocodedPlaceQuery.isFetching + nearbyPlacesQuery.isFetching || reverseGeocodedPlaceQuery.isFetching } location={currentLocation} onEnable={handleSetCurrentLocation} @@ -1050,6 +1138,7 @@ function HomeContent() { } likedTrackIds={currentSoundtrackLikedTrackIds} onClose={handleCloseCurrentSoundtrack} + onDismissed={handleDismissCurrentSoundtrack} onRetry={() => void refetchRecommendedPlaylist()} onSelectTrack={handleSelectCurrentSoundtrackTrack} onToggleLike={handleToggleCurrentSoundtrackLike} @@ -1077,6 +1166,13 @@ function HomeContent() { savedTrackIds={selectedMusicPlaylistSavedTrackIds} visible={isMusicPlaylistSheetVisible} /> + {currentTrack ? : null} ); diff --git a/package-lock.json b/package-lock.json index 8f5d715..18b31c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,22 +11,22 @@ "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "2.2.0", "@tanstack/react-query": "^5.100.14", - "expo": "~56.0.20", - "expo-build-properties": "~56.0.26", + "expo": "~56.0.21", + "expo-build-properties": "~56.0.27", "expo-camera": "~56.0.8", - "expo-constants": "~56.0.21", - "expo-dev-client": "~56.0.25", - "expo-file-system": "~56.0.7", + "expo-constants": "~56.0.25", + "expo-dev-client": "~56.0.26", + "expo-file-system": "~56.0.11", "expo-font": "~56.0.5", "expo-image": "~56.0.12", - "expo-image-picker": "~56.0.24", + "expo-image-picker": "~56.0.25", "expo-linear-gradient": "~56.0.4", "expo-linking": "~56.0.17", - "expo-location": "~56.0.24", + "expo-location": "~56.0.25", "expo-media-library": "~56.0.11", - "expo-router": "~56.2.19", + "expo-router": "~56.2.20", "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.25", + "expo-sharing": "~56.0.26", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", "react": "19.2.3", @@ -1271,15 +1271,15 @@ } }, "node_modules/@expo/config": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.13.tgz", - "integrity": "sha512-Fi1J1mjD3O2qDQsUPTHhsFXoHduZ5gSkWvCeyKxyp3R9fXgXrmhbpo6T+lZJG8x2t6xL1P/SIc6bLdsTJkKaOg==", + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.14.tgz", + "integrity": "sha512-OxKFAjFiOoWIBornS+QeACEt1HyIidZhLL1RKe+lyY/afk+c2H3BT8ftMLTp7NgQ28Af4MuKu8sJznUEEQFs1A==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~56.0.14", + "@expo/config-plugins": "~56.0.16", "@expo/config-types": "^56.0.7", "@expo/json-file": "^10.2.0", - "@expo/require-utils": "^56.1.6", + "@expo/require-utils": "^56.1.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -1289,15 +1289,15 @@ } }, "node_modules/@expo/config-plugins": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.15.tgz", - "integrity": "sha512-MsSko6coyBWgx7oWFFETxAZZtGJlYykCuCKAGxYgzfT1leUxPVNg1FuGD9moPObDxGadB9l6GJeDuaM2tuCmsQ==", + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.16.tgz", + "integrity": "sha512-IfdCDxIGpaeaBN+r1DFg3j8Vmg/pN0G58WMaJwJw++dopSU0CLmXL8348llPKvvUpSEQQhx6TyVi5au6spZCbw==", "license": "MIT", "dependencies": { "@expo/config-types": "^56.0.7", "@expo/json-file": "~10.2.0", "@expo/plist": "^0.7.0", - "@expo/require-utils": "^56.1.6", + "@expo/require-utils": "^56.1.7", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -1367,9 +1367,9 @@ } }, "node_modules/@expo/env": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.2.tgz", - "integrity": "sha512-28pqaEqwnmLduZ00Pq9HkSzE5wbj1MTwp5/n8nm8rD8MCjR9eUnVOwmNksPI3Be2ReAPO/DbPn1puy0mvoocsQ==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.3.tgz", + "integrity": "sha512-M1NXeZCA1mkMkYOyIe7PlyRX0/jqFtMoJgyblnlq/vpCRfmueFT7RnGSQG8uEFDF5WHOFGijAQ3fogPh3/n5Ng==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -1387,9 +1387,9 @@ "license": "MIT" }, "node_modules/@expo/fingerprint": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.9.tgz", - "integrity": "sha512-gphqoJt5dXPfq6iWFwSLKFcUi6gDnBy9GM4XuKkKbjQEqGyBH6v2iS16Oht6xfCBWkRSy28CNFsm8lKmoJVP2g==", + "version": "0.19.10", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.10.tgz", + "integrity": "sha512-ZA/THeLUJruSIlyd0p3W9loXXWqYp/VpKBYWSDu+dJk/gUymBuJ1Y2VTnW06TTuMQXvuaA8LsJOS+dnX9DM4Ng==", "license": "MIT", "dependencies": { "@expo/env": "^2.3.1", @@ -1409,12 +1409,12 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.4.tgz", - "integrity": "sha512-QGdC9eeNyF7vozMV77wQQwX4z3ZJK8D707WSTcBTE/XI19T41g5NtGVM/BMqb1oBV+zSZlNwpOA6bXbbvtC2UA==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.5.tgz", + "integrity": "sha512-s40rjuVtsawTe5Gkx0SorxoUChz10jLUU9tlysqUyND5l1DiWx5ZiJ86Bt0/TnSWkRqCMj4JECe6zyuNoRmlGw==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^56.1.6", + "@expo/require-utils": "^56.1.7", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", @@ -1424,12 +1424,12 @@ } }, "node_modules/@expo/inline-modules": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.15.tgz", - "integrity": "sha512-EtGaKjzsNF0Jp3Alcn6aKX/QOPEdf1P6i5qTm91mJBmJ4lcWbQkyPrCVNEZHjp989w7c4exd8myA6c0whHcghg==", + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.16.tgz", + "integrity": "sha512-w/Iqfn+gkIhIbJEh+bahCn3PqJEUvoP3Vw0t63wbrCoDZaOOTDcyQ/WqkGStu069e2n85dacEYbymw3Ep8FFDA==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~56.0.14" + "@expo/config-plugins": "~56.0.16" } }, "node_modules/@expo/json-file": { @@ -1443,12 +1443,12 @@ } }, "node_modules/@expo/local-build-cache-provider": { - "version": "56.0.11", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.11.tgz", - "integrity": "sha512-2SfYrb5vBs0Ra3bZfpvtJXJbS1oTSwo9DmG4bpZUW/sKUT0+kXHkPI9uGiUa687SIOEQuFBinylD3C9Z2lma3Q==", + "version": "56.0.12", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.12.tgz", + "integrity": "sha512-HJFHU1TXSdS41U61k361TXwc0uqCwzI7mxHXJLBMJR+PV5Rw7xHSRUY33bZ0b+FJ2plcQbrhUVUMNkOuJhcqaA==", "license": "MIT", "dependencies": { - "@expo/config": "~56.0.13", + "@expo/config": "~56.0.14", "chalk": "^4.1.2" } }, @@ -1470,41 +1470,41 @@ } }, "node_modules/@expo/metro": { - "version": "56.0.0", - "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.0.tgz", - "integrity": "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==", - "license": "MIT", - "dependencies": { - "metro": "0.84.4", - "metro-babel-transformer": "0.84.4", - "metro-cache": "0.84.4", - "metro-cache-key": "0.84.4", - "metro-config": "0.84.4", - "metro-core": "0.84.4", - "metro-file-map": "0.84.4", - "metro-minify-terser": "0.84.4", - "metro-resolver": "0.84.4", - "metro-runtime": "0.84.4", - "metro-source-map": "0.84.4", - "metro-symbolicate": "0.84.4", - "metro-transform-plugins": "0.84.4", - "metro-transform-worker": "0.84.4" + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.2.tgz", + "integrity": "sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA==", + "license": "MIT", + "dependencies": { + "metro": "0.84.5", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-config": "0.84.5", + "metro-core": "0.84.5", + "metro-file-map": "0.84.5", + "metro-minify-terser": "0.84.5", + "metro-resolver": "0.84.5", + "metro-runtime": "0.84.5", + "metro-source-map": "0.84.5", + "metro-symbolicate": "0.84.5", + "metro-transform-plugins": "0.84.5", + "metro-transform-worker": "0.84.5" } }, "node_modules/@expo/metro-config": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.18.tgz", - "integrity": "sha512-X7ephejQE7YjL4401ObdfpluL0enEmLK4ZjMLJrhtkFh0ksZI7jQMvUHxLXZzCHD0SlMXa/RdB9abnWsOj1E5A==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.19.tgz", + "integrity": "sha512-Dh48EFBloqVZVMvc/GpX8oNyLslrTTR+MXviTVUWjLZAOB2Olj6Mk0SPNy/IOX9LnXvGaz484vTT0M2rttfpfA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~56.0.13", + "@expo/config": "~56.0.14", "@expo/env": "~2.3.1", "@expo/json-file": "~10.2.0", - "@expo/metro": "~56.0.0", - "@expo/require-utils": "^56.1.6", + "@expo/metro": "~56.0.2", + "@expo/require-utils": "^56.1.7", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", @@ -1560,9 +1560,9 @@ } }, "node_modules/@expo/metro-config/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -1572,9 +1572,9 @@ } }, "node_modules/@expo/metro-file-map": { - "version": "56.0.3", - "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-56.0.3.tgz", - "integrity": "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==", + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-56.0.4.tgz", + "integrity": "sha512-f+zB0/gOabExV46MryvvQFkWh5au42Pm2CVIY/2TuYN/eIp3bxieOXPPnKCvj+WVM93aliGNiVUF323rorM3xw==", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -1586,9 +1586,9 @@ } }, "node_modules/@expo/metro-runtime": { - "version": "56.0.20", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.20.tgz", - "integrity": "sha512-ICwOVi5rwcQQn3/euXytnhS4YoU6wenKchCx8HFSvl9vKx0w6Tv4IE9oWE09UT6rTRtmVavRHqfPTM8eaNBLtA==", + "version": "56.0.21", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.21.tgz", + "integrity": "sha512-3zESpoHWC4wuI5KKsqui7PkrozHIe8+kOcenpVJ2bkf5qkdtN+zBa7gUqickb3b+iUK8efjKnT4e6WUG2oOu3w==", "license": "MIT", "dependencies": { "@expo/log-box": "^56.0.14", @@ -1610,6 +1610,285 @@ } } }, + "node_modules/@expo/metro/node_modules/metro": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.5.tgz", + "integrity": "sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-config": "0.84.5", + "metro-core": "0.84.5", + "metro-file-map": "0.84.5", + "metro-resolver": "0.84.5", + "metro-runtime": "0.84.5", + "metro-source-map": "0.84.5", + "metro-symbolicate": "0.84.5", + "metro-transform-plugins": "0.84.5", + "metro-transform-worker": "0.84.5", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-babel-transformer": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.5.tgz", + "integrity": "sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.5", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-cache": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.5.tgz", + "integrity": "sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.5" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-cache-key": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.5.tgz", + "integrity": "sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-config": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.5.tgz", + "integrity": "sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.5", + "metro-cache": "0.84.5", + "metro-core": "0.84.5", + "metro-runtime": "0.84.5", + "yaml": "^2.6.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-core": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.5.tgz", + "integrity": "sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.5" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-file-map": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.5.tgz", + "integrity": "sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-minify-terser": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.5.tgz", + "integrity": "sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-resolver": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.5.tgz", + "integrity": "sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-runtime": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.5.tgz", + "integrity": "sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-source-map": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.5.tgz", + "integrity": "sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.5", + "nullthrows": "^1.1.1", + "ob1": "0.84.5", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-symbolicate": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.5.tgz", + "integrity": "sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.5", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-transform-plugins": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.5.tgz", + "integrity": "sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-transform-worker": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.5.tgz", + "integrity": "sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.5", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-minify-terser": "0.84.5", + "metro-source-map": "0.84.5", + "metro-transform-plugins": "0.84.5", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/ob1": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.5.tgz", + "integrity": "sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, "node_modules/@expo/osascript": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz", @@ -1658,27 +1937,27 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.22.tgz", - "integrity": "sha512-U0/7zZWFj/6kMSwNHPfl7QGfM3VyoONPC5TjFXh1gyYcdE1sVBZv5pJH5uRC0DgRNqyeiHvU429C5snIAZ5x2w==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.23.tgz", + "integrity": "sha512-cXHsaLMMN1NJUV84TAXbajdllzQVFwtZ4dW+iyWEjc1nFGiMih66Oj7EfvhAM2y6IvsDEEu02XoCnl4zlAlRHA==", "license": "MIT", "dependencies": { - "@expo/config": "~56.0.13", - "@expo/config-plugins": "~56.0.15", + "@expo/config": "~56.0.14", + "@expo/config-plugins": "~56.0.16", "@expo/config-types": "^56.0.7", - "@expo/image-utils": "^0.10.4", + "@expo/image-utils": "^0.10.5", "@expo/json-file": "^10.2.0", "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.1", - "expo-modules-autolinking": "~56.0.22", + "expo-modules-autolinking": "~56.0.23", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "node_modules/@expo/require-utils": { - "version": "56.1.6", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.6.tgz", - "integrity": "sha512-e68r6X+c7yPxJP0gac/CaJc/RCHeLB2Bf4MyCuqfNM4YXfl1ONmQbQlRL0RbwBfEHn6A6UZKj+5UX9j0Im646g==", + "version": "56.1.7", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.7.tgz", + "integrity": "sha512-7Huw3V4VVThWZglueHpHX4gxdYvjhKOO5DzC/1NBuaDY4IPf/iPhqE+diFA6J/ZF0nC+tcC4YmUidXhnr7cmzQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -4108,9 +4387,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "56.0.19", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.19.tgz", - "integrity": "sha512-js8jq/7x2jclTbfmc3Yp24B8r+KEAE7kmla2vpPVPiCfidi77lJMUAAZ3KBYXXOFaoWF0esAlFuLIuMIMHcmDg==", + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.20.tgz", + "integrity": "sha512-NIP4Kbpbgqrnfq30F5H/tpN6l8Cs7NpPmMxa41xiXsgS/Vsd2KZ3AepXcKygGXklWZCNuq4Zr9vv3gpK+/orIg==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -4159,7 +4438,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^56.0.26", + "expo-widgets": "^56.0.27", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -5026,31 +5305,31 @@ } }, "node_modules/expo": { - "version": "56.0.20", - "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.20.tgz", - "integrity": "sha512-PCjEnbVK3gssHpODgt7PqCwrLx3ERfWRFrVyi38P8K/UMHD6mPiehuNE786WPxZ5tx8xUtsshKtuBxaUQMu3dw==", + "version": "56.0.21", + "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.21.tgz", + "integrity": "sha512-EbN+wFq/bzREf2/4RKjt9UQoYCpB+ohxPDHVLMxkWlepvidpLZgezGberWp7CMzqLdpff+66jdL0fmO3aMRq1g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^56.1.24", - "@expo/config": "~56.0.13", - "@expo/config-plugins": "~56.0.15", + "@expo/cli": "^56.1.25", + "@expo/config": "~56.0.14", + "@expo/config-plugins": "~56.0.16", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.6", - "@expo/fingerprint": "^0.19.9", - "@expo/local-build-cache-provider": "^56.0.11", + "@expo/fingerprint": "^0.19.10", + "@expo/local-build-cache-provider": "^56.0.12", "@expo/log-box": "^56.0.14", - "@expo/metro": "~56.0.0", - "@expo/metro-config": "~56.0.18", + "@expo/metro": "~56.0.2", + "@expo/metro-config": "~56.0.19", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~56.0.19", - "expo-asset": "~56.0.23", - "expo-constants": "~56.0.24", - "expo-file-system": "~56.0.10", + "babel-preset-expo": "~56.0.20", + "expo-asset": "~56.0.24", + "expo-constants": "~56.0.25", + "expo-file-system": "~56.0.11", "expo-font": "~56.0.7", "expo-keep-awake": "~56.0.3", - "expo-modules-autolinking": "~56.0.22", - "expo-modules-core": "~56.0.24", + "expo-modules-autolinking": "~56.0.23", + "expo-modules-core": "~56.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -5088,13 +5367,13 @@ } }, "node_modules/expo-asset": { - "version": "56.0.23", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.23.tgz", - "integrity": "sha512-QN0rEr7I6mAx4Hk2wWBdSzeTtt+6hqg2ImMBcL3SoCqHjxBUVJ05+wyb1jAlZOm/R0HkpMZbkGqBnyqUNSJB1w==", + "version": "56.0.24", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.24.tgz", + "integrity": "sha512-Zy41rl0gtfyZmzV1bzxZype1iEAKz/FiTKOw3yTvA5I2biGnDSh0GbTAYQzkrqYbhnyJAqIe+sXe2eo4DfJ8KQ==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.10.4", - "expo-constants": "~56.0.24" + "@expo/image-utils": "^0.10.5", + "expo-constants": "~56.0.25" }, "peerDependencies": { "expo": "*", @@ -5103,9 +5382,9 @@ } }, "node_modules/expo-build-properties": { - "version": "56.0.26", - "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.26.tgz", - "integrity": "sha512-0sNVshDBa4nmR+8aNiQ/57oJrlpi7yDrdlm8PMZu3g6u2xTncmVXjh2kY7n3tVbfnKPRPXmut4OgQ4/8oaZTkA==", + "version": "56.0.27", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.27.tgz", + "integrity": "sha512-m9rRYr1hqIrR/ggDKCkHHJ4rTcN3P4+Sve0cYmsWNgB0bRp3lMu4pSSMqr09BSoJBLOxB8ECcfW2r4VU3igR+g==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^56.0.2", @@ -5137,9 +5416,9 @@ } }, "node_modules/expo-constants": { - "version": "56.0.24", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.24.tgz", - "integrity": "sha512-DxlaB0pb0Cy0QEnd7WnKVy4L5nCRQY58ePCWCTvbIVbZZ4zTt1Y1xFHeAjT+2p4PGXJ87bjWskscajyXUopP2g==", + "version": "56.0.25", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.25.tgz", + "integrity": "sha512-KlYOe6F/7lxyedsSrZJHhOjff7goDeJ+tp+e1rK0/ohmt+AxziB3vfyvwcqamC5lRhxJYAgz2CCLAU3Rbb7xUg==", "license": "MIT", "dependencies": { "@expo/env": "~2.3.1" @@ -5164,13 +5443,13 @@ } }, "node_modules/expo-dev-client": { - "version": "56.0.25", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.25.tgz", - "integrity": "sha512-rV2SzS3TjUU0gz2l6X97DhazUvgkpIy/RoB7bP1+ieq3ru90IPXGcLQ6O/9iNYG8tWs101jNC8U9ZHL+SLOgVw==", + "version": "56.0.26", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.26.tgz", + "integrity": "sha512-1M80j0OQ/hL0Jmd4ilWFbRmRcxFKJFHTBqhIdqtjiGhpF2F9EZ470TWunprXN38IgWCZLYIwzx7srEy0/+3z4A==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "~56.0.26", - "expo-dev-menu": "~56.0.22", + "expo-dev-launcher": "~56.0.27", + "expo-dev-menu": "~56.0.23", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" @@ -5180,13 +5459,13 @@ } }, "node_modules/expo-dev-launcher": { - "version": "56.0.26", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.26.tgz", - "integrity": "sha512-p8qM4lutkTWLFCJd/GrVXrF3au4kxUPXirHZyRqY7k/COHLw8MgsQeqcHVqGYcE/YVzpNLexq19hHumPoAO4Pw==", + "version": "56.0.27", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.27.tgz", + "integrity": "sha512-3WgDBUu9UXH7flib3SdRGI6LblvNJuWTpZL1kurs/BTdfdFCXHXCHerVPNX00qXsHqdKM6Td7nScINFjXKnQYw==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^56.0.2", - "expo-dev-menu": "~56.0.22", + "expo-dev-menu": "~56.0.23", "expo-manifests": "~56.0.4" }, "peerDependencies": { @@ -5195,9 +5474,9 @@ } }, "node_modules/expo-dev-menu": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.22.tgz", - "integrity": "sha512-xTo3WxcFll3YVnvKXo4KIOGeUwSABrnxztv1J+JzbEc77pOVQhc+ZepXz4c8fksCDBMIW0kO00O29Oe8HotfhA==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.23.tgz", + "integrity": "sha512-DBs2lc6kIEmDvC2BstZHeHWnjhT/3XdPY+CEKBquaE/HAJQ1owhSMDHNgfa13zL+SCtUSOrbrT5EYbNRAFLEDw==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "~56.0.0" @@ -5217,9 +5496,9 @@ } }, "node_modules/expo-file-system": { - "version": "56.0.10", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.10.tgz", - "integrity": "sha512-qXX6lGnt0nIouZJMaaxqEMiWvDRTk+1aQxCzowZiZ22xD5VQIa25glX1/d+YpUhuB8Rh5auSdgFUbH+xliL+rg==", + "version": "56.0.11", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.11.tgz", + "integrity": "sha512-0a0QQIUPxU8ds78hIVjSEFzs+vGCzwTVguCssmjhLJgUyUCEiC8en3xuRbBUCKw1YS+8HZVEZdMvF4qr9RzjXQ==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5281,9 +5560,9 @@ } }, "node_modules/expo-image-picker": { - "version": "56.0.24", - "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.24.tgz", - "integrity": "sha512-5GuHVqHUhf9RM96MDZwHdsSh9F/H7DmXKnDbXlONNK8+AwTsSDwB1NSwLZgl8CxlPbZXYaGcELVJdLLscXvZng==", + "version": "56.0.25", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.25.tgz", + "integrity": "sha512-RSQgyMqSvJnAdoXio7gHF9uDI1duNzkxycE7b86zH7Loq2JmOfSGZpamfwDJJJyKgpD0dwlngD+lHo5L1PRXiw==", "license": "MIT", "dependencies": { "expo-image-loader": "~56.0.3" @@ -5324,12 +5603,12 @@ } }, "node_modules/expo-location": { - "version": "56.0.24", - "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.24.tgz", - "integrity": "sha512-ZSrqs1yoMfyovJdWQPluAxHp27FUhsGxA/PiduVk9Uz1w3ScLFJpRmOU0yUkGRCAfAnAzkaxWcemEBmOGqexmA==", + "version": "56.0.25", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.25.tgz", + "integrity": "sha512-3iUKzbLylL8Ut0Xn5kYFKneE0/HmUUa9YVygRAC2Z89jU5yPftMbY0H4hGZyincttl8mKyvLPZrwZjkmlDopGA==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.10.4" + "@expo/image-utils": "^0.10.5" }, "peerDependencies": { "expo": "*" @@ -5358,12 +5637,12 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.22.tgz", - "integrity": "sha512-okvttTwsaDHzyRLp4/1qpkK2QV9gFIhp7qWZlIHzM4xCatoPSTT2dtvBD5VbXkf4whWUA7Y+DJy99hxnnUONYg==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.23.tgz", + "integrity": "sha512-JGOJ5PznQeQJeKVoHDokIu9+q5L4urR48xSD1HlymssItsyg1NpqCic1znlywg4oPNJgrI1dJETgwzcBFZtpuQ==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^56.1.6", + "@expo/require-utils": "^56.1.7", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" @@ -5373,13 +5652,13 @@ } }, "node_modules/expo-modules-core": { - "version": "56.0.24", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.24.tgz", - "integrity": "sha512-cwBCdPS0lRM2jEmSUhF9vV//HnjJ/Ysb8EwkwxQW7ltSU/YvPNulfRhF4UsdXxw/mUfHvrH7AF+i6oxu3pHtMw==", + "version": "56.0.25", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.25.tgz", + "integrity": "sha512-bNqzl82QrxK7BEPT2H4zrel58GG1X6aHYdJXSkn/2riaKiNgstHmtyTI9oO4G6qCB3DYXZQ1572aCF14ZxCnFw==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", - "expo-modules-jsi": "~56.0.12", + "expo-modules-jsi": "~56.0.13", "invariant": "^2.2.4" }, "peerDependencies": { @@ -5394,22 +5673,22 @@ } }, "node_modules/expo-modules-jsi": { - "version": "56.0.12", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.12.tgz", - "integrity": "sha512-OnXiNbXzYybZPh5QnJyIIwQjQfN7PP4Di/2Hz0xQHKLgQmCfn/zAQziOjbUXVeL3cAyZ8+uDnTDYmDAc3B2qhQ==", + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.13.tgz", + "integrity": "sha512-If9W5Me4aSaBSgLD6MPUm6YDVB0KNsK2gIEaTeLEO9dnTqmRcJ3WM206S8iix+4GSvXMOyE8mLlmqE1cQMUuyQ==", "license": "MIT", "peerDependencies": { "react-native": "*" } }, "node_modules/expo-router": { - "version": "56.2.19", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.19.tgz", - "integrity": "sha512-SnhQjmQv3pauoVYUaCVI9AikKNiO7FXBgrQ0oVw/fn2ivP79GKElP7opEOlNOBE9gsGrHrsA3Co3xFXIf19ujA==", + "version": "56.2.20", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.20.tgz", + "integrity": "sha512-NpYdiNOp3HsSbx+XXsKCb3YMmfSnLTV6RIX2XWDlFB2cNj4Yq7WnEfrZEuHtbJUUPN1Z2C0SF71gwxxi1952yA==", "license": "MIT", "dependencies": { "@expo/log-box": "^56.0.14", - "@expo/metro-runtime": "^56.0.20", + "@expo/metro-runtime": "^56.0.21", "@expo/schema-utils": "^56.0.2", "@expo/ui": "^56.0.26", "@radix-ui/react-slot": "^1.2.0", @@ -5440,10 +5719,10 @@ }, "peerDependencies": { "@expo/log-box": "^56.0.14", - "@expo/metro-runtime": "^56.0.20", + "@expo/metro-runtime": "^56.0.21", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^56.0.24", + "expo-constants": "^56.0.25", "expo-linking": "^56.0.17", "react": "*", "react-dom": "*", @@ -5531,12 +5810,12 @@ } }, "node_modules/expo-sharing": { - "version": "56.0.25", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.25.tgz", - "integrity": "sha512-X3QviZbMuWrizYmsGojmbahoKBappNe8AvGl+g+Heh1Ebgr3thHotJbBOkgTK5scBszA3qc83MZTtHJ3gAtRaQ==", + "version": "56.0.26", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.26.tgz", + "integrity": "sha512-wtgW8MOFSpfToiEvWIJ5g8cgPjWkIKnlEulrMrws80CYC7sEQb+IB6/6jjVFlP02JvvKeTvsflEPinacqhVZHw==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "^56.0.15", + "@expo/config-plugins": "^56.0.16", "@expo/config-types": "^56.0.7", "@expo/plist": "^0.7.0" }, @@ -5583,28 +5862,28 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "56.1.24", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.24.tgz", - "integrity": "sha512-zWx4kHuZQrq1yz/uF6ADeXVvzG8N2gUj/Zf7eF6L8eALvEeX0uCBDmpdo5Yj/fY4sLo1N2VEH62StDRqnnSOxg==", + "version": "56.1.25", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.25.tgz", + "integrity": "sha512-5tBKSRoGWwGfQfA2IupoUf2WMcRYGAO1mCvjTIJ1u+ff3+f4uAJQxB5xdD6VjU8G3KHdLSDhsc7UL4uKIjwtSA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~56.0.13", - "@expo/config-plugins": "~56.0.15", + "@expo/config": "~56.0.14", + "@expo/config-plugins": "~56.0.16", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.1", - "@expo/image-utils": "^0.10.4", - "@expo/inline-modules": "^0.0.15", + "@expo/image-utils": "^0.10.5", + "@expo/inline-modules": "^0.0.16", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.14", - "@expo/metro": "~56.0.0", - "@expo/metro-config": "~56.0.18", - "@expo/metro-file-map": "^56.0.3", + "@expo/metro": "~56.0.2", + "@expo/metro-config": "~56.0.19", + "@expo/metro-file-map": "^56.0.4", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", - "@expo/prebuild-config": "^56.0.22", - "@expo/require-utils": "^56.1.6", + "@expo/prebuild-config": "^56.0.23", + "@expo/require-utils": "^56.1.7", "@expo/router-server": "^56.0.18", "@expo/schema-utils": "^56.0.2", "@expo/spawn-async": "^1.8.0", @@ -5612,7 +5891,7 @@ "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", - "agent-cli-detector": "^0.1.2", + "agent-cli-detector": "0.1.6", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", @@ -5791,9 +6070,9 @@ } }, "node_modules/expo/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -6555,9 +6834,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 2fb5cf2..061335a 100644 --- a/package.json +++ b/package.json @@ -6,22 +6,22 @@ "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "2.2.0", "@tanstack/react-query": "^5.100.14", - "expo": "~56.0.20", - "expo-build-properties": "~56.0.26", + "expo": "~56.0.21", + "expo-build-properties": "~56.0.27", "expo-camera": "~56.0.8", - "expo-constants": "~56.0.21", - "expo-dev-client": "~56.0.25", - "expo-file-system": "~56.0.7", + "expo-constants": "~56.0.25", + "expo-dev-client": "~56.0.26", + "expo-file-system": "~56.0.11", "expo-font": "~56.0.5", "expo-image": "~56.0.12", - "expo-image-picker": "~56.0.24", + "expo-image-picker": "~56.0.25", "expo-linear-gradient": "~56.0.4", "expo-linking": "~56.0.17", - "expo-location": "~56.0.24", + "expo-location": "~56.0.25", "expo-media-library": "~56.0.11", - "expo-router": "~56.2.19", + "expo-router": "~56.2.20", "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.25", + "expo-sharing": "~56.0.26", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", "react": "19.2.3", @@ -62,13 +62,15 @@ "test": "vitest run", "doctor": "npx expo-doctor", "check:api-origin": "node scripts/check-api-origin.js", + "check:image-components": "node scripts/check-image-components.js", "check:deployed-web": "node scripts/check-deployed-web.js", "check:server-web-export": "node scripts/check-server-web-export.js", "check:store-release": "node scripts/check-store-release.js", + "check:store-release:live": "node scripts/check-live-store-release.js", "check:recap-clustering": "node scripts/check-recap-map-clustering.js", "check:server-contract": "node scripts/check-server-api-contract.js", "check:vercel-config": "node scripts/check-vercel-config.js", - "check": "npm run typecheck && npm run check:recap-clustering && npm run doctor", + "check": "npm run typecheck && npm run check:image-components && npm run check:recap-clustering && npm run doctor", "prepare": "sh scripts/install-git-hooks.sh" }, "private": true, diff --git a/scripts/check-image-components.js b/scripts/check-image-components.js new file mode 100644 index 0000000..eca9ad1 --- /dev/null +++ b/scripts/check-image-components.js @@ -0,0 +1,78 @@ +const { readFileSync, readdirSync, statSync } = require('node:fs'); +const path = require('node:path'); + +const projectRoot = path.resolve(__dirname, '..'); +const appRoot = path.join(projectRoot, 'app'); +const sourceRoot = path.resolve(__dirname, '..', 'src'); +const allowedRawImageFiles = new Map([ + ['src/components/BrandLogo.tsx', '앱 번들 로고 자산'], + ['src/components/onboarding/OnboardingScreen.tsx', '앱 번들 온보딩 자산'], + [ + 'src/components/moment-capture/MomentPhotoCanvas.tsx', + '오류 처리가 포함된 사용자 촬영 원본', + ], + [ + 'src/components/media/ResilientImage.tsx', + '원격 이미지 공통 오류 처리 컴포넌트', + ], +]); + +function collectFiles(directory) { + return readdirSync(directory).flatMap((entry) => { + const fullPath = path.join(directory, entry); + return statSync(fullPath).isDirectory() ? collectFiles(fullPath) : [fullPath]; + }); +} + +const failures = []; +let resilientUsageCount = 0; +const sourceFiles = [appRoot, sourceRoot].flatMap(collectFiles).filter((file) => + /\.(?:js|jsx|ts|tsx)$/.test(file), +); +const routeFiles = collectFiles(appRoot).filter((file) => file.endsWith('.tsx')); + +for (const filePath of sourceFiles) { + const source = readFileSync(filePath, 'utf8'); + const importsRawImage = + /import\s*\{[^}]*\bImage\b[^}]*\}\s*from\s*['"](?:expo-image|react-native)['"]/s.test( + source, + ); + const rawImageCount = importsRawImage + ? (source.match(/)/g)?.length ?? 0) + : 0; + const resilientCount = source.match(/)/g)?.length ?? 0; + const relativePath = path.relative(projectRoot, filePath); + + resilientUsageCount += resilientCount; + + if (source.includes('tong.visitkorea.or.kr/cms2/website/')) { + failures.push(`${relativePath}: 검증되지 않은 관광 이미지 URL 사용`); + } + + if (rawImageCount > 0 && !allowedRawImageFiles.has(relativePath)) { + failures.push(`${relativePath}: 공통 오류 처리 없는 Image 사용`); + } + + if (relativePath === 'src/components/moment-capture/MomentPhotoCanvas.tsx') { + if (rawImageCount !== 1 || !source.includes('onError={() => setHasPhotoError(true)}')) { + failures.push(`${relativePath}: 사용자 촬영 원본의 오류 처리가 변경됨`); + } + } +} + +if (routeFiles.length < 17) { + failures.push(`검사한 라우트 파일이 예상보다 적음: ${routeFiles.length}`); +} + +if (resilientUsageCount < 20) { + failures.push(`ResilientImage 적용 수가 예상보다 적음: ${resilientUsageCount}`); +} + +if (failures.length > 0) { + console.error(failures.join('\n')); + process.exitCode = 1; +} else { + console.log( + `[image-components] 라우트 ${routeFiles.length}개와 원격 이미지 ${resilientUsageCount}곳 공통 오류 처리 확인 완료`, + ); +} diff --git a/scripts/check-live-store-release.js b/scripts/check-live-store-release.js new file mode 100644 index 0000000..0b037b3 --- /dev/null +++ b/scripts/check-live-store-release.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); + +const projectRoot = path.resolve(__dirname, '..'); +const easConfig = JSON.parse(fs.readFileSync(path.join(projectRoot, 'eas.json'), 'utf8')); +const productionEnv = easConfig.build?.production?.env ?? {}; +const apiBaseUrl = ( + process.env.SOUNDLOG_RELEASE_API_BASE_URL ?? + productionEnv.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL ?? + '' +).replace(/\/+$/, ''); +const requiredOpenApiPaths = [ + '/v1/community/blocks:', + '/v1/community/reports:', + '/v1/admin/moderation/reports:', +]; +const errors = []; + +function addError(message) { + errors.push(message); +} + +async function fetchReleasePath(endpoint) { + try { + const response = await fetch(`${apiBaseUrl}${endpoint}`, { + headers: { + accept: endpoint === '/openapi.yaml' ? 'text/yaml,text/plain' : '*/*', + }, + signal: AbortSignal.timeout(15_000), + }); + const body = await response.text(); + + return { body, response }; + } catch (error) { + addError( + `${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } +} + +async function assertPublicPage(endpoint, expectedText) { + const result = await fetchReleasePath(endpoint); + + if (!result) { + return; + } + + if (result.response.status !== 200) { + addError(`${endpoint} returned HTTP ${result.response.status}; expected 200.`); + return; + } + + if (!result.body.includes(expectedText)) { + addError(`${endpoint} did not include the expected Soundlog document text.`); + } +} + +async function main() { + if (apiBaseUrl !== 'https://api.soundlog.p-e.kr') { + throw new Error( + 'SOUNDLOG_RELEASE_API_BASE_URL must resolve to https://api.soundlog.p-e.kr for release checks.', + ); + } + + const healthResult = await fetchReleasePath('/v1/health'); + + if (healthResult) { + if (healthResult.response.status !== 200) { + addError(`/v1/health returned HTTP ${healthResult.response.status}; expected 200.`); + } else { + try { + const health = JSON.parse(healthResult.body)?.data; + + if (health?.status !== 'ok' || health?.database !== 'ok') { + addError('/v1/health did not report both API and database status as ok.'); + } + } catch { + addError('/v1/health did not return valid JSON.'); + } + } + } + + await Promise.all([ + assertPublicPage('/legal/privacy', '개인정보 처리방침'), + assertPublicPage('/legal/terms', '서비스 이용약관'), + assertPublicPage('/support', '고객지원'), + ]); + + const adminResult = await fetchReleasePath('/v1/admin/moderation/reports'); + + if (adminResult && adminResult.response.status !== 401) { + addError( + `/v1/admin/moderation/reports returned HTTP ${adminResult.response.status}; expected 401 without the admin key.`, + ); + } + + const openApiResult = await fetchReleasePath('/openapi.yaml'); + + if (openApiResult) { + if (openApiResult.response.status !== 200) { + addError(`/openapi.yaml returned HTTP ${openApiResult.response.status}; expected 200.`); + } else { + requiredOpenApiPaths.forEach((requiredPath) => { + if (!openApiResult.body.includes(requiredPath)) { + addError(`/openapi.yaml is missing ${requiredPath.slice(0, -1)}.`); + } + }); + } + } + + if (errors.length > 0) { + console.error('Live store release check failed:'); + errors.forEach((error) => console.error(`- ${error}`)); + process.exit(1); + } + + console.log(`Live store release check passed for ${apiBaseUrl}.`); +} + +void main(); diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 2637030..cb928f6 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -1,6 +1,5 @@ import { Feather } from "@expo/vector-icons"; import { useRouter } from "expo-router"; -import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; import { useState } from "react"; import { Modal, Platform, Pressable, ScrollView, View } from "react-native"; @@ -9,6 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { libraryApi } from "@/api/libraryApi"; import { syncRecommendationEvent } from "@/api/recommendationEventApi"; import { AppText } from "@/components/AppText"; +import { ResilientImage } from "@/components/media/ResilientImage"; import { TrackActionMenu } from "@/components/playlist/TrackActionMenu"; import { getMiniPlayerBottom } from "@/constants/layout"; import { useLibraryStore } from "@/store/libraryStore"; @@ -166,20 +166,13 @@ export function MiniPlayer() { borderColor: "rgba(255,255,255,0.24)", }} > - {artworkUrl ? ( - - ) : ( - - - - )} + ); const renderLpCover = () => ( @@ -219,20 +212,13 @@ export function MiniPlayer() { borderColor: "rgba(255,255,255,0.22)", }} > - {artworkUrl ? ( - - ) : ( - - - - )} + { if (playlist) { @@ -130,15 +132,15 @@ export function CurrentSoundtrackCard({ opacity: isLoading || isOpeningPlaylist ? 0.52 : pressed ? 0.72 : 1, })} > - {currentPlace?.imageUrl ? ( - - ) : null} + onPress(playlist)} > - {playlist.coverImageUrl ? ( - - ) : ( - - - - - - - - - )} + ; onClose: () => void; + onDismissed?: () => void; onSelectTrack: (track: Track) => void; onToggleLike: (track: Track) => void; onToggleSave: (track: Track) => void; @@ -67,6 +68,7 @@ export function HomeSoundtrackBottomSheet({ isLoading = false, likedTrackIds, onClose, + onDismissed, onSelectTrack, onToggleLike, onToggleSave, @@ -145,20 +147,32 @@ export function HomeSoundtrackBottomSheet({ ]).start(({ finished }) => { if (finished) { setIsMounted(false); + onDismissed?.(); } }); - }, [backdropOpacity, isMounted, restingOffset, sheetHiddenOffset, translateY, visible]); + }, [ + backdropOpacity, + isMounted, + onDismissed, + restingOffset, + sheetHiddenOffset, + translateY, + visible, + ]); - const snapTo = useCallback((offset: number) => { - currentSnapOffset.current = offset; - Animated.spring(translateY, { - damping: 24, - mass: 0.82, - stiffness: 230, - toValue: offset, - useNativeDriver: true, - }).start(); - }, [translateY]); + const snapTo = useCallback( + (offset: number) => { + currentSnapOffset.current = offset; + Animated.spring(translateY, { + damping: 24, + mass: 0.82, + stiffness: 230, + toValue: offset, + useNativeDriver: true, + }).start(); + }, + [translateY], + ); const panResponder = useMemo( () => @@ -169,6 +183,8 @@ export function HomeSoundtrackBottomSheet({ }, onMoveShouldSetPanResponder: (_, gesture) => Math.abs(gesture.dy) > 4 && Math.abs(gesture.dy) > Math.abs(gesture.dx), + onMoveShouldSetPanResponderCapture: (_, gesture) => + Math.abs(gesture.dy) > 4 && Math.abs(gesture.dy) > Math.abs(gesture.dx), onPanResponderMove: (_, gesture) => { const nextOffset = dragStartOffset.current + gesture.dy; translateY.setValue(getResistedOffset(nextOffset, restingOffset)); @@ -194,6 +210,7 @@ export function HomeSoundtrackBottomSheet({ onPanResponderTerminate: () => { snapTo(currentSnapOffset.current); }, + onPanResponderTerminationRequest: () => false, }), [onClose, restingOffset, snapTo, translateY], ); @@ -227,20 +244,25 @@ export function HomeSoundtrackBottomSheet({ }, ]} > - - + + + + {eyebrowLabel} - + {playlist?.regionName ?? '추천 곡'} {playlist ? ( @@ -310,9 +332,7 @@ export function HomeSoundtrackBottomSheet({ className="mt-4 min-h-11 items-center justify-center rounded-full border border-soundlog-lime/45 bg-soundlog-action px-6" onPress={onRetry} > - - 다시 시도 - + 다시 시도 ) : ( @@ -342,9 +362,14 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255,255,255,0.72)', borderRadius: 999, height: 5, - marginBottom: 18, width: 54, }, + dragHandleRegion: { + alignItems: 'center', + justifyContent: 'center', + minHeight: 32, + marginBottom: 2, + }, dragRegion: { paddingBottom: 10, paddingHorizontal: 28, diff --git a/src/components/home/MoodRecommendationCard.tsx b/src/components/home/MoodRecommendationCard.tsx index 71017c4..c5b9bea 100644 --- a/src/components/home/MoodRecommendationCard.tsx +++ b/src/components/home/MoodRecommendationCard.tsx @@ -1,7 +1,7 @@ -import { Image } from 'expo-image'; import { Pressable, StyleSheet, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { ResilientImage } from '@/components/media/ResilientImage'; import { MoodRecommendation } from '@/types/domain'; type MoodRecommendationCardProps = { @@ -21,14 +21,14 @@ export function MoodRecommendationCard({ item, onPress }: MoodRecommendationCard onPress={() => onPress(item)} style={{ backgroundColor: item.color }} > - {item.imageUrl ? ( - - ) : null} + {moodLabel ? ( diff --git a/src/components/home/MusicLogCard.tsx b/src/components/home/MusicLogCard.tsx index 9835614..c6c46db 100644 --- a/src/components/home/MusicLogCard.tsx +++ b/src/components/home/MusicLogCard.tsx @@ -1,4 +1,3 @@ -import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { Animated, @@ -10,6 +9,7 @@ import { } from 'react-native'; import { AppText } from '@/components/AppText'; +import { ResilientImage } from '@/components/media/ResilientImage'; import { MusicLogItem } from '@/types/domain'; type MusicLogCardProps = { @@ -47,22 +47,20 @@ export function MusicLogCard({ width: cardWidth, }} > - {item.imageUrl ? ( - <> - - - - ) : null} + + LOG diff --git a/src/components/home/RecommendationFeedbackSheet.tsx b/src/components/home/RecommendationFeedbackSheet.tsx new file mode 100644 index 0000000..0c317c9 --- /dev/null +++ b/src/components/home/RecommendationFeedbackSheet.tsx @@ -0,0 +1,201 @@ +import { Feather } from '@expo/vector-icons'; +import { useEffect, useState } from 'react'; +import { Animated, Modal, Pressable, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AppText } from '@/components/AppText'; +import { useDismissibleBottomSheetGesture } from '@/hooks/useBottomSheetGesture'; + +export type RecommendationFeedbackRating = 'great' | 'not_for_me' | 'okay'; + +type RecommendationFeedbackSheetProps = { + onClose: () => void; + onSubmit: (rating: RecommendationFeedbackRating, moodFilter?: string) => void; + playlistReason?: string; + regionName?: string; + visible: boolean; +}; + +const moodOptions = [ + { icon: '🌿', label: '잔잔한' }, + { icon: '⚡', label: '신나는' }, + { icon: '🌙', label: '감성적인' }, + { icon: '🌊', label: '시원한' }, +] as const; + +const ratingOptions: Array<{ + description: string; + icon: keyof typeof Feather.glyphMap; + label: string; + value: RecommendationFeedbackRating; +}> = [ + { + description: '지금 장소와 분위기에 잘 맞아요', + icon: 'thumbs-up', + label: '딱 좋아요', + value: 'great', + }, + { + description: '들어볼 만하지만 조금 더 맞출 수 있어요', + icon: 'minus-circle', + label: '괜찮아요', + value: 'okay', + }, + { + description: '다른 분위기의 추천을 받고 싶어요', + icon: 'refresh-cw', + label: '다른 느낌이 좋아요', + value: 'not_for_me', + }, +]; + +export function RecommendationFeedbackSheet({ + onClose, + onSubmit, + playlistReason, + regionName, + visible, +}: RecommendationFeedbackSheetProps) { + const insets = useSafeAreaInsets(); + const [isChoosingMood, setIsChoosingMood] = useState(false); + const { panHandlers, translateY } = useDismissibleBottomSheetGesture({ + onDismiss: onClose, + visible, + }); + + useEffect(() => { + if (visible) { + setIsChoosingMood(false); + } + }, [visible]); + + const handleSelectRating = (rating: RecommendationFeedbackRating) => { + if (rating === 'not_for_me') { + setIsChoosingMood(true); + return; + } + + onSubmit(rating); + }; + + return ( + + + + + + + + + + + + 추천 피드백 + + + {isChoosingMood ? '어떤 느낌으로 바꿔볼까요?' : '이번 추천 어땠나요?'} + + {!isChoosingMood && regionName ? ( + + {regionName}의 사운드트랙을 더 잘 맞추는 데 반영할게요. + + ) : null} + + + + + + + {playlistReason && !isChoosingMood ? ( + + + {playlistReason} + + + ) : null} + + {isChoosingMood ? ( + <> + + {moodOptions.map((option) => ( + onSubmit('not_for_me', option.label)} + > + {option.icon} + + 더 {option.label} + + + ))} + + onSubmit('not_for_me')} + > + + 무드 변경 없이 보내기 + + + + ) : ( + + {ratingOptions.map((option) => ( + handleSelectRating(option.value)} + > + + + + + {option.label} + + {option.description} + + + + + ))} + + )} + + + 건너뛰기 + + + + + ); +} diff --git a/src/components/home/TravelModeSuggestionSheet.tsx b/src/components/home/TravelModeSuggestionSheet.tsx index 40bae92..8717988 100644 --- a/src/components/home/TravelModeSuggestionSheet.tsx +++ b/src/components/home/TravelModeSuggestionSheet.tsx @@ -1,7 +1,8 @@ import { Feather } from '@expo/vector-icons'; -import { Pressable, View } from 'react-native'; +import { Animated, Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { useDismissibleBottomSheetGesture } from '@/hooks/useBottomSheetGesture'; import type { PlaceContext } from '@/types/domain'; type TravelModeSuggestionSheetProps = { @@ -15,10 +16,25 @@ export function TravelModeSuggestionSheet({ onStartTravelMode, place, }: TravelModeSuggestionSheetProps) { + const { panHandlers, translateY } = useDismissibleBottomSheetGesture({ + onDismiss, + }); + return ( - - + + + + - - Travel Mode 시작 - + Travel Mode 시작 나중에 - + ); } diff --git a/src/components/library/LibraryScreen.tsx b/src/components/library/LibraryScreen.tsx index 9a2b6ed..4555e7f 100644 --- a/src/components/library/LibraryScreen.tsx +++ b/src/components/library/LibraryScreen.tsx @@ -1,6 +1,5 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { Feather } from "@expo/vector-icons"; -import { Image } from "expo-image"; import { router } from "expo-router"; import { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; @@ -13,6 +12,7 @@ import { import { syncRecommendationEvent } from "@/api/recommendationEventApi"; import { AppText } from "@/components/AppText"; import { IconButton } from "@/components/IconButton"; +import { ResilientImage } from "@/components/media/ResilientImage"; import { LibraryEmptyState } from "@/components/library/LibraryEmptyState"; import { LibraryTrackRow } from "@/components/library/LibraryTrackRow"; import { PageHeader } from "@/components/PageHeader"; @@ -368,13 +368,13 @@ export function LibraryScreen() { "#2B176C", }} > - {imageUrl ? ( - - ) : null} + - {artworkUrl ? ( - - ) : ( - - - - )} + diff --git a/src/components/media/ResilientImage.tsx b/src/components/media/ResilientImage.tsx new file mode 100644 index 0000000..e5e074c --- /dev/null +++ b/src/components/media/ResilientImage.tsx @@ -0,0 +1,155 @@ +import { Feather } from '@expo/vector-icons'; +import { Image, type ImageProps } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useMemo, useState } from 'react'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; + +type ImageFallbackVariant = 'music' | 'place' | 'playlist' | 'recap'; + +type ResilientImageProps = Omit & { + className?: string; + containerClassName?: string; + fallbackVariant?: ImageFallbackVariant; + source?: ImageProps['source']; + sourceKey?: string; + style?: StyleProp; + uri?: string | null; +}; + +const fallbackConfig: Record< + ImageFallbackVariant, + { + colors: readonly [string, string, ...string[]]; + icon: keyof typeof Feather.glyphMap; + } +> = { + music: { + colors: ['#30205D', '#11172C'], + icon: 'music', + }, + place: { + colors: ['#243A5E', '#0A1225'], + icon: 'map-pin', + }, + playlist: { + colors: ['#3A286A', '#12182B'], + icon: 'disc', + }, + recap: { + colors: ['#344561', '#101728'], + icon: 'image', + }, +}; + +function getSourceKey(source: ImageProps['source'], uri?: string | null) { + if (uri) { + return uri; + } + + if (typeof source === 'number' || typeof source === 'string') { + return String(source); + } + + if (Array.isArray(source)) { + return source.map((item) => (typeof item === 'object' ? item?.uri : item)).join('|'); + } + + return source && typeof source === 'object' && 'uri' in source + ? String(source.uri ?? '') + : ''; +} + +export function ResilientImage({ + accessibilityLabel, + className, + containerClassName, + fallbackVariant = 'recap', + onError, + source, + sourceKey, + style, + uri, + ...imageProps +}: ResilientImageProps) { + const resolvedSource = uri ? { uri } : source; + const resolvedSourceKey = useMemo( + () => sourceKey ?? getSourceKey(resolvedSource, uri), + [resolvedSource, sourceKey, uri], + ); + const [failedSourceKey, setFailedSourceKey] = useState(); + const shouldShowFallback = !resolvedSource || failedSourceKey === resolvedSourceKey; + const fallback = fallbackConfig[fallbackVariant]; + + return ( + + {shouldShowFallback ? ( + + + + + + + + ) : ( + { + setFailedSourceKey(resolvedSourceKey); + onError?.(event); + }} + source={resolvedSource} + style={StyleSheet.absoluteFill} + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, + fallbackIcon: { + alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.10)', + borderColor: 'rgba(255,255,255,0.14)', + borderRadius: 999, + borderWidth: 1, + height: 50, + justifyContent: 'center', + left: '50%', + marginLeft: -25, + marginTop: -25, + position: 'absolute', + top: '50%', + width: 50, + }, + glowLarge: { + backgroundColor: 'rgba(183,230,40,0.10)', + borderRadius: 999, + height: 150, + position: 'absolute', + right: -62, + top: -74, + width: 150, + }, + glowSmall: { + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 999, + bottom: -36, + height: 90, + left: -26, + position: 'absolute', + width: 90, + }, +}); diff --git a/src/components/moment-capture/MomentPhotoCanvas.tsx b/src/components/moment-capture/MomentPhotoCanvas.tsx index 3b72245..1baca9c 100644 --- a/src/components/moment-capture/MomentPhotoCanvas.tsx +++ b/src/components/moment-capture/MomentPhotoCanvas.tsx @@ -26,6 +26,7 @@ import ViewShot from 'react-native-view-shot'; import type { MapStyleElement } from 'react-native-maps'; import { AppText } from '@/components/AppText'; +import { ResilientImage } from '@/components/media/ResilientImage'; import type { GeoPoint, RecapTemplateId, Track } from '@/types/domain'; type NativeMapsModule = typeof import('react-native-maps'); @@ -653,8 +654,9 @@ function MapMusicPin({ track }: { track?: Track }) { > {track?.albumImageUrl ? ( - diff --git a/src/components/playlist/PlaylistBackground.tsx b/src/components/playlist/PlaylistBackground.tsx index 62c03d2..f1660ea 100644 --- a/src/components/playlist/PlaylistBackground.tsx +++ b/src/components/playlist/PlaylistBackground.tsx @@ -1,7 +1,8 @@ -import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; import { StyleSheet, View } from "react-native"; +import { ResilientImage } from "@/components/media/ResilientImage"; + type PlaylistBackgroundProps = { accentColor?: string; imageUrl?: string; @@ -13,21 +14,14 @@ export function PlaylistBackground({ }: PlaylistBackgroundProps) { return ( - {imageUrl ? ( - - ) : ( - - )} + ; -export function PlaylistBottomSheet({ - children, - stickyHeader, -}: PlaylistBottomSheetProps) { +export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSheetProps) { + const insets = useSafeAreaInsets(); const { height } = useWindowDimensions(); + const expandedTop = Math.max(insets.top + 58, 78); + const collapsedTop = Math.min(COLLAPSED_TOP, Math.max(expandedTop + 72, height * 0.28)); + const collapsedOffset = collapsedTop - expandedTop; + const sheetHeight = height - expandedTop; + const { panHandlers, translateY } = useSnappingBottomSheetGesture({ + collapsedOffset, + }); const sheetStyle = { ...glassSurfaceStyle, backgroundColor: SHEET_BACKGROUND, }; return ( - - {stickyHeader ? ( - - - {stickyHeader} - {children} - - ) : ( - - - {children} - - )} - + + + + + {stickyHeader ? {stickyHeader} : null} + + + {children} + + ); } diff --git a/src/components/playlist/TrackActionMenu.tsx b/src/components/playlist/TrackActionMenu.tsx index c345765..1686540 100644 --- a/src/components/playlist/TrackActionMenu.tsx +++ b/src/components/playlist/TrackActionMenu.tsx @@ -1,8 +1,9 @@ import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; -import { Modal, Pressable, View } from 'react-native'; +import { Animated, Modal, Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { ResilientImage } from '@/components/media/ResilientImage'; +import { useDismissibleBottomSheetGesture } from '@/hooks/useBottomSheetGesture'; import { Track } from '@/types/domain'; type TrackActionMenuProps = { @@ -57,30 +58,41 @@ export function TrackActionMenu({ const handleClose = () => { onClose(); }; + const { panHandlers, translateY } = useDismissibleBottomSheetGesture({ + onDismiss: handleClose, + visible, + }); return ( - - + + + + {track ? ( - {artworkUrl ? ( - - ) : ( - - - - )} + @@ -109,11 +121,9 @@ export function TrackActionMenu({ ) : null} {actionMessage ? ( - - {actionMessage} - + {actionMessage} ) : null} - + ); diff --git a/src/components/playlist/TrackRow.tsx b/src/components/playlist/TrackRow.tsx index e152d50..87f3fb7 100644 --- a/src/components/playlist/TrackRow.tsx +++ b/src/components/playlist/TrackRow.tsx @@ -1,8 +1,8 @@ import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { ResilientImage } from '@/components/media/ResilientImage'; import { Track } from '@/types/domain'; import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals'; @@ -51,16 +51,13 @@ export function TrackRow({ backgroundColor: hexToRgba(keyColor, 0.24), }} > - {artworkUrl ? ( - - ) : ( - - - - )} + diff --git a/src/components/recap-share/RecapPreviewCard.tsx b/src/components/recap-share/RecapPreviewCard.tsx index 49b5835..ec00900 100644 --- a/src/components/recap-share/RecapPreviewCard.tsx +++ b/src/components/recap-share/RecapPreviewCard.tsx @@ -1,5 +1,4 @@ import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { Animated, @@ -11,6 +10,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppText } from '@/components/AppText'; +import { ResilientImage } from '@/components/media/ResilientImage'; import { RecordDisc } from '@/components/recap-share/RecordDisc'; import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; import { @@ -68,8 +68,9 @@ function RecapBackground({ return ( <> {visibleImageUrl ? ( - setFailedImageUrl(visibleImageUrl)} source={photoSource} style={StyleSheet.absoluteFill} @@ -332,8 +333,9 @@ function RecapFilmMomentThumbnail({ moment, index }: { index: number; moment: Re return ( {moment.imageUrl ? ( - {moment.imageUrl ? ( - @@ -161,8 +162,9 @@ function MomentDetailModal({ > {moment?.imageUrl ? ( - diff --git a/src/components/recap-share/RecapSoundLogList.tsx b/src/components/recap-share/RecapSoundLogList.tsx index d8ef8f4..4718695 100644 --- a/src/components/recap-share/RecapSoundLogList.tsx +++ b/src/components/recap-share/RecapSoundLogList.tsx @@ -1,5 +1,4 @@ import { Feather } from "@expo/vector-icons"; -import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; import { useEffect, useState } from "react"; import { @@ -13,6 +12,7 @@ import { } from "react-native"; import { AppText } from "@/components/AppText"; +import { ResilientImage } from "@/components/media/ResilientImage"; import { SectionTitle } from "@/components/SectionTitle"; import { useAuthenticatedImageSource } from "@/hooks/useAuthenticatedImageSource"; import type { RecapShare, RecapShareMoment } from "@/types/domain"; @@ -54,8 +54,9 @@ function SoundLogBackground({ imageUrl }: { imageUrl?: string }) { return ( <> {visibleImageUrl ? ( - setFailedImageUrl(visibleImageUrl)} source={photoSource} style={StyleSheet.absoluteFill} diff --git a/src/components/recap-share/RecordDisc.tsx b/src/components/recap-share/RecordDisc.tsx index b13e4fc..ba9c610 100644 --- a/src/components/recap-share/RecordDisc.tsx +++ b/src/components/recap-share/RecordDisc.tsx @@ -1,6 +1,6 @@ -import { Image } from 'expo-image'; import { View } from 'react-native'; +import { ResilientImage } from '@/components/media/ResilientImage'; import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; type RecordDiscProps = { @@ -13,8 +13,9 @@ export function RecordDisc({ imageUrl }: RecordDiscProps) { return ( {imageUrl ? ( - {imageUrl ? ( - {visibleImageUrl ? ( - setFailedImageUrl(visibleImageUrl)} source={photoSource} style={StyleSheet.absoluteFill} diff --git a/src/components/travel/TravelModeBottomSheet.tsx b/src/components/travel/TravelModeBottomSheet.tsx index 389068e..b144ccb 100644 --- a/src/components/travel/TravelModeBottomSheet.tsx +++ b/src/components/travel/TravelModeBottomSheet.tsx @@ -1,8 +1,9 @@ import { Feather } from '@expo/vector-icons'; -import { Modal, Pressable, View } from 'react-native'; +import { Animated, Modal, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AppText } from '@/components/AppText'; +import { useDismissibleBottomSheetGesture } from '@/hooks/useBottomSheetGesture'; import type { TravelMode } from '@/types/domain'; import { travelModeOptions } from './travelData'; @@ -25,16 +26,31 @@ export function TravelModeBottomSheet({ visible, }: TravelModeBottomSheetProps) { const insets = useSafeAreaInsets(); + const { panHandlers, translateY } = useDismissibleBottomSheetGesture({ + onDismiss: onClose, + visible, + }); return ( - + - - + + + @@ -97,7 +113,7 @@ export function TravelModeBottomSheet({ {submitLabel} - + ); diff --git a/src/components/travel/TravelReportModal.tsx b/src/components/travel/TravelReportModal.tsx index d824133..5f9cb76 100644 --- a/src/components/travel/TravelReportModal.tsx +++ b/src/components/travel/TravelReportModal.tsx @@ -1,10 +1,11 @@ import { Feather } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import { useEffect, useState } from "react"; -import { Image, Modal, Pressable, StyleSheet, View } from "react-native"; +import { Modal, Pressable, StyleSheet, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText } from "@/components/AppText"; +import { ResilientImage } from "@/components/media/ResilientImage"; import { useAuthenticatedImageSource } from "@/hooks/useAuthenticatedImageSource"; import { @@ -41,9 +42,10 @@ function RecapThumbnailFrame({ photoUri }: { photoUri?: string }) { } return ( - ); diff --git a/src/components/travel/recap-map/SelectedRecapPinPanel.tsx b/src/components/travel/recap-map/SelectedRecapPinPanel.tsx index 054745d..8251a68 100644 --- a/src/components/travel/recap-map/SelectedRecapPinPanel.tsx +++ b/src/components/travel/recap-map/SelectedRecapPinPanel.tsx @@ -1,10 +1,10 @@ import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; import { useState } from 'react'; import { Alert, Pressable, ScrollView, View } from 'react-native'; import { communityApi } from '@/api/communityApi'; import { AppText } from '@/components/AppText'; +import { ResilientImage } from '@/components/media/ResilientImage'; import { ReportContentSheet } from '@/components/moderation/ReportContentSheet'; import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; import type { RecapMapMarker } from '@/types/domain'; @@ -29,9 +29,10 @@ function MarkerThumbnail({ imageUrl }: { imageUrl?: string }) { return ( {imageUrl ? ( - diff --git a/src/components/travel/recap-map/SelectedTourPlacePanel.tsx b/src/components/travel/recap-map/SelectedTourPlacePanel.tsx index f8f5e13..2b88f51 100644 --- a/src/components/travel/recap-map/SelectedTourPlacePanel.tsx +++ b/src/components/travel/recap-map/SelectedTourPlacePanel.tsx @@ -1,10 +1,10 @@ import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useEffect, useState } from 'react'; import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { ResilientImage } from '@/components/media/ResilientImage'; import type { PlaceContext } from '@/types/domain'; type SelectedTourPlacePanelProps = { @@ -46,10 +46,11 @@ export function SelectedTourPlacePanel({ {shouldShowImage ? ( - setHasImageError(true)} source={{ uri: place.imageUrl }} transition={180} diff --git a/src/components/travel/travelData.ts b/src/components/travel/travelData.ts index da7515f..3dcda8c 100644 --- a/src/components/travel/travelData.ts +++ b/src/components/travel/travelData.ts @@ -1,4 +1,5 @@ import type { MomentLog, MoodTag, TravelMode } from '@/types/domain'; +import { getMockImageUrl } from '@/mocks/imageAssets'; export type TravelModeOption = { icon: string; @@ -63,7 +64,7 @@ export const sampleMoments: MomentLog[] = [ createdAt: '2026-06-06T17:21:00.000+09:00', id: 'sample-gwangalli', moodTags: ['fresh'], - photoUri: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + photoUri: getMockImageUrl('busan'), placeName: '광안리 해변', source: 'camera', track: { @@ -78,7 +79,7 @@ export const sampleMoments: MomentLog[] = [ createdAt: '2026-06-06T15:42:00.000+09:00', id: 'sample-seongsu', moodTags: ['calm', 'local'], - photoUri: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + photoUri: getMockImageUrl('seoul'), placeName: '성수 카페거리', source: 'camera', track: { @@ -93,7 +94,7 @@ export const sampleMoments: MomentLog[] = [ createdAt: '2026-06-06T13:18:00.000+09:00', id: 'sample-night', moodTags: ['emotional'], - photoUri: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + photoUri: getMockImageUrl('seoul'), placeName: '남산 산책로', source: 'camera', track: { diff --git a/src/hooks/useBottomSheetGesture.ts b/src/hooks/useBottomSheetGesture.ts new file mode 100644 index 0000000..d0758cd --- /dev/null +++ b/src/hooks/useBottomSheetGesture.ts @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { Animated, Easing, PanResponder } from 'react-native'; + +import { + getDismissibleBottomSheetOffset, + getResistedBottomSheetOffset, + resolveBottomSheetSnapOffset, + shouldDismissBottomSheet, +} from '@/utils/bottomSheetGesture'; + +type DismissibleBottomSheetGestureParams = { + onDismiss: () => void; + visible?: boolean; +}; + +type SnappingBottomSheetGestureParams = { + collapsedOffset: number; + expandedOffset?: number; +}; + +function isVerticalSheetDrag(dx: number, dy: number) { + return Math.abs(dy) > 4 && Math.abs(dy) > Math.abs(dx); +} + +export function useDismissibleBottomSheetGesture({ + onDismiss, + visible = true, +}: DismissibleBottomSheetGestureParams) { + const translateY = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + translateY.setValue(0); + } + }, [translateY, visible]); + + const restore = useCallback(() => { + Animated.spring(translateY, { + damping: 22, + stiffness: 240, + toValue: 0, + useNativeDriver: true, + }).start(); + }, [translateY]); + + const dismiss = useCallback(() => { + Animated.timing(translateY, { + duration: 170, + easing: Easing.out(Easing.cubic), + toValue: 360, + useNativeDriver: true, + }).start(({ finished }) => { + if (finished) { + onDismiss(); + translateY.setValue(0); + } + }); + }, [onDismiss, translateY]); + + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gesture) => isVerticalSheetDrag(gesture.dx, gesture.dy), + onMoveShouldSetPanResponderCapture: (_, gesture) => + isVerticalSheetDrag(gesture.dx, gesture.dy), + onPanResponderMove: (_, gesture) => { + translateY.setValue(getDismissibleBottomSheetOffset(gesture.dy)); + }, + onPanResponderRelease: (_, gesture) => { + if ( + shouldDismissBottomSheet({ + translationY: gesture.dy, + velocity: gesture.vy, + }) + ) { + dismiss(); + return; + } + + restore(); + }, + onPanResponderTerminate: restore, + onPanResponderTerminationRequest: () => false, + }), + [dismiss, restore, translateY], + ); + + return { + panHandlers: panResponder.panHandlers, + translateY, + }; +} + +export function useSnappingBottomSheetGesture({ + collapsedOffset, + expandedOffset = 0, +}: SnappingBottomSheetGestureParams) { + const translateY = useRef(new Animated.Value(collapsedOffset)).current; + const currentOffset = useRef(collapsedOffset); + const dragStartOffset = useRef(collapsedOffset); + + useEffect(() => { + currentOffset.current = collapsedOffset; + dragStartOffset.current = collapsedOffset; + translateY.setValue(collapsedOffset); + }, [collapsedOffset, expandedOffset, translateY]); + + const snapTo = useCallback( + (offset: number) => { + currentOffset.current = offset; + Animated.spring(translateY, { + damping: 22, + stiffness: 240, + toValue: offset, + useNativeDriver: true, + }).start(); + }, + [translateY], + ); + + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gesture) => isVerticalSheetDrag(gesture.dx, gesture.dy), + onMoveShouldSetPanResponderCapture: (_, gesture) => + isVerticalSheetDrag(gesture.dx, gesture.dy), + onPanResponderGrant: () => { + translateY.stopAnimation(); + dragStartOffset.current = currentOffset.current; + }, + onPanResponderMove: (_, gesture) => { + translateY.setValue( + getResistedBottomSheetOffset( + dragStartOffset.current + gesture.dy, + expandedOffset, + collapsedOffset, + ), + ); + }, + onPanResponderRelease: (_, gesture) => { + snapTo( + resolveBottomSheetSnapOffset({ + collapsedOffset, + currentOffset: dragStartOffset.current, + expandedOffset, + translationY: gesture.dy, + velocity: gesture.vy, + }), + ); + }, + onPanResponderTerminate: () => snapTo(currentOffset.current), + onPanResponderTerminationRequest: () => false, + }), + [collapsedOffset, expandedOffset, snapTo, translateY], + ); + + return { + panHandlers: panResponder.panHandlers, + translateY, + }; +} diff --git a/src/mocks/homeMocks.ts b/src/mocks/homeMocks.ts index 47c6b4c..85c6f2f 100644 --- a/src/mocks/homeMocks.ts +++ b/src/mocks/homeMocks.ts @@ -1,7 +1,11 @@ import { FeaturedPlaylist, MoodRecommendation, MusicLogItem } from '@/types/domain'; +import { getMockImageUrl } from './imageAssets'; + export const featuredPlaylists: FeaturedPlaylist[] = [ { + backgroundImageUrl: getMockImageUrl('geoje'), + coverImageUrl: getMockImageUrl('geoje'), id: 'geoje-ocean', regionName: '거제', description: '거제 바다와 섬길을 따라 듣기 좋은 플레이리스트예요.', @@ -9,6 +13,8 @@ export const featuredPlaylists: FeaturedPlaylist[] = [ trackCount: 8, }, { + backgroundImageUrl: getMockImageUrl('busan'), + coverImageUrl: getMockImageUrl('busan'), id: 'busan-ocean', regionName: '부산', description: '부산광역시에 어울리는 노래를 추천해드립니다.', @@ -16,6 +22,8 @@ export const featuredPlaylists: FeaturedPlaylist[] = [ trackCount: 12, }, { + backgroundImageUrl: getMockImageUrl('seoul'), + coverImageUrl: getMockImageUrl('seoul'), id: 'seoul-night', regionName: '서울', description: '서울 야경과 어울리는 음악을 모았어요.', @@ -31,7 +39,7 @@ export const moodRecommendations: MoodRecommendation[] = [ subtitle: '천천히 걷는 시간', color: '#2B176C', genres: ['인디', '발라드'], - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg', + imageUrl: getMockImageUrl('chuncheon'), moods: ['잔잔한', '감성적인'], playlistId: 'calm-walk', track: { artist: 'JENNIE', fallbackColor: '#192554', id: 'seoul-city', title: 'Seoul City' }, @@ -43,7 +51,7 @@ export const moodRecommendations: MoodRecommendation[] = [ subtitle: '창문을 열고 달리는 순간', color: '#B1913A', genres: ['팝', 'K-POP'], - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + imageUrl: getMockImageUrl('geoje'), moods: ['신나는', '시원한', '활기찬'], playlistId: 'drive', track: { artist: '김건모', fallbackColor: '#48A5B4', id: 'moon-seoul', title: '서울의 달' }, @@ -55,7 +63,7 @@ export const moodRecommendations: MoodRecommendation[] = [ subtitle: '불빛이 번지는 밤', color: '#1F2937', genres: ['R&B', 'OST'], - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + imageUrl: getMockImageUrl('seoul'), moods: ['감성적인', '잔잔한', '설레는'], playlistId: 'city-night', track: { artist: '폴킴', fallbackColor: '#D70D31', id: 'hangang', title: '한강에서' }, @@ -67,7 +75,7 @@ export const moodRecommendations: MoodRecommendation[] = [ subtitle: '잠시 머물고 싶은 오후', color: '#3F2C6B', genres: ['인디', 'R&B'], - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + imageUrl: getMockImageUrl('jeonju'), moods: ['잔잔한', '시원한'], playlistId: 'cafe-indie', track: { artist: '10cm', fallbackColor: '#DA6C51', id: 'seoul-night-track', title: '서울의 밤' }, @@ -80,7 +88,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: 'JENNIE', createdAt: '2026-05-25T00:00:00.000Z', id: 'log-1', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + imageUrl: getMockImageUrl('busan'), placeName: '광안리', recapShareId: 'log-1', trackTitle: 'Seoul City', @@ -89,7 +97,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: '아이유', createdAt: '2026-05-25T00:10:00.000Z', id: 'log-2', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + imageUrl: getMockImageUrl('seoul'), placeName: '한강', recapShareId: 'log-2', trackTitle: '밤편지', @@ -98,7 +106,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: '10cm', createdAt: '2026-05-25T00:20:00.000Z', id: 'log-3', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + imageUrl: getMockImageUrl('busan'), placeName: '부산', recapShareId: 'log-3', trackTitle: '서울의 밤', @@ -107,7 +115,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: 'wave to earth', createdAt: '2026-05-25T00:30:00.000Z', id: 'log-4', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/33/3010733_image2_1.jpg', + imageUrl: getMockImageUrl('jeju'), placeName: '제주 애월', recapShareId: 'log-1', trackTitle: 'seasons', @@ -116,7 +124,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: 'AKMU', createdAt: '2026-05-25T00:40:00.000Z', id: 'log-5', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/08/2674208_image2_1.jpg', + imageUrl: getMockImageUrl('gyeongju'), placeName: '경주', recapShareId: 'log-2', trackTitle: 'Dinosaur', @@ -125,7 +133,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: '잔나비', createdAt: '2026-05-25T00:50:00.000Z', id: 'log-6', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg', + imageUrl: getMockImageUrl('seoul'), placeName: '남산', recapShareId: 'log-3', trackTitle: '뜨거운 여름밤은 가고', diff --git a/src/mocks/imageAssets.ts b/src/mocks/imageAssets.ts new file mode 100644 index 0000000..d336069 --- /dev/null +++ b/src/mocks/imageAssets.ts @@ -0,0 +1,25 @@ +import { getApiBaseUrl } from '@/api/client'; + +export type MockImageRegion = + | 'busan' + | 'chuncheon' + | 'daegu' + | 'geoje' + | 'gyeongju' + | 'jeju' + | 'jeonju' + | 'seoul'; + +export function getMockImageUrl(region: MockImageRegion) { + const apiBaseUrl = getApiBaseUrl(); + + if (!apiBaseUrl) { + return undefined; + } + + try { + return `${new URL(apiBaseUrl).origin}/assets/playlists/${region}.webp`; + } catch { + return undefined; + } +} diff --git a/src/mocks/playlistMocks.ts b/src/mocks/playlistMocks.ts index ed80f09..ab04088 100644 --- a/src/mocks/playlistMocks.ts +++ b/src/mocks/playlistMocks.ts @@ -1,5 +1,7 @@ import { PlaylistCuration, Track } from '@/types/domain'; +import { getMockImageUrl } from './imageAssets'; + const tracks: Track[] = [ { artist: 'JENNIE', @@ -222,8 +224,8 @@ const festivalKpopTracks = createMoodPlaylistTracks('festival-kpop', [ ], ['#9A3E62', '#D70D31', '#E66A73', '#29376B']); export const playlistDetail: PlaylistCuration = { - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + backgroundImageUrl: getMockImageUrl('seoul'), + coverImageUrl: getMockImageUrl('seoul'), durationText: '36:00분', id: 'seoul-night', placeName: '서울 야경', @@ -236,7 +238,7 @@ export const playlistDetail: PlaylistCuration = { export const playlistCurationById: Record = { 'calm-walk': { accentColor: '#2B176C', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg', + coverImageUrl: getMockImageUrl('chuncheon'), durationText: '28:00분', id: 'calm-walk', placeName: '느린 걸음의 산책길', @@ -247,7 +249,7 @@ export const playlistCurationById: Record = { }, drive: { accentColor: '#B1913A', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + coverImageUrl: getMockImageUrl('geoje'), durationText: '34:00분', id: 'drive', placeName: '해안도로 드라이브', @@ -258,7 +260,7 @@ export const playlistCurationById: Record = { }, 'city-night': { accentColor: '#1F2937', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + coverImageUrl: getMockImageUrl('seoul'), durationText: '31:00분', id: 'city-night', placeName: '도시 야경 산책', @@ -269,7 +271,7 @@ export const playlistCurationById: Record = { }, 'cafe-indie': { accentColor: '#3F2C6B', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + coverImageUrl: getMockImageUrl('jeonju'), durationText: '29:00분', id: 'cafe-indie', placeName: '창가 자리와 오후 산책', @@ -280,7 +282,7 @@ export const playlistCurationById: Record = { }, 'festival-kpop': { accentColor: '#9A3E62', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/33/3010733_image2_1.jpg', + coverImageUrl: getMockImageUrl('daegu'), durationText: '33:00분', id: 'festival-kpop', placeName: '페스티벌 광장', @@ -290,8 +292,8 @@ export const playlistCurationById: Record = { tracks: festivalKpopTracks, }, 'geoje-ocean': { - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + backgroundImageUrl: getMockImageUrl('geoje'), + coverImageUrl: getMockImageUrl('geoje'), durationText: '32:00분', id: 'geoje-ocean', placeName: '바람의 언덕과 해안도로', @@ -302,8 +304,8 @@ export const playlistCurationById: Record = { }, 'busan-ocean': { ...playlistDetail, - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + backgroundImageUrl: getMockImageUrl('busan'), + coverImageUrl: getMockImageUrl('busan'), durationText: '40:00분', id: 'busan-ocean', placeName: '광안리 해변', diff --git a/src/mocks/recapMocks.ts b/src/mocks/recapMocks.ts index d78982b..1d7f585 100644 --- a/src/mocks/recapMocks.ts +++ b/src/mocks/recapMocks.ts @@ -1,5 +1,7 @@ import { RecapItem, RecapShare } from '@/types/domain'; +import { getMockImageUrl } from './imageAssets'; + export const recapItems: RecapItem[] = [ { createdAt: '2026-05-25T00:00:00.000Z', @@ -18,14 +20,14 @@ export const recapItems: RecapItem[] = [ export const recapShare: RecapShare = { artistName: 'JENNIE', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', - discImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + backgroundImageUrl: getMockImageUrl('seoul'), + discImageUrl: getMockImageUrl('seoul'), id: 'seoul-night', moments: [ { artistName: 'JENNIE', id: 'log-1', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + imageUrl: getMockImageUrl('busan'), location: { lat: 35.1532, lng: 129.1187 }, placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', @@ -34,7 +36,7 @@ export const recapShare: RecapShare = { { artistName: '아이유', id: 'log-2', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + imageUrl: getMockImageUrl('seoul'), location: { lat: 37.5294, lng: 126.9348 }, placeName: '한강', recordedAt: '2026-05-25T00:10:00.000Z', @@ -43,7 +45,7 @@ export const recapShare: RecapShare = { { artistName: '10cm', id: 'log-3', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + imageUrl: getMockImageUrl('busan'), location: { lat: 35.1796, lng: 129.0756 }, placeName: '부산', recordedAt: '2026-05-25T00:20:00.000Z', @@ -59,14 +61,14 @@ export const recapShare: RecapShare = { export const recapShareById: Record = { 'log-1': { artistName: 'JENNIE', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', - discImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + backgroundImageUrl: getMockImageUrl('busan'), + discImageUrl: getMockImageUrl('busan'), id: 'log-1', moments: [ { artistName: 'JENNIE', id: 'log-1', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + imageUrl: getMockImageUrl('busan'), location: { lat: 35.1532, lng: 129.1187 }, placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', @@ -80,14 +82,14 @@ export const recapShareById: Record = { }, 'log-2': { artistName: '아이유', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', - discImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + backgroundImageUrl: getMockImageUrl('seoul'), + discImageUrl: getMockImageUrl('seoul'), id: 'log-2', moments: [ { artistName: '아이유', id: 'log-2', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + imageUrl: getMockImageUrl('seoul'), location: { lat: 37.5294, lng: 126.9348 }, placeName: '한강', recordedAt: '2026-05-25T00:10:00.000Z', @@ -101,14 +103,14 @@ export const recapShareById: Record = { }, 'log-3': { artistName: '10cm', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', - discImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/84/1870084.jpg', + backgroundImageUrl: getMockImageUrl('busan'), + discImageUrl: getMockImageUrl('busan'), id: 'log-3', moments: [ { artistName: '10cm', id: 'log-3', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + imageUrl: getMockImageUrl('busan'), location: { lat: 35.1796, lng: 129.0756 }, placeName: '부산', recordedAt: '2026-05-25T00:20:00.000Z', diff --git a/src/mocks/tourMocks.ts b/src/mocks/tourMocks.ts index 46c51d7..5b8b3ff 100644 --- a/src/mocks/tourMocks.ts +++ b/src/mocks/tourMocks.ts @@ -1,5 +1,7 @@ import { GeoPoint, PlaceContext } from '@/types/domain'; +import { getMockImageUrl } from './imageAssets'; + const seoulPlaces: PlaceContext[] = [ { address: '서울특별시 용산구 남산공원길 105', @@ -7,7 +9,7 @@ const seoulPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 620, id: 'seed-namsan', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + imageUrl: getMockImageUrl('seoul'), location: { lat: 37.5512, lng: 126.9882 }, overview: '서울의 야경과 도심 산책을 함께 즐길 수 있는 대표 관광지입니다.', source: 'seed', @@ -19,7 +21,7 @@ const seoulPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 940, id: 'seed-gwanghwamun', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + imageUrl: getMockImageUrl('seoul'), location: { lat: 37.5759, lng: 126.9768 }, overview: '도시 산책과 역사 관광 맥락을 함께 제공하는 서울 중심 관광지입니다.', source: 'seed', @@ -34,7 +36,7 @@ const busanPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 360, id: 'seed-gwangalli', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + imageUrl: getMockImageUrl('busan'), location: { lat: 35.1532, lng: 129.1186 }, overview: '바다 산책과 야경을 함께 즐길 수 있는 부산 대표 해변입니다.', source: 'seed', @@ -46,7 +48,7 @@ const busanPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 1280, id: 'seed-haeundae', - imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + imageUrl: getMockImageUrl('busan'), location: { lat: 35.1587, lng: 129.1604 }, overview: '해변과 드라이브 맥락에 어울리는 부산 대표 관광지입니다.', source: 'seed', diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts index bd2257d..a599110 100644 --- a/src/store/recommendationEventStore.ts +++ b/src/store/recommendationEventStore.ts @@ -19,6 +19,7 @@ export type RecommendationEventType = | 'live_track_shared' | 'nearby_sound_opened' | 'recommendation_mode_change' + | 'recommendation_feedback' | 'recap_representative_track_select'; export type RecommendationEventContext = { diff --git a/src/test/__tests__/bottomSheetGesture.test.ts b/src/test/__tests__/bottomSheetGesture.test.ts new file mode 100644 index 0000000..cd7b111 --- /dev/null +++ b/src/test/__tests__/bottomSheetGesture.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + getDismissibleBottomSheetOffset, + getResistedBottomSheetOffset, + resolveBottomSheetSnapOffset, + shouldDismissBottomSheet, +} from '@/utils/bottomSheetGesture'; + +describe('bottom sheet gesture helpers', () => { + it('resists upward dragging on a dismissible sheet', () => { + expect(getDismissibleBottomSheetOffset(-100)).toBe(-18); + expect(getDismissibleBottomSheetOffset(48)).toBe(48); + }); + + it('dismisses only after enough downward distance or velocity', () => { + expect(shouldDismissBottomSheet({ translationY: 90 })).toBe(true); + expect(shouldDismissBottomSheet({ translationY: 24, velocity: 0.8 })).toBe(true); + expect(shouldDismissBottomSheet({ translationY: 20, velocity: 0.8 })).toBe(false); + }); + + it('resists movement beyond expanded and collapsed snap points', () => { + expect(getResistedBottomSheetOffset(-20, 0, 120)).toBe(-4.4); + expect(getResistedBottomSheetOffset(150, 0, 120)).toBe(128.4); + }); + + it('snaps based on velocity before falling back to the midpoint', () => { + expect( + resolveBottomSheetSnapOffset({ + collapsedOffset: 120, + currentOffset: 120, + translationY: -10, + velocity: -0.5, + }), + ).toBe(0); + expect( + resolveBottomSheetSnapOffset({ + collapsedOffset: 120, + currentOffset: 0, + translationY: 10, + velocity: 0.5, + }), + ).toBe(120); + expect( + resolveBottomSheetSnapOffset({ + collapsedOffset: 120, + currentOffset: 120, + translationY: -70, + }), + ).toBe(0); + expect( + resolveBottomSheetSnapOffset({ + collapsedOffset: 120, + currentOffset: 0, + translationY: 70, + }), + ).toBe(120); + }); +}); diff --git a/src/test/__tests__/mockImageAssets.test.ts b/src/test/__tests__/mockImageAssets.test.ts new file mode 100644 index 0000000..447d796 --- /dev/null +++ b/src/test/__tests__/mockImageAssets.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getMockImageUrl } from '@/mocks/imageAssets'; + +describe('getMockImageUrl', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('uses the API origin for public regional assets', () => { + vi.stubEnv( + 'EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', + 'https://soundlog.example.com/api/soundlog', + ); + + expect(getMockImageUrl('busan')).toBe( + 'https://soundlog.example.com/assets/playlists/busan.webp', + ); + }); + + it('lets the UI render a semantic fallback when no API origin exists', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', ''); + + expect(getMockImageUrl('seoul')).toBeUndefined(); + }); +}); diff --git a/src/utils/authenticatedImageSource.test.ts b/src/utils/authenticatedImageSource.test.ts index b6c8527..a5395a9 100644 --- a/src/utils/authenticatedImageSource.test.ts +++ b/src/utils/authenticatedImageSource.test.ts @@ -212,7 +212,7 @@ describe('toAuthenticatedImageSource', () => { }); it('leaves external tourism API photo URLs completely unchanged', () => { - const uri = 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg'; + const uri = 'https://images.example.com/tour/place.jpg'; const result = toAuthenticatedImageSource(uri, TOKEN); expect(result).toEqual({ uri }); diff --git a/src/utils/bottomSheetGesture.ts b/src/utils/bottomSheetGesture.ts new file mode 100644 index 0000000..ea7c4b1 --- /dev/null +++ b/src/utils/bottomSheetGesture.ts @@ -0,0 +1,61 @@ +type DismissGestureInput = { + distance?: number; + translationY: number; + velocity?: number; +}; + +type SnapGestureInput = { + collapsedOffset: number; + currentOffset: number; + expandedOffset?: number; + translationY: number; + velocity?: number; +}; + +export function getDismissibleBottomSheetOffset(translationY: number) { + return translationY < 0 ? translationY * 0.18 : translationY; +} + +export function shouldDismissBottomSheet({ + distance = 84, + translationY, + velocity = 0, +}: DismissGestureInput) { + return translationY > distance || (velocity > 0.72 && translationY > 20); +} + +export function getResistedBottomSheetOffset( + offset: number, + expandedOffset: number, + collapsedOffset: number, +) { + if (offset < expandedOffset) { + return expandedOffset + (offset - expandedOffset) * 0.22; + } + + if (offset > collapsedOffset) { + return collapsedOffset + (offset - collapsedOffset) * 0.28; + } + + return offset; +} + +export function resolveBottomSheetSnapOffset({ + collapsedOffset, + currentOffset, + expandedOffset = 0, + translationY, + velocity = 0, +}: SnapGestureInput) { + if (velocity < -0.32) { + return expandedOffset; + } + + if (velocity > 0.32) { + return collapsedOffset; + } + + const nextOffset = currentOffset + translationY; + const midpoint = expandedOffset + (collapsedOffset - expandedOffset) / 2; + return nextOffset <= midpoint ? expandedOffset : collapsedOffset; +} From 71c7344484d74c8f1cfd23f21d1947b8126df79b Mon Sep 17 00:00:00 2001 From: manNomi Date: Wed, 2 Sep 2026 00:02:46 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20EAS=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=97=90=EC=84=9C=20=EA=B2=80=EC=88=98=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EB=AC=BC=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .easignore | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .easignore diff --git a/.easignore b/.easignore new file mode 100644 index 0000000..4c7a9b2 --- /dev/null +++ b/.easignore @@ -0,0 +1,41 @@ +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +/ios +/android + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# TypeScript +*.tsbuildinfo + +# Release documentation and local QA captures +artifacts/ +docs/ From 048749c3e2f6f52f8cd72bda43a0a258562d710c Mon Sep 17 00:00:00 2001 From: manNomi Date: Wed, 2 Sep 2026 00:38:42 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=EC=95=B1=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=A0=88=EC=B0=A8=EC=99=80=20=EC=84=9C=EB=B2=84=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EA=B2=BD=EA=B3=84=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 13 ++ README.md | 1 + deploy.md | 370 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 384 insertions(+) create mode 100644 deploy.md diff --git a/AGENTS.md b/AGENTS.md index 003fbf5..a019c59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,19 @@ - API health checks and command-line diagnostics may support investigation, but they do not replace simulator verification and must not be reported as completed app testing. - Use a web target only for explicit web build or export compatibility work. Even then, do not use it for product acceptance testing unless the user's latest message explicitly overrides this rule. +## Server Ownership Boundary + +- Soundlog 앱 에이전트는 모바일 앱 저장소만 담당한다. `SoundLogServer`, `soundlog-ml`과 운영 인프라는 서버 팀원이 담당한다. +- 앱 빌드나 배포 요청을 서버 배포 권한으로 해석하지 않는다. 앱을 EAS 또는 TestFlight로 배포하더라도 서버는 변경하지 않는다. +- 서버 저장소의 파일을 수정하거나 커밋하거나 브랜치를 푸시하거나 Pull Request를 만들지 않는다. +- 운영 서버에 SSH로 접속하지 않는다. Docker 이미지를 배포하거나 컨테이너를 재시작하거나 배포 워크플로를 실행하지 않는다. +- 서버의 환경변수와 GitHub Actions 시크릿과 SSH 키와 도메인과 DNS와 인증서와 방화벽 설정을 생성하거나 변경하지 않는다. +- 운영 데이터베이스의 migration과 seed와 관리자 API와 사용자 또는 신고 데이터를 변경하지 않는다. +- 앱 연동 검수에 필요한 공개 HTTPS `GET` 요청은 허용한다. 예를 들어 health와 OpenAPI와 법적 문서의 응답 상태를 읽을 수 있다. 이 검사는 서버 배포나 서버 기능 완료의 증거로 사용하지 않는다. +- 앱 변경에 서버 작업이 필요하면 필요한 API 계약과 현재 실패 증거만 정리해 서버 팀원에게 전달한다. 앱 작업 중 임의로 서버 수정이나 우회 배포를 하지 않는다. +- 작업을 시작하기 전에 `pwd`, `git rev-parse --show-toplevel`, `git remote get-url origin`으로 앱 저장소인지 확인한다. 서버 저장소가 나오면 즉시 중단하고 앱 저장소로 이동한다. +- 서버 작업을 함께 해달라는 후속 요청이 오더라도 대상 저장소와 작업 범위를 사용자가 최신 메시지에서 명시하지 않으면 수행하지 않는다. + ## Text Color - 기본 사용자 노출 텍스트와 버튼, 탭, 칩의 인터랙션 라벨은 흰색 또는 흰색 투명도 계열을 사용한다. 상태를 구분하는 의미 색상과 브랜드 강조 색상은 예외로 둔다. diff --git a/README.md b/README.md index ecc6c1c..23c1e2e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/Test ## 문서 +- [앱 빌드와 TestFlight 배포](deploy.md) - [문서 인덱스](docs/README.md) - [리캡·로그 도메인 기준](docs/product/RECAP_LOG_DOMAIN_MODEL.md) - [서비스 기획서](docs/product/SOUNDLOG_APP_PLANNING.md) diff --git a/deploy.md b/deploy.md new file mode 100644 index 0000000..6e85dfc --- /dev/null +++ b/deploy.md @@ -0,0 +1,370 @@ +# Soundlog 앱 빌드와 TestFlight 배포 + +이 문서는 Soundlog 모바일 앱을 CLI로 검증하고 iOS 빌드를 만든 뒤 TestFlight까지 업로드하는 절차를 설명합니다. 대상 저장소는 `SoundLogApp`이며 서버 배포는 이 절차에 포함하지 않습니다. + +## 담당 범위 + +앱 담당자는 다음 작업만 수행합니다. + +- 앱 코드와 `app.config.js` 및 `eas.json` 검증 +- iOS Simulator 실행과 주요 화면 검수 +- 로컬 IPA 또는 EAS 클라우드 빌드 생성 +- 완성된 IPA를 App Store Connect에 업로드 +- EAS와 TestFlight 처리 상태 확인 + +다음 작업은 서버 팀원에게 일임합니다. + +- `SoundLogServer`와 `soundlog-ml` 코드 변경 +- 운영 서버 SSH 접속과 Docker 배포 +- 서버 환경변수와 GitHub Actions 시크릿 변경 +- DB migration과 seed 실행 +- 도메인과 DNS와 인증서와 방화벽 변경 + +앱 배포 중 서버 문제가 발견되면 실패한 공개 API 주소와 응답 상태만 서버 팀원에게 전달합니다. 앱 담당자가 서버를 직접 수정하거나 배포하지 않습니다. + +## 현재 앱 식별 정보 + +| 항목 | 값 | +| --- | --- | +| Expo 프로젝트 | `@mannomi/soundlog` | +| EAS Project ID | `4b07627b-36bf-463d-a15e-b4839022ecbb` | +| iOS Bundle ID | `com.mannomi.soundlog` | +| App Store Connect App ID | `6797038341` | +| 운영 API | `https://api.soundlog.p-e.kr` | +| 앱 버전 관리 | EAS remote version | +| production 빌드 번호 | EAS가 자동 증가 | + +`eas.json`의 production profile은 API와 업로드 주소를 `https://api.soundlog.p-e.kr`로 고정합니다. production 빌드에서 HTTP API를 사용하면 `app.config.js` 검증이 실패해야 합니다. + +## 1. 사전 준비 + +macOS에 Xcode와 Node.js와 CocoaPods가 설치되어 있어야 합니다. Expo 계정과 Apple Developer Program 권한도 필요합니다. + +```bash +cd /Users/manwook-han/Desktop/hmw/code/apps/soundlog/soundlog + +pwd +git rev-parse --show-toplevel +git remote get-url origin +node --version +npm --version +xcodebuild -version +pod --version +eas --version +eas whoami +``` + +원격 저장소가 `SoundLogTeam/SoundLogApp`이 아니면 배포를 중단합니다. 서버 저장소에서는 이 문서의 명령을 실행하지 않습니다. + +EAS CLI가 없거나 오래된 경우 공식 npm 패키지로 설치합니다. + +```bash +npm install -g eas-cli@latest +eas --version +``` + +## 2. 작업 상태와 디스크 확인 + +빌드에 넣을 커밋과 로컬 변경을 먼저 확인합니다. + +```bash +git status --short --branch +git log -1 --oneline +df -h / +``` + +출시 코드가 커밋되지 않았거나 어떤 파일이 빌드에 포함되는지 불분명하면 빌드를 시작하지 않습니다. 사용자 작업이 남아 있는 저장소를 강제로 초기화하거나 정리하지 않습니다. + +로컬 네이티브 빌드는 Xcode 파생 데이터까지 생성하므로 10GB 이상의 여유 공간을 권장합니다. EAS 업로드 중 `No space left on device`가 발생하면 소스 파일을 삭제하지 말고 재생성 가능한 Xcode DerivedData와 npm 다운로드 캐시만 정리합니다. + +```bash +derived_data_dir=/Users/manwook-han/Library/Developer/Xcode/DerivedData +npm_cache_dir=/Users/manwook-han/.npm/_cacache + +find "$derived_data_dir" -mindepth 1 -depth -delete +find "$npm_cache_dir" -mindepth 1 -depth -delete +df -h / +``` + +위 삭제는 진행 중인 Xcode와 Metro를 종료한 뒤 실행합니다. Simulator 기기 데이터와 앱 소스는 삭제하지 않습니다. + +`.easignore`는 `node_modules`, 생성된 네이티브 폴더, 로컬 환경변수, QA 캡처와 문서를 EAS 입력에서 제외합니다. `EAS_NO_VCS=1`은 의도하지 않은 파일까지 업로드할 수 있으므로 일반 배포에서 사용하지 않습니다. + +## 3. 의존성과 출시 설정 검증 + +잠금 파일 기준으로 의존성을 설치하고 전체 앱 검사를 실행합니다. + +```bash +npm ci +npm run typecheck +npm test +npm run check:store-release +npm run check:image-components +npm run check:recap-clustering +npm run check:server-contract +npm run doctor +``` + +운영 API를 읽기 전용으로 확인할 때는 다음 명령을 사용합니다. + +```bash +npm run check:store-release:live +``` + +이 검사가 실패하면 앱 배포와 서버 배포를 구분해 판단합니다. 앱 설정 오류는 앱에서 수정합니다. 법적 페이지와 OpenAPI와 moderation API와 공개 seed 문제는 서버 팀원에게 전달합니다. 앱 담당자는 실패를 해결하려고 서버에 접속하거나 서버 배포를 실행하지 않습니다. + +production profile이 실제로 어떤 설정을 생성하는지 확인합니다. + +```bash +EAS_BUILD_PROFILE=production npx expo config --type public --json > /tmp/soundlog-production-config.json +jq '{name, version, ios, extra}' /tmp/soundlog-production-config.json +``` + +출력이나 문서에 비밀값을 복사하지 않습니다. `EXPO_PUBLIC_` 값은 앱 번들에 포함되는 공개 설정이므로 비밀키를 넣으면 안 됩니다. + +## 4. iOS Simulator 로컬 빌드와 검수 + +Soundlog 제품 검수는 브라우저가 아니라 iOS Simulator에서 수행합니다. + +사용 가능한 Simulator를 확인합니다. + +```bash +xcrun simctl list devices available +``` + +기본 개발 빌드를 설치하고 Metro를 실행합니다. + +```bash +npx expo run:ios --device "iPhone 16 Pro" +``` + +한 번 설치한 뒤 JavaScript 코드만 다시 확인할 때는 다음 명령을 사용합니다. + +```bash +npx expo start --dev-client +``` + +최소한 다음 흐름을 Simulator에서 확인합니다. + +- 첫 실행 온보딩과 로그인 진입 +- 로그인 전에 필수 이용약관과 개인정보 처리방침 동의 노출 +- 실제 운영 API 로그인과 세션 유지 +- 지도와 현재 위치와 관광지 마커 상세 정보 +- 장소 기반 음악 추천과 추천 곡 목록 +- 곡 선택과 좋아요와 저장과 미니 플레이어 +- 공개 로그와 내 로그 전환 +- 공개 콘텐츠 신고와 사용자 차단 진입점 +- 마이 페이지와 로그아웃 + +API health check와 단위 테스트는 보조 증거입니다. Simulator에서 화면과 인터랙션을 확인하지 않았다면 앱 검수가 끝난 것이 아닙니다. + +## 5. EAS 클라우드 production 빌드 + +일반적인 TestFlight 배포에는 EAS 클라우드 빌드를 권장합니다. Mac에서 Xcode 아카이브를 오래 유지할 필요가 없고 EAS에 저장된 서명 자격 증명을 그대로 사용할 수 있습니다. + +빌드만 생성하고 완료까지 기다립니다. + +```bash +eas build \ + --platform ios \ + --profile production \ + --non-interactive \ + --wait +``` + +빌드와 TestFlight 업로드를 한 번에 예약하려면 다음 명령을 사용합니다. + +```bash +eas build \ + --platform ios \ + --profile production \ + --auto-submit \ + --non-interactive \ + --wait +``` + +터미널을 점유하지 않고 시작하려면 `--wait` 대신 `--no-wait`을 사용합니다. 출력된 Build ID와 Submission ID를 반드시 기록합니다. + +```bash +eas build \ + --platform ios \ + --profile production \ + --auto-submit \ + --non-interactive \ + --no-wait +``` + +`eas.json`의 `appVersionSource`가 `remote`이고 production에 `autoIncrement`가 설정되어 있으므로 빌드 번호는 EAS에서 자동 증가합니다. 실패한 업로드 시도도 번호를 소비할 수 있습니다. 번호가 건너뛰어도 같은 번호를 재사용하려고 임의로 되돌리지 않습니다. + +## 6. Mac에서 production IPA 로컬 빌드 + +EAS 빌드 머신 대신 현재 Mac에서 production IPA를 만들 때 `--local`을 사용합니다. 로컬 빌드는 macOS와 Xcode와 CocoaPods와 Fastlane 상태에 영향을 받으므로 클라우드 빌드가 불가능하거나 로컬 아카이브가 필요한 경우에만 사용합니다. + +출력 디렉터리를 준비합니다. + +```bash +mkdir -p artifacts/builds +``` + +production IPA를 로컬에서 생성합니다. + +```bash +eas build \ + --platform ios \ + --profile production \ + --local \ + --non-interactive \ + --output ./artifacts/builds/soundlog-production.ipa +``` + +빌드가 끝나면 파일 형식과 크기를 확인합니다. + +```bash +file ./artifacts/builds/soundlog-production.ipa +du -h ./artifacts/builds/soundlog-production.ipa +``` + +로컬 IPA는 저장소에 커밋하지 않습니다. `.easignore`와 Git ignore 정책을 유지하고 외부로 전달할 때는 승인된 배포 경로만 사용합니다. + +## 7. 완성된 IPA를 TestFlight에 업로드 + +EAS 클라우드 빌드가 끝났지만 자동 제출하지 않았다면 Build ID로 업로드합니다. + +```bash +eas submit \ + --platform ios \ + --profile production \ + --id \ + --non-interactive \ + --wait +``` + +로컬에서 만든 IPA는 파일 경로로 업로드합니다. + +```bash +eas submit \ + --platform ios \ + --profile production \ + --path ./artifacts/builds/soundlog-production.ipa \ + --non-interactive \ + --wait +``` + +EAS credentials service에 App Store Connect API Key가 연결되어 있으면 그 키를 사용합니다. API Key 파일이나 issuer와 private key 값을 저장소와 PR과 문서에 기록하지 않습니다. + +## 8. 빌드와 제출 상태 확인 + +최근 iOS 빌드를 확인합니다. + +```bash +eas build:list --platform ios --limit 5 --non-interactive --json +``` + +특정 빌드를 확인합니다. + +```bash +eas build:view --json +``` + +최근 제출을 확인합니다. + +```bash +eas submit:list --platform ios --limit 5 --json +``` + +특정 제출을 확인합니다. + +```bash +eas submit:view --json +``` + +App Store Connect와 TestFlight에서 실제로 인식한 빌드를 조회합니다. + +```bash +eas submit:status --platform ios --json --non-interactive +``` + +상태는 다음처럼 구분합니다. + +| 상태 | 의미 | +| --- | --- | +| Build `NEW` 또는 `IN_PROGRESS` | EAS가 빌드를 준비하거나 컴파일하는 중 | +| Build `FINISHED` | 서명된 IPA 생성 완료 | +| Submit `IN_QUEUE` | EAS가 Apple 업로드 작업을 기다리는 중 | +| Submit `IN_PROGRESS` | EAS가 App Store Connect에 업로드 중 | +| Submit `FINISHED` | Apple로 바이너리 전달 완료 | +| TestFlight `PROCESSING` | Apple이 바이너리를 처리하는 중 | +| TestFlight `VALID` | TestFlight에서 사용할 수 있는 빌드 | + +Build가 `FINISHED`여도 TestFlight 배포가 끝난 것은 아닙니다. Submit이 `FINISHED`이고 `eas submit:status`에서 새 빌드가 보여야 업로드가 완료된 것입니다. + +## 9. 제출 대기열과 중복 업로드 방지 + +`--auto-submit`으로 생성한 제출이 `IN_QUEUE`에 오래 머물러도 오류가 없다면 같은 Build ID를 다시 제출하지 않습니다. 중복 제출은 Apple 처리 오류와 운영 혼선을 만들 수 있습니다. + +다음 순서로 확인합니다. + +```bash +eas submit:view --json +eas submit:list --platform ios --limit 5 --json +eas submit:status --platform ios --json --non-interactive +``` + +EAS 서비스 상태도 확인할 수 있습니다. + +- [Expo 서비스 상태](https://status.expo.dev/) +- [EAS Build 대기열](https://expo.dev/eas-build-status) + +제출 상태가 `ERRORED`일 때만 로그를 읽고 원인을 수정한 뒤 재시도합니다. 대기 중인 제출을 취소하거나 새 제출을 만드는 것은 마지막 수단입니다. + +## 10. 자주 발생하는 실패 + +### `No space left on device` + +프로젝트 압축용 임시 Git clone이나 Xcode DerivedData를 만들 공간이 부족한 상태입니다. `df -h /`로 확인하고 2장의 정리 절차를 따릅니다. 소스와 Simulator 데이터를 삭제하지 않습니다. + +### EAS가 로컬 Git clone을 만들지 못함 + +다음 명령으로 로컬 저장소를 얕게 복제할 수 있는지 확인합니다. + +```bash +clone_test_dir=$(mktemp -d /tmp/soundlog-eas-clone.XXXXXX) +git clone --no-checkout --no-hardlinks --depth 1 "file://$(pwd)" "$clone_test_dir/repo" +git -C "$clone_test_dir/repo" rev-parse --short HEAD +find "$clone_test_dir" -mindepth 1 -depth -delete +rmdir "$clone_test_dir" +``` + +임시 디렉터리는 `mktemp`가 만든 정확한 경로만 삭제합니다. 저장소 루트나 홈 디렉터리를 재귀 삭제 대상으로 사용하지 않습니다. + +### CocoaPods 버전 불일치 + +Expo patch 버전을 올린 뒤 Pod lock과 로컬 podspec이 어긋날 수 있습니다. + +```bash +cd ios +pod install --repo-update +``` + +특정 Pod만 갱신하면 연쇄 의존성이 다시 충돌할 수 있습니다. Expo 네이티브 모듈이 여러 개 함께 바뀌었다면 전체 Pod 정합성을 확인합니다. `ios` 폴더는 생성 산출물이므로 production 설정의 기준은 `app.config.js`와 package lock입니다. + +### custom Metro 설정 경고 + +EAS가 `metro.config.js`가 `@expo/metro-config`를 확장하지 않는다고 경고하면 실제 파일을 확인합니다. 자산 누락 가능성이 있으므로 경고를 무시한 채 TestFlight를 배포하지 않습니다. 현재 프로젝트는 Expo 설정을 확장해야 합니다. + +### Apple 처리 지연 + +Submit이 끝나도 TestFlight에 나타나기까지 시간이 걸릴 수 있습니다. `eas submit:status`와 App Store Connect에서 처리 상태를 확인합니다. Apple 처리 중에는 같은 바이너리를 다시 업로드하지 않습니다. + +## 11. 배포 완료 기준 + +다음 조건을 모두 만족해야 앱 TestFlight 배포가 완료된 것입니다. + +- 출시 대상 커밋이 원격 브랜치에 존재한다. +- 자동 검사와 iOS Simulator 검수가 통과했다. +- EAS Build가 `FINISHED`다. +- EAS Submit이 `FINISHED`다. +- `eas submit:status` 또는 App Store Connect에서 새 빌드 번호가 확인된다. +- 새 빌드가 TestFlight 내부 테스터에게 제공 가능한 상태다. + +TestFlight 업로드는 App Store 심사 제출과 다릅니다. 새 빌드를 App Store 버전에 선택하고 심사 답변과 스크린샷과 심사 정보를 제출하는 작업은 별도 승인과 별도 검수가 필요합니다. From a6626f7f25378ddeb543dc4c90b1a64e292141eb Mon Sep 17 00:00:00 2001 From: manNomi Date: Wed, 2 Sep 2026 00:42:51 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B0=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dev/DevTestManager.tsx | 6 ++-- src/components/travel/travelData.ts | 8 ++--- src/mocks/homeMocks.ts | 34 +++++++++---------- src/mocks/playlistMocks.ts | 24 ++++++------- src/mocks/recapMocks.ts | 30 ++++++++-------- src/mocks/tourMocks.ts | 10 +++--- ...sets.test.ts => publicImageAssets.test.ts} | 10 +++--- .../publicImageAssets.ts} | 4 +-- 8 files changed, 63 insertions(+), 63 deletions(-) rename src/test/__tests__/{mockImageAssets.test.ts => publicImageAssets.test.ts} (62%) rename src/{mocks/imageAssets.ts => utils/publicImageAssets.ts} (79%) diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index ad7870a..660bcc6 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -17,7 +17,7 @@ import { SOUNDLOG_TERMS_VERSION } from '@/constants/legal'; import { getApiBaseUrl } from '@/api/client'; import { queryClient } from '@/providers/queryClient'; import { AppText } from '@/components/AppText'; -import { getMockImageUrl } from '@/mocks/imageAssets'; +import { getPublicImageUrl } from '@/utils/publicImageAssets'; import { playlistCurationById } from '@/mocks/playlistMocks'; import { useAuthStore } from '@/store/authStore'; import { useHomeFilterStore } from '@/store/homeFilterStore'; @@ -45,7 +45,7 @@ const placePresets: Array<{ category: '해변', contentType: '관광지', id: 'dev-busan-gwangalli', - imageUrl: getMockImageUrl('busan'), + imageUrl: getPublicImageUrl('busan'), location: { lat: 35.1532, lng: 129.1186 }, overview: '바다와 야경, 산책 맥락을 테스트하는 개발용 장소입니다.', source: 'seed', @@ -59,7 +59,7 @@ const placePresets: Array<{ category: '야경', contentType: '문화시설', id: 'dev-seoul-night', - imageUrl: getMockImageUrl('seoul'), + imageUrl: getPublicImageUrl('seoul'), location: { lat: 37.5512, lng: 126.9882 }, overview: '도시 야경과 감성 음악 추천을 테스트하는 개발용 장소입니다.', source: 'seed', diff --git a/src/components/travel/travelData.ts b/src/components/travel/travelData.ts index 3dcda8c..26f1359 100644 --- a/src/components/travel/travelData.ts +++ b/src/components/travel/travelData.ts @@ -1,5 +1,5 @@ import type { MomentLog, MoodTag, TravelMode } from '@/types/domain'; -import { getMockImageUrl } from '@/mocks/imageAssets'; +import { getPublicImageUrl } from '@/utils/publicImageAssets'; export type TravelModeOption = { icon: string; @@ -64,7 +64,7 @@ export const sampleMoments: MomentLog[] = [ createdAt: '2026-06-06T17:21:00.000+09:00', id: 'sample-gwangalli', moodTags: ['fresh'], - photoUri: getMockImageUrl('busan'), + photoUri: getPublicImageUrl('busan'), placeName: '광안리 해변', source: 'camera', track: { @@ -79,7 +79,7 @@ export const sampleMoments: MomentLog[] = [ createdAt: '2026-06-06T15:42:00.000+09:00', id: 'sample-seongsu', moodTags: ['calm', 'local'], - photoUri: getMockImageUrl('seoul'), + photoUri: getPublicImageUrl('seoul'), placeName: '성수 카페거리', source: 'camera', track: { @@ -94,7 +94,7 @@ export const sampleMoments: MomentLog[] = [ createdAt: '2026-06-06T13:18:00.000+09:00', id: 'sample-night', moodTags: ['emotional'], - photoUri: getMockImageUrl('seoul'), + photoUri: getPublicImageUrl('seoul'), placeName: '남산 산책로', source: 'camera', track: { diff --git a/src/mocks/homeMocks.ts b/src/mocks/homeMocks.ts index 85c6f2f..5865564 100644 --- a/src/mocks/homeMocks.ts +++ b/src/mocks/homeMocks.ts @@ -1,11 +1,11 @@ import { FeaturedPlaylist, MoodRecommendation, MusicLogItem } from '@/types/domain'; -import { getMockImageUrl } from './imageAssets'; +import { getPublicImageUrl } from '@/utils/publicImageAssets'; export const featuredPlaylists: FeaturedPlaylist[] = [ { - backgroundImageUrl: getMockImageUrl('geoje'), - coverImageUrl: getMockImageUrl('geoje'), + backgroundImageUrl: getPublicImageUrl('geoje'), + coverImageUrl: getPublicImageUrl('geoje'), id: 'geoje-ocean', regionName: '거제', description: '거제 바다와 섬길을 따라 듣기 좋은 플레이리스트예요.', @@ -13,8 +13,8 @@ export const featuredPlaylists: FeaturedPlaylist[] = [ trackCount: 8, }, { - backgroundImageUrl: getMockImageUrl('busan'), - coverImageUrl: getMockImageUrl('busan'), + backgroundImageUrl: getPublicImageUrl('busan'), + coverImageUrl: getPublicImageUrl('busan'), id: 'busan-ocean', regionName: '부산', description: '부산광역시에 어울리는 노래를 추천해드립니다.', @@ -22,8 +22,8 @@ export const featuredPlaylists: FeaturedPlaylist[] = [ trackCount: 12, }, { - backgroundImageUrl: getMockImageUrl('seoul'), - coverImageUrl: getMockImageUrl('seoul'), + backgroundImageUrl: getPublicImageUrl('seoul'), + coverImageUrl: getPublicImageUrl('seoul'), id: 'seoul-night', regionName: '서울', description: '서울 야경과 어울리는 음악을 모았어요.', @@ -39,7 +39,7 @@ export const moodRecommendations: MoodRecommendation[] = [ subtitle: '천천히 걷는 시간', color: '#2B176C', genres: ['인디', '발라드'], - imageUrl: getMockImageUrl('chuncheon'), + imageUrl: getPublicImageUrl('chuncheon'), moods: ['잔잔한', '감성적인'], playlistId: 'calm-walk', track: { artist: 'JENNIE', fallbackColor: '#192554', id: 'seoul-city', title: 'Seoul City' }, @@ -51,7 +51,7 @@ export const moodRecommendations: MoodRecommendation[] = [ subtitle: '창문을 열고 달리는 순간', color: '#B1913A', genres: ['팝', 'K-POP'], - imageUrl: getMockImageUrl('geoje'), + imageUrl: getPublicImageUrl('geoje'), moods: ['신나는', '시원한', '활기찬'], playlistId: 'drive', track: { artist: '김건모', fallbackColor: '#48A5B4', id: 'moon-seoul', title: '서울의 달' }, @@ -63,7 +63,7 @@ export const moodRecommendations: MoodRecommendation[] = [ subtitle: '불빛이 번지는 밤', color: '#1F2937', genres: ['R&B', 'OST'], - imageUrl: getMockImageUrl('seoul'), + imageUrl: getPublicImageUrl('seoul'), moods: ['감성적인', '잔잔한', '설레는'], playlistId: 'city-night', track: { artist: '폴킴', fallbackColor: '#D70D31', id: 'hangang', title: '한강에서' }, @@ -75,7 +75,7 @@ export const moodRecommendations: MoodRecommendation[] = [ subtitle: '잠시 머물고 싶은 오후', color: '#3F2C6B', genres: ['인디', 'R&B'], - imageUrl: getMockImageUrl('jeonju'), + imageUrl: getPublicImageUrl('jeonju'), moods: ['잔잔한', '시원한'], playlistId: 'cafe-indie', track: { artist: '10cm', fallbackColor: '#DA6C51', id: 'seoul-night-track', title: '서울의 밤' }, @@ -88,7 +88,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: 'JENNIE', createdAt: '2026-05-25T00:00:00.000Z', id: 'log-1', - imageUrl: getMockImageUrl('busan'), + imageUrl: getPublicImageUrl('busan'), placeName: '광안리', recapShareId: 'log-1', trackTitle: 'Seoul City', @@ -97,7 +97,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: '아이유', createdAt: '2026-05-25T00:10:00.000Z', id: 'log-2', - imageUrl: getMockImageUrl('seoul'), + imageUrl: getPublicImageUrl('seoul'), placeName: '한강', recapShareId: 'log-2', trackTitle: '밤편지', @@ -106,7 +106,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: '10cm', createdAt: '2026-05-25T00:20:00.000Z', id: 'log-3', - imageUrl: getMockImageUrl('busan'), + imageUrl: getPublicImageUrl('busan'), placeName: '부산', recapShareId: 'log-3', trackTitle: '서울의 밤', @@ -115,7 +115,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: 'wave to earth', createdAt: '2026-05-25T00:30:00.000Z', id: 'log-4', - imageUrl: getMockImageUrl('jeju'), + imageUrl: getPublicImageUrl('jeju'), placeName: '제주 애월', recapShareId: 'log-1', trackTitle: 'seasons', @@ -124,7 +124,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: 'AKMU', createdAt: '2026-05-25T00:40:00.000Z', id: 'log-5', - imageUrl: getMockImageUrl('gyeongju'), + imageUrl: getPublicImageUrl('gyeongju'), placeName: '경주', recapShareId: 'log-2', trackTitle: 'Dinosaur', @@ -133,7 +133,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: '잔나비', createdAt: '2026-05-25T00:50:00.000Z', id: 'log-6', - imageUrl: getMockImageUrl('seoul'), + imageUrl: getPublicImageUrl('seoul'), placeName: '남산', recapShareId: 'log-3', trackTitle: '뜨거운 여름밤은 가고', diff --git a/src/mocks/playlistMocks.ts b/src/mocks/playlistMocks.ts index ab04088..d963a90 100644 --- a/src/mocks/playlistMocks.ts +++ b/src/mocks/playlistMocks.ts @@ -1,6 +1,6 @@ import { PlaylistCuration, Track } from '@/types/domain'; -import { getMockImageUrl } from './imageAssets'; +import { getPublicImageUrl } from '@/utils/publicImageAssets'; const tracks: Track[] = [ { @@ -224,8 +224,8 @@ const festivalKpopTracks = createMoodPlaylistTracks('festival-kpop', [ ], ['#9A3E62', '#D70D31', '#E66A73', '#29376B']); export const playlistDetail: PlaylistCuration = { - backgroundImageUrl: getMockImageUrl('seoul'), - coverImageUrl: getMockImageUrl('seoul'), + backgroundImageUrl: getPublicImageUrl('seoul'), + coverImageUrl: getPublicImageUrl('seoul'), durationText: '36:00분', id: 'seoul-night', placeName: '서울 야경', @@ -238,7 +238,7 @@ export const playlistDetail: PlaylistCuration = { export const playlistCurationById: Record = { 'calm-walk': { accentColor: '#2B176C', - coverImageUrl: getMockImageUrl('chuncheon'), + coverImageUrl: getPublicImageUrl('chuncheon'), durationText: '28:00분', id: 'calm-walk', placeName: '느린 걸음의 산책길', @@ -249,7 +249,7 @@ export const playlistCurationById: Record = { }, drive: { accentColor: '#B1913A', - coverImageUrl: getMockImageUrl('geoje'), + coverImageUrl: getPublicImageUrl('geoje'), durationText: '34:00분', id: 'drive', placeName: '해안도로 드라이브', @@ -260,7 +260,7 @@ export const playlistCurationById: Record = { }, 'city-night': { accentColor: '#1F2937', - coverImageUrl: getMockImageUrl('seoul'), + coverImageUrl: getPublicImageUrl('seoul'), durationText: '31:00분', id: 'city-night', placeName: '도시 야경 산책', @@ -271,7 +271,7 @@ export const playlistCurationById: Record = { }, 'cafe-indie': { accentColor: '#3F2C6B', - coverImageUrl: getMockImageUrl('jeonju'), + coverImageUrl: getPublicImageUrl('jeonju'), durationText: '29:00분', id: 'cafe-indie', placeName: '창가 자리와 오후 산책', @@ -282,7 +282,7 @@ export const playlistCurationById: Record = { }, 'festival-kpop': { accentColor: '#9A3E62', - coverImageUrl: getMockImageUrl('daegu'), + coverImageUrl: getPublicImageUrl('daegu'), durationText: '33:00분', id: 'festival-kpop', placeName: '페스티벌 광장', @@ -292,8 +292,8 @@ export const playlistCurationById: Record = { tracks: festivalKpopTracks, }, 'geoje-ocean': { - backgroundImageUrl: getMockImageUrl('geoje'), - coverImageUrl: getMockImageUrl('geoje'), + backgroundImageUrl: getPublicImageUrl('geoje'), + coverImageUrl: getPublicImageUrl('geoje'), durationText: '32:00분', id: 'geoje-ocean', placeName: '바람의 언덕과 해안도로', @@ -304,8 +304,8 @@ export const playlistCurationById: Record = { }, 'busan-ocean': { ...playlistDetail, - backgroundImageUrl: getMockImageUrl('busan'), - coverImageUrl: getMockImageUrl('busan'), + backgroundImageUrl: getPublicImageUrl('busan'), + coverImageUrl: getPublicImageUrl('busan'), durationText: '40:00분', id: 'busan-ocean', placeName: '광안리 해변', diff --git a/src/mocks/recapMocks.ts b/src/mocks/recapMocks.ts index 1d7f585..d1db8e2 100644 --- a/src/mocks/recapMocks.ts +++ b/src/mocks/recapMocks.ts @@ -1,6 +1,6 @@ import { RecapItem, RecapShare } from '@/types/domain'; -import { getMockImageUrl } from './imageAssets'; +import { getPublicImageUrl } from '@/utils/publicImageAssets'; export const recapItems: RecapItem[] = [ { @@ -20,14 +20,14 @@ export const recapItems: RecapItem[] = [ export const recapShare: RecapShare = { artistName: 'JENNIE', - backgroundImageUrl: getMockImageUrl('seoul'), - discImageUrl: getMockImageUrl('seoul'), + backgroundImageUrl: getPublicImageUrl('seoul'), + discImageUrl: getPublicImageUrl('seoul'), id: 'seoul-night', moments: [ { artistName: 'JENNIE', id: 'log-1', - imageUrl: getMockImageUrl('busan'), + imageUrl: getPublicImageUrl('busan'), location: { lat: 35.1532, lng: 129.1187 }, placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', @@ -36,7 +36,7 @@ export const recapShare: RecapShare = { { artistName: '아이유', id: 'log-2', - imageUrl: getMockImageUrl('seoul'), + imageUrl: getPublicImageUrl('seoul'), location: { lat: 37.5294, lng: 126.9348 }, placeName: '한강', recordedAt: '2026-05-25T00:10:00.000Z', @@ -45,7 +45,7 @@ export const recapShare: RecapShare = { { artistName: '10cm', id: 'log-3', - imageUrl: getMockImageUrl('busan'), + imageUrl: getPublicImageUrl('busan'), location: { lat: 35.1796, lng: 129.0756 }, placeName: '부산', recordedAt: '2026-05-25T00:20:00.000Z', @@ -61,14 +61,14 @@ export const recapShare: RecapShare = { export const recapShareById: Record = { 'log-1': { artistName: 'JENNIE', - backgroundImageUrl: getMockImageUrl('busan'), - discImageUrl: getMockImageUrl('busan'), + backgroundImageUrl: getPublicImageUrl('busan'), + discImageUrl: getPublicImageUrl('busan'), id: 'log-1', moments: [ { artistName: 'JENNIE', id: 'log-1', - imageUrl: getMockImageUrl('busan'), + imageUrl: getPublicImageUrl('busan'), location: { lat: 35.1532, lng: 129.1187 }, placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', @@ -82,14 +82,14 @@ export const recapShareById: Record = { }, 'log-2': { artistName: '아이유', - backgroundImageUrl: getMockImageUrl('seoul'), - discImageUrl: getMockImageUrl('seoul'), + backgroundImageUrl: getPublicImageUrl('seoul'), + discImageUrl: getPublicImageUrl('seoul'), id: 'log-2', moments: [ { artistName: '아이유', id: 'log-2', - imageUrl: getMockImageUrl('seoul'), + imageUrl: getPublicImageUrl('seoul'), location: { lat: 37.5294, lng: 126.9348 }, placeName: '한강', recordedAt: '2026-05-25T00:10:00.000Z', @@ -103,14 +103,14 @@ export const recapShareById: Record = { }, 'log-3': { artistName: '10cm', - backgroundImageUrl: getMockImageUrl('busan'), - discImageUrl: getMockImageUrl('busan'), + backgroundImageUrl: getPublicImageUrl('busan'), + discImageUrl: getPublicImageUrl('busan'), id: 'log-3', moments: [ { artistName: '10cm', id: 'log-3', - imageUrl: getMockImageUrl('busan'), + imageUrl: getPublicImageUrl('busan'), location: { lat: 35.1796, lng: 129.0756 }, placeName: '부산', recordedAt: '2026-05-25T00:20:00.000Z', diff --git a/src/mocks/tourMocks.ts b/src/mocks/tourMocks.ts index 5b8b3ff..d09fc1c 100644 --- a/src/mocks/tourMocks.ts +++ b/src/mocks/tourMocks.ts @@ -1,6 +1,6 @@ import { GeoPoint, PlaceContext } from '@/types/domain'; -import { getMockImageUrl } from './imageAssets'; +import { getPublicImageUrl } from '@/utils/publicImageAssets'; const seoulPlaces: PlaceContext[] = [ { @@ -9,7 +9,7 @@ const seoulPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 620, id: 'seed-namsan', - imageUrl: getMockImageUrl('seoul'), + imageUrl: getPublicImageUrl('seoul'), location: { lat: 37.5512, lng: 126.9882 }, overview: '서울의 야경과 도심 산책을 함께 즐길 수 있는 대표 관광지입니다.', source: 'seed', @@ -21,7 +21,7 @@ const seoulPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 940, id: 'seed-gwanghwamun', - imageUrl: getMockImageUrl('seoul'), + imageUrl: getPublicImageUrl('seoul'), location: { lat: 37.5759, lng: 126.9768 }, overview: '도시 산책과 역사 관광 맥락을 함께 제공하는 서울 중심 관광지입니다.', source: 'seed', @@ -36,7 +36,7 @@ const busanPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 360, id: 'seed-gwangalli', - imageUrl: getMockImageUrl('busan'), + imageUrl: getPublicImageUrl('busan'), location: { lat: 35.1532, lng: 129.1186 }, overview: '바다 산책과 야경을 함께 즐길 수 있는 부산 대표 해변입니다.', source: 'seed', @@ -48,7 +48,7 @@ const busanPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 1280, id: 'seed-haeundae', - imageUrl: getMockImageUrl('busan'), + imageUrl: getPublicImageUrl('busan'), location: { lat: 35.1587, lng: 129.1604 }, overview: '해변과 드라이브 맥락에 어울리는 부산 대표 관광지입니다.', source: 'seed', diff --git a/src/test/__tests__/mockImageAssets.test.ts b/src/test/__tests__/publicImageAssets.test.ts similarity index 62% rename from src/test/__tests__/mockImageAssets.test.ts rename to src/test/__tests__/publicImageAssets.test.ts index 447d796..9ac303d 100644 --- a/src/test/__tests__/mockImageAssets.test.ts +++ b/src/test/__tests__/publicImageAssets.test.ts @@ -1,19 +1,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getMockImageUrl } from '@/mocks/imageAssets'; +import { getPublicImageUrl } from '@/utils/publicImageAssets'; -describe('getMockImageUrl', () => { +describe('getPublicImageUrl', () => { afterEach(() => { vi.unstubAllEnvs(); }); - it('uses the API origin for public regional assets', () => { + it('uses the configured API origin without inheriting its path', () => { vi.stubEnv( 'EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://soundlog.example.com/api/soundlog', ); - expect(getMockImageUrl('busan')).toBe( + expect(getPublicImageUrl('busan')).toBe( 'https://soundlog.example.com/assets/playlists/busan.webp', ); }); @@ -21,6 +21,6 @@ describe('getMockImageUrl', () => { it('lets the UI render a semantic fallback when no API origin exists', () => { vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', ''); - expect(getMockImageUrl('seoul')).toBeUndefined(); + expect(getPublicImageUrl('seoul')).toBeUndefined(); }); }); diff --git a/src/mocks/imageAssets.ts b/src/utils/publicImageAssets.ts similarity index 79% rename from src/mocks/imageAssets.ts rename to src/utils/publicImageAssets.ts index d336069..64113c8 100644 --- a/src/mocks/imageAssets.ts +++ b/src/utils/publicImageAssets.ts @@ -1,6 +1,6 @@ import { getApiBaseUrl } from '@/api/client'; -export type MockImageRegion = +export type PublicImageRegion = | 'busan' | 'chuncheon' | 'daegu' @@ -10,7 +10,7 @@ export type MockImageRegion = | 'jeonju' | 'seoul'; -export function getMockImageUrl(region: MockImageRegion) { +export function getPublicImageUrl(region: PublicImageRegion) { const apiBaseUrl = getApiBaseUrl(); if (!apiBaseUrl) {