From 842d74b975f72e49a4a9e073f6e3743b79ecb866 Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 11 Aug 2026 16:18:53 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9D=8C=EC=95=85=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=EA=B3=BC=20=EB=A6=AC=EC=BA=A1=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 + app/(tabs)/music.tsx | 48 ++--- app/(tabs)/my.tsx | 3 + src/components/home/CurrentSoundtrackCard.tsx | 104 ++++++--- src/components/home/LocationContextCard.tsx | 37 +--- .../home/ManualPlacePickerModal.tsx | 199 ------------------ .../moment-capture/MomentCaptureScreen.tsx | 6 +- .../moment-capture/MomentPhotoCanvas.tsx | 41 +++- .../moment-capture/MomentReviewPanel.tsx | 20 +- src/components/my/RecapLogGuide.tsx | 69 ++++++ src/components/recap/RecapListScreen.tsx | 30 ++- 11 files changed, 261 insertions(+), 304 deletions(-) delete mode 100644 src/components/home/ManualPlacePickerModal.tsx create mode 100644 src/components/my/RecapLogGuide.tsx diff --git a/AGENTS.md b/AGENTS.md index f12e76c..447bee9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,14 @@ - When a feature depends on native capabilities such as camera, location, media library, sharing, secure storage, or app permissions, prioritize iOS/Android behavior and native Expo APIs. - Do not block mobile feature work just because the same flow cannot fully work on web. Provide a minimal web fallback only when it is needed for local development, type checking, preview safety, or export stability. +## Mandatory Simulator Testing + +- Never use Expo web, a deployed web app, or a browser to test or validate Soundlog product behavior, UI, server integration, permissions, or regressions. +- Run every product test and visual verification in the iOS Simulator. A browser result must never be treated as evidence that the app works correctly. +- After a Soundlog app change, launch the app in the iOS Simulator and verify the affected flow there before reporting completion. +- 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. + ## Recap And Log Domain - Before changing Recap, Log, camera capture, travel mode, route tracking, map pins, visibility, or related API behavior, read `docs/product/RECAP_LOG_DOMAIN_MODEL.md`. diff --git a/app/(tabs)/music.tsx b/app/(tabs)/music.tsx index 597ebb6..67224f6 100644 --- a/app/(tabs)/music.tsx +++ b/app/(tabs)/music.tsx @@ -27,7 +27,6 @@ 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 { ManualPlacePickerModal } from "@/components/home/ManualPlacePickerModal"; import { MoodRecommendationSection, isMoodRecommendationFilter, @@ -142,7 +141,6 @@ function HomeContent() { const insets = useSafeAreaInsets(); const authStatus = useAuthStore((state) => state.status); const [actionMessage, setActionMessage] = useState(); - const [isPlacePickerVisible, setIsPlacePickerVisible] = useState(false); const [isSoundtrackSheetVisible, setIsSoundtrackSheetVisible] = useState(false); const [selectedMusicPlaylistId, setSelectedMusicPlaylistId] = @@ -306,18 +304,13 @@ function HomeContent() { recommendedPlaylist ? toFeaturedPlaylist(recommendedPlaylist) : undefined, [recommendedPlaylist], ); - const displayedFeaturedPlaylists = useMemo(() => { - if (!currentSoundtrackPlaylist) { - return featuredPlaylistsQuery.data; - } - - return [ - currentSoundtrackPlaylist, - ...(featuredPlaylistsQuery.data ?? []).filter( - (playlist) => playlist.id !== currentSoundtrackPlaylist.id, + const displayedFeaturedPlaylists = useMemo( + () => + featuredPlaylistsQuery.data?.filter( + (playlist) => playlist.id !== recommendedPlaylist?.id, ), - ]; - }, [currentSoundtrackPlaylist, featuredPlaylistsQuery.data]); + [featuredPlaylistsQuery.data, recommendedPlaylist?.id], + ); const currentSoundtrackSummary = useMemo( () => recommendedPlaylist @@ -420,6 +413,12 @@ function HomeContent() { shouldReverseGeocode, ]); + useEffect(() => { + if (!currentLocation && currentPlace) { + setPlace(undefined); + } + }, [currentLocation, currentPlace, setPlace]); + useEffect(() => { if (!recommendedPlaylist) { return; @@ -587,17 +586,6 @@ function HomeContent() { setLocationStatus, setPlace, ]); - const handleSelectManualPlace = useCallback( - (place: PlaceContext) => { - clearLocation(); - setPlace(place); - setIsPlacePickerVisible(false); - setActionMessage( - `${place.title} 기준으로 오늘의 사운드트랙을 준비할게요.`, - ); - }, - [clearLocation, setPlace], - ); const handleSetCurrentLocation = useCallback(async () => { if (!profile.locationRecommendationEnabled) { const didEnable = await handleEnableLocationRecommendation(); @@ -971,7 +959,6 @@ function HomeContent() { location={currentLocation} onEnable={handleSetCurrentLocation} onRefresh={handleRefreshLocation} - onSelectPlace={() => setIsPlacePickerVisible(true)} place={currentPlace} placeCount={nearbyPlacesQuery.data?.length ?? 0} placeInfoMessage={placeInfoMessage} @@ -1038,6 +1025,12 @@ function HomeContent() { {actionMessage} ) : null} + + {currentPlace?.attribution ? ( + + {currentPlace.attribution} + + ) : null} - setIsPlacePickerVisible(false)} - onSelect={handleSelectManualPlace} - visible={isPlacePickerVisible} - /> {currentTrack ? : null} ); diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index 5288f94..6950158 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -8,6 +8,7 @@ import { AppText } from '@/components/AppText'; import { AuthAccountCard } from '@/components/my/AuthAccountCard'; import { MySettingsRow } from '@/components/my/MySettingsRow'; import { PermissionSettingsCard } from '@/components/my/PermissionSettingsCard'; +import { RecapLogGuide } from '@/components/my/RecapLogGuide'; import { PageHeader } from '@/components/PageHeader'; import { Screen } from '@/components/Screen'; import { SectionTitle } from '@/components/SectionTitle'; @@ -126,6 +127,8 @@ export default function MyScreen() { + + + - {sectionStatus} - - ) : undefined + + {sectionStatus ? ( + + {sectionStatus} + + ) : null} + + } title="오늘의 사운드트랙" /> - - - + style={({ pressed }) => ({ + opacity: isLoading || isOpeningPlaylist ? 0.52 : pressed ? 0.72 : 1, + })} + > + + + + + + + + + + {isOpeningPlaylist ? '여는 중' : trackMeta} + + + + + + + {playlistTitle} + + + {isError ? '추천을 다시 받아볼 수 있어요.' : playlistDescription} + + + + + {placeTitle} · {placeCaption} + + + {moodLabel} + + + + ); } diff --git a/src/components/home/LocationContextCard.tsx b/src/components/home/LocationContextCard.tsx index 23a77da..5373655 100644 --- a/src/components/home/LocationContextCard.tsx +++ b/src/components/home/LocationContextCard.tsx @@ -16,7 +16,6 @@ type LocationContextCardProps = { onDismiss?: () => void; onEnable: () => void; onRefresh: () => void; - onSelectPlace?: () => void; place?: PlaceContext; placeCount?: number; placeInfoMessage?: string; @@ -60,7 +59,6 @@ export function LocationContextCard({ onDismiss, onEnable, onRefresh, - onSelectPlace, place, placeCount = 0, placeInfoMessage, @@ -92,8 +90,6 @@ export function LocationContextCard({ ? '주변 관광지를 확인 중이에요' : placeCount > 0 ? `주변 장소 ${placeCount}곳 반영` - : place && !location - ? '직접 선택한 장소로 추천 중' : location ? updatedAt ? `${formatRecapRecordedAt(updatedAt)} 갱신` @@ -105,14 +101,14 @@ export function LocationContextCard({ - - + + + ) : undefined } title="장소 기반 추천" @@ -128,23 +124,6 @@ export function LocationContextCard({ onPress={enabled ? onRefresh : onEnable} rightText={isLoading ? '확인 중' : buttonLabel} /> - {place?.attribution ? ( - - ) : null} - {onSelectPlace ? ( - - ) : null} ); } diff --git a/src/components/home/ManualPlacePickerModal.tsx b/src/components/home/ManualPlacePickerModal.tsx deleted file mode 100644 index 3277b94..0000000 --- a/src/components/home/ManualPlacePickerModal.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { Feather } from '@expo/vector-icons'; -import { useEffect, useState } from 'react'; -import { - ActivityIndicator, - KeyboardAvoidingView, - Modal, - Platform, - Pressable, - ScrollView, - TextInput, - View, -} from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { usePlaceSearchQuery } from '@/api/tourQueries'; -import { AppText } from '@/components/AppText'; -import type { PlaceContext } from '@/types/domain'; - -const suggestedQueries = ['광안리', '서울', '카페', '야경']; -const SEARCH_DEBOUNCE_MS = 300; - -type ManualPlacePickerModalProps = { - onClose: () => void; - onSelect: (place: PlaceContext) => void; - visible: boolean; -}; - -export function ManualPlacePickerModal({ - onClose, - onSelect, - visible, -}: ManualPlacePickerModalProps) { - const insets = useSafeAreaInsets(); - const [query, setQuery] = useState(''); - const [debouncedQuery, setDebouncedQuery] = useState(''); - const placeSearchQuery = usePlaceSearchQuery({ - enabled: visible, - query: debouncedQuery, - }); - - useEffect(() => { - const timerId = setTimeout(() => { - setDebouncedQuery(query.trim()); - }, SEARCH_DEBOUNCE_MS); - - return () => clearTimeout(timerId); - }, [query]); - - useEffect(() => { - if (!visible) { - setQuery(''); - setDebouncedQuery(''); - } - }, [visible]); - - const results = placeSearchQuery.data ?? []; - const isWaitingForDebounce = query.trim() !== debouncedQuery; - const isLoading = isWaitingForDebounce || placeSearchQuery.isFetching; - - return ( - - - - - - 장소 직접 선택 - - 위치 권한 없이도 선택한 장소를 기준으로 음악을 추천해요. - - - - - - - - - - - {query ? ( - setQuery('')} - > - - - ) : null} - - - {!query ? ( - - 빠른 검색 - - {suggestedQueries.map((suggestion) => ( - setQuery(suggestion)} - > - {suggestion} - - ))} - - - ) : null} - - - {isLoading ? ( - - - 장소를 찾고 있어요 - - ) : placeSearchQuery.isError ? ( - void placeSearchQuery.refetch()} - > - - 장소를 불러오지 못했어요 - - 눌러서 다시 시도하세요. - - ) : debouncedQuery && results.length === 0 ? ( - - - - 검색된 장소가 없어요 - - - 더 넓은 지역명이나 다른 키워드로 검색해보세요. - - - ) : ( - - {results.map((place) => { - const canSelect = Boolean(place.location); - - return ( - onSelect(place)} - style={{ opacity: canSelect ? 1 : 0.45 }} - > - - - - - - {place.title} - - - {[place.category, place.address].filter(Boolean).join(' · ') || - (canSelect ? '장소 정보' : '추천 좌표 없음')} - - - - - ); - })} - - )} - - - - - ); -} diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx index 7b30071..1c1cbac 100644 --- a/src/components/moment-capture/MomentCaptureScreen.tsx +++ b/src/components/moment-capture/MomentCaptureScreen.tsx @@ -329,7 +329,11 @@ export function MomentCaptureScreen() { queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }), ]); - router.replace(resolveReturnPath(returnTo) as never); + if (router.canGoBack()) { + router.back(); + } else { + router.replace(resolveReturnPath(returnTo) as never); + } } catch { setErrorMessage("이 리캡을 저장하지 못했어요. 다시 시도해주세요."); } finally { diff --git a/src/components/moment-capture/MomentPhotoCanvas.tsx b/src/components/moment-capture/MomentPhotoCanvas.tsx index 51f387a..dbf4a89 100644 --- a/src/components/moment-capture/MomentPhotoCanvas.tsx +++ b/src/components/moment-capture/MomentPhotoCanvas.tsx @@ -33,7 +33,7 @@ type NativeMapsModule = typeof import('react-native-maps'); type StickerTheme = 'glass' | 'lime' | 'mono'; type TimestampStickerTemplate = 'card' | 'stamp' | 'type'; type MusicStickerTemplate = 'player' | 'label' | 'vinyl'; -type StickerKind = 'music'; +type StickerKind = 'music' | 'timestamp'; type FeatherIconName = ComponentProps['name']; export type MomentPhotoCanvasHandle = { @@ -237,6 +237,7 @@ export const MomentPhotoCanvas = forwardRef< const musicStickerSize = musicStickerSizes[musicTemplate]; const timestampThemeStyle = getStickerThemeStyle(timestampTheme); const musicThemeStyle = getStickerThemeStyle(musicTheme); + const isDraggingTimestamp = activeSticker === 'timestamp'; const isDraggingMusic = activeSticker === 'music'; const isMapTemplate = selectedTemplate === 'map'; @@ -276,6 +277,25 @@ export const MomentPhotoCanvas = forwardRef< [canvasSize, handleDragEnd, handleDragStart, musicPan, musicStickerSize], ); + const timestampPanResponder = useMemo( + () => + createStickerPanResponder({ + canvasSize, + onDragEnd: handleDragEnd, + onDragStart: () => handleDragStart('timestamp'), + pan: timestampPan, + positionRef: timestampPositionRef, + size: timestampStickerSize, + }), + [ + canvasSize, + handleDragEnd, + handleDragStart, + timestampPan, + timestampStickerSize, + ], + ); + useImperativeHandle( ref, () => ({ @@ -393,15 +413,22 @@ export const MomentPhotoCanvas = forwardRef< )} - 촬영 시각으로 고정되며 이동할 수 없어요. + 촬영 시각을 표시하며 드래그해 옮길 수 있어요. diff --git a/src/components/moment-capture/MomentReviewPanel.tsx b/src/components/moment-capture/MomentReviewPanel.tsx index e361592..3535634 100644 --- a/src/components/moment-capture/MomentReviewPanel.tsx +++ b/src/components/moment-capture/MomentReviewPanel.tsx @@ -156,6 +156,16 @@ export const MomentReviewPanel = forwardRef< 사진과 장소, 음악을 확인하고 저장하세요. + + + + + + + {photoUri ? ( )} - - - - - - - diff --git a/src/components/my/RecapLogGuide.tsx b/src/components/my/RecapLogGuide.tsx new file mode 100644 index 0000000..b202935 --- /dev/null +++ b/src/components/my/RecapLogGuide.tsx @@ -0,0 +1,69 @@ +import { Feather } from '@expo/vector-icons'; +import type { ComponentProps } from 'react'; +import { View } from 'react-native'; + +import { AppText } from '@/components/AppText'; +import { SectionTitle } from '@/components/SectionTitle'; + +type FeatherIconName = ComponentProps['name']; + +type GuideRowProps = { + description: string; + icon: FeatherIconName; + label: string; + title: string; +}; + +function GuideRow({ description, icon, label, title }: GuideRowProps) { + return ( + + + + + + + {label} + + + {title} + + + {description} + + + + ); +} + +export function RecapLogGuide() { + return ( + + + + + + + + + + 리캡은 한 장면이고 로그는 한 편의 여행이에요! + + + + + ); +} diff --git a/src/components/recap/RecapListScreen.tsx b/src/components/recap/RecapListScreen.tsx index 8c0b3c0..251132a 100644 --- a/src/components/recap/RecapListScreen.tsx +++ b/src/components/recap/RecapListScreen.tsx @@ -26,6 +26,7 @@ import { RecapEmptyState } from "@/components/recap/RecapEmptyState"; import { Screen } from "@/components/Screen"; import { getTabBarHeight } from "@/constants/layout"; import { useAuthenticatedImageSource } from "@/hooks/useAuthenticatedImageSource"; +import { useTravelSessionStore } from "@/store/travelSessionStore"; import type { RecapItem, RecapVisibility } from "@/types/domain"; type LogFeedTabId = "others" | "mine"; @@ -318,6 +319,9 @@ export function RecapListScreen() { const params = useLocalSearchParams<{ view?: string | string[] }>(); const initialView = Array.isArray(params.view) ? params.view[0] : params.view; const queryClient = useQueryClient(); + const travelSessionStatus = useTravelSessionStore( + (state) => state.session.status, + ); const [selectedTab, setSelectedTab] = useState( initialView === "mine" || initialView === "all" ? "mine" : "others", ); @@ -365,9 +369,14 @@ export function RecapListScreen() { [serverMineEntries], ); const hasAnyLog = otherEntries.length > 0 || myEntries.length > 0; + const travelButtonLabel = + travelSessionStatus === "active" ? "여행 계속하기" : "여행 시작하기"; const handleOpenEntry = useCallback((entry: LogGridEntry) => { router.push(`/recap-share/${entry.shareId}`); }, []); + const handleOpenTravel = useCallback(() => { + router.navigate("/" as never); + }, []); const handleSelectTab = useCallback( (tab: LogFeedTabId) => { @@ -470,7 +479,26 @@ export function RecapListScreen() { return ( - + + + + {travelButtonLabel} + + + } + title="로그" + /> From effd6ca52ecbc89ed22e2def3195059e3ebfc0c8 Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 11 Aug 2026 16:30:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?build:=20TestFlight=20=EC=A0=9C=EC=B6=9C=20?= =?UTF-8?q?=EC=95=B1=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eas.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/eas.json b/eas.json index 96dcae2..5adb898 100644 --- a/eas.json +++ b/eas.json @@ -34,6 +34,10 @@ } }, "submit": { - "production": {} + "production": { + "ios": { + "ascAppId": "6797038341" + } + } } }