diff --git a/.codex/skills/soundlog-codex-workflow/SKILL.md b/.codex/skills/soundlog-codex-workflow/SKILL.md index 0d4a00f..6ed5353 100644 --- a/.codex/skills/soundlog-codex-workflow/SKILL.md +++ b/.codex/skills/soundlog-codex-workflow/SKILL.md @@ -9,6 +9,7 @@ description: Soundlog React Native/Expo 앱에서 기능을 설계하거나 구 Before changing code, load only the docs relevant to the task: +- Recap/Log/TravelSession canonical domain: `docs/product/RECAP_LOG_DOMAIN_MODEL.md` - Product context: `docs/product/SOUNDLOG_APP_PLANNING.md` - RN frontend principles: `docs/frontend/RN_FRONTEND_PLANNING_POINTS.md` - Home screen: `docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md` @@ -24,7 +25,7 @@ Before changing code, load only the docs relevant to the task: 2. Define the feature contract: user goal, affected screens, state, API/mock needs, permissions, loading/empty/error/offline states. 3. Ask the user only about product-risk edge cases, especially privacy, location tracking, music-platform policy, persistence, or sharing behavior. 4. Create a concrete implementation plan with files to modify and verification steps. -5. Review the plan before editing. If Claude review is available, use it; otherwise perform a Codex self-review and revise the plan. +5. Review the plan before editing. Perform a Codex self-review and revise the plan when it exposes a product, data, privacy, or verification gap. 6. Implement with scoped changes. 7. Verify with `npm run typecheck`; for UI work also check web or simulator when practical. 8. Review the diff, fix issues, then summarize. Commit only when the user asks. @@ -42,6 +43,8 @@ For natural-language UI feedback, use the narrower `soundlog-ui-feedback-loop` w - Avoid top-level imports of native-only modules when web can load the file. Lazy import native modules or guard with `Platform.OS`. - Prefer query-backed data for server/mock state and local stores only for user selections or lightweight persisted UI state. - Recap UI should preserve visual share quality: stable capture frame, clear CTA states, graceful fallback when sharing or media permissions fail. +- A product Recap is one camera-flow capture. A product Log is the set of Recaps sharing one travel `sessionId`; standalone Recaps must not appear as Logs. +- A Log detail map must render only that Log's Recap pins and session route. ## Verification Checklist diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f12e76c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Soundlog Agent Rules + +- Do not merge GitHub pull requests unless the user's latest message explicitly asks to merge that exact PR. +- PR creation, CI verification, reviewer assignment, issue closing, or "finish the task" does not imply merge approval. +- If a merge request is ambiguous, ask for confirmation before using any merge UI, GitHub API, or commands such as `gh pr merge`. + +## Platform Direction + +- Soundlog is a mobile app product. We do not intend to ship or maintain a public web deployment as a product surface. +- Treat Expo web as a development or CI/export compatibility target only. Do not design product behavior around web deployment unless the user explicitly asks for web support. +- 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. + +## 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`. +- Treat that document as the canonical product contract when older planning docs or legacy code names conflict with it. +- Product `Recap` means one capture saved from the camera flow. +- Product `Log` means one or more Recaps created in the same travel-mode session. A standalone Recap created outside travel mode is not a one-item Log. +- A travel Log is identified by `sessionId`, even when it contains only one Recap. Never merge Logs by date, place, distance, or track. +- A Log detail map may show only its member Recap pins and its own session route. Do not mix nearby/public pins or other Logs into that map. +- `MomentLog` and the server `Recap` model are legacy technical names. Follow the product-to-code mapping in the canonical domain document instead of exposing those terms to users. diff --git a/README.md b/README.md index a88f056..8947e02 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,16 @@ npm run dev:client ```bash npm run typecheck +npm run check:recap-clustering +npm run check:server-contract npm run check npm run web:clear ``` +`check:recap-clustering`은 전체 리캡과 내 리캡 지도의 좌표 묶음, 줌별 반경, 확대 시 개별 핀 분리를 검증합니다. `check`에도 포함되어 있습니다. + +`check:server-contract`은 형제 경로의 `SoundLogServer/openapi/soundlog-api.yaml`과 프론트 `src/api`의 HTTP 메서드·경로를 비교합니다. 서버 저장소 위치가 다르면 `SOUNDLOG_SERVER_ROOT`를 지정합니다. + 레거시 mock API 상태를 확인하던 지연/실패 환경변수는 `src/mock-server` 참고용으로만 남아 있습니다. 현재 앱 API facade는 서버 API만 호출합니다. ```bash @@ -79,6 +85,7 @@ iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/Test ## 문서 - [문서 인덱스](docs/README.md) +- [리캡·로그 도메인 기준](docs/product/RECAP_LOG_DOMAIN_MODEL.md) - [서비스 기획서](docs/product/SOUNDLOG_APP_PLANNING.md) - [React Native 프론트 고려사항](docs/frontend/RN_FRONTEND_PLANNING_POINTS.md) - [비개발자용 Codex 개발 가이드](docs/codex/NON_DEVELOPER_CODEX_GUIDE.md) diff --git a/app.config.js b/app.config.js index de7b244..ff0e23c 100644 --- a/app.config.js +++ b/app.config.js @@ -100,6 +100,13 @@ function getApiBaseUrl() { return process.env.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL?.replace(/\/+$/, ''); } +function getGoogleMapsAndroidApiKey() { + return ( + process.env.EXPO_PUBLIC_GOOGLE_MAPS_ANDROID_API_KEY ?? + process.env.GOOGLE_MAPS_ANDROID_API_KEY + ); +} + function isProductionBuildProfile() { return process.env.EAS_BUILD_PROFILE === 'production'; } @@ -166,11 +173,31 @@ function upsertBuildPropertiesPlugin(config, nextAndroidConfig) { ); } +function applyAndroidGoogleMapsApiKey(config) { + const apiKey = getGoogleMapsAndroidApiKey(); + + if (!apiKey) { + return; + } + + config.android = { + ...config.android, + config: { + ...(config.android?.config ?? {}), + googleMaps: { + ...(config.android?.config?.googleMaps ?? {}), + apiKey, + }, + }, + }; +} + module.exports = () => { const nextConfig = JSON.parse(JSON.stringify(baseConfig)); const apiBaseUrl = getApiBaseUrl(); const httpApiHost = getHttpApiHost(apiBaseUrl); + applyAndroidGoogleMapsApiKey(nextConfig); assertProductionConfig(apiBaseUrl); if (!httpApiHost || isProductionBuildProfile()) { diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6366e20..96773dc 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,583 +1,394 @@ -import { Redirect, router } from 'expo-router'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ScrollView, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { - useFeaturedPlaylistsQuery, - useMoodRecommendationsQuery, - useRecentMusicLogsQuery, -} from '@/api/homeQueries'; -import { meApi } from '@/api/meApi'; -import { playlistApi, PlaylistMlMood, PlaylistMlState } from '@/api/playlistApi'; -import { playlistQueryKeys } from '@/api/playlistQueries'; -import { syncRecommendationEvent } from '@/api/recommendationEventApi'; -import { AppText } from '@/components/AppText'; -import { useNearbyPlacesQuery } from '@/api/tourQueries'; -import { MiniPlayer } from '@/components/MiniPlayer'; -import { FeaturedPlaylistSection } from '@/components/home/FeaturedPlaylistSection'; -import { CurrentSoundtrackCard } from '@/components/home/CurrentSoundtrackCard'; -import { - HomeHeader, - HomeNavigationBar, - HomeTopFilterBar, - isHomeTopFilter, -} from '@/components/home/HomeHeader'; -import { LocationContextCard } from '@/components/home/LocationContextCard'; +import { Redirect, router } from "expo-router"; +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { travelSessionApi } from "@/api/travelSessionApi"; +import { recapQueryKeys } from "@/api/recapQueries"; +import { useNearbyPlacesQuery } from "@/api/tourQueries"; +import { AppText } from "@/components/AppText"; +import { MiniPlayer } from "@/components/MiniPlayer"; +import { Screen } from "@/components/Screen"; +import { EndTravelConfirmModal } from "@/components/travel/EndTravelConfirmModal"; +import { TravelModeBottomSheet } from "@/components/travel/TravelModeBottomSheet"; +import { RecapMapSection } from "@/components/travel/recap-map"; import { - MoodRecommendationSection, - isMoodRecommendationFilter, -} from '@/components/home/MoodRecommendationSection'; -import { MusicLogSection } from '@/components/home/MusicLogSection'; -import { TravelSessionCard } from '@/components/home/TravelSessionCard'; -import { Screen } from '@/components/Screen'; -import { getHomeContentBottomPadding } from '@/constants/layout'; -import { useHomeFilterStore } from '@/store/homeFilterStore'; + getMiniPlayerBottom, + getTabBarHeight, + layout, +} from "@/constants/layout"; import { - momentLogToMusicLogItem, - useMomentLogStore, -} from '@/store/momentLogStore'; -import { usePlayerStore } from '@/store/playerStore'; -import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import { - createFeaturedPlaylistsCacheKey, - createMoodRecommendationsCacheKey, - useRecommendationCacheStore, -} from '@/store/recommendationCacheStore'; -import { queryClient } from '@/providers/queryClient'; -import { useAuthStore } from '@/store/authStore'; -import { useTravelSessionStore } from '@/store/travelSessionStore'; -import { useUserProfileStore } from '@/store/userProfileStore'; -import { FeaturedPlaylist, MoodRecommendation, MusicLogItem, TravelMode } from '@/types/domain'; -import { requestForegroundLocationWithStatus } from '@/utils/location'; -import { getMoodTagsFromFilter } from '@/utils/moodTags'; -import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; - -const moodFilterToMlMood: Record = { - 감성적인: '감성적인', - 로컬한: '설레는', - 설레는: '설레는', - 시원한: '시원한', - 신나는: '신나는', - 잔잔한: '잔잔한', - 청량한: '시원한', - 활기찬: '신나는', -}; - -const travelModeToMlState: Partial> = { - cafe: '카페', - drive: '드라이브', - night: '야경', - ocean: '바다', - walk: '산책', -}; - -const travelModeDisplayLabel: Record = { - cafe: '카페', - drive: '드라이브', - festival: '축제', - night: '야경', - ocean: '바다', - walk: '산책', -}; - -const travelStyleDisplayLabel: Record = { - '바다 보기': '바다', - '야경 감상': '야경', - '카페 투어': '카페', -}; - -function resolvePlaylistMood(filter: string, preferredMoods: string[]): PlaylistMlMood { - if (filter !== '전체' && moodFilterToMlMood[filter]) { - return moodFilterToMlMood[filter]; - } - - return preferredMoods.map((mood) => moodFilterToMlMood[mood]).find(Boolean) ?? '잔잔한'; -} - -function resolvePlaylistState(mode?: TravelMode): PlaylistMlState { - return (mode ? travelModeToMlState[mode] : undefined) ?? '산책'; -} - -function resolveCurrentTravelLabel(mode: TravelMode | undefined, travelStyles: string[]) { - if (mode) { - return travelModeDisplayLabel[mode]; - } - - const firstTravelStyle = travelStyles[0]; - - return firstTravelStyle ? travelStyleDisplayLabel[firstTravelStyle] ?? firstTravelStyle : '산책'; -} - -function resolveCurrentMoodLabel(filter: string, preferredMoods: string[]) { - if (filter !== '전체') { - return filter; - } - - return preferredMoods[0] ?? '잔잔한'; -} - -function HomeContent() { + createRoutePoint, + useTravelRouteTracking, +} from "@/hooks/useTravelRouteTracking"; +import { useAuthStore } from "@/store/authStore"; +import { useMomentLogStore } from "@/store/momentLogStore"; +import { usePlayerStore } from "@/store/playerStore"; +import { queryClient } from "@/providers/queryClient"; +import { useTravelSessionStore } from "@/store/travelSessionStore"; +import { useTravelLogSyncStore } from "@/store/travelLogSyncStore"; +import { useUserProfileStore } from "@/store/userProfileStore"; +import type { TravelMode } from "@/types/domain"; +import { requestForegroundLocationWithStatus } from "@/utils/location"; +import { createSessionRecapId } from "@/utils/recapMappers"; +import { flushPendingMomentActions } from "@/utils/momentLogSync"; +import { flushPendingTravelLogFinalizations } from "@/utils/travelLogSync"; + +const NEARBY_TOUR_RADIUS_METERS = 2000; + +export default function MapHomeScreen() { const insets = useSafeAreaInsets(); - const authStatus = useAuthStore((state) => state.status); - const [actionMessage, setActionMessage] = useState(); - const [creatingPlaylistId, setCreatingPlaylistId] = useState(); - const { - selectedMoodFilter, - selectedTopFilter, - setSelectedMoodFilter, - setSelectedTopFilter, - } = useHomeFilterStore(); - const { currentTrack, setTrack } = usePlayerStore(); - const addRecommendationEvent = useRecommendationEventStore( - (state) => state.addEvent, - ); + useTravelRouteTracking(); + const { isHydrated: authHydrated, status } = useAuthStore(); + const { isHydrated, profile } = useUserProfileStore(); + const { currentTrack } = usePlayerStore(); + const [isModeSheetVisible, setIsModeSheetVisible] = useState(false); + const [isStartingTravel, setIsStartingTravel] = useState(false); + const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false); + const [isEndingTravel, setIsEndingTravel] = useState(false); + const [mapMessage, setMapMessage] = useState(); const momentLogs = useMomentLogStore((state) => state.logs); - const { profile, updateProfile } = useUserProfileStore(); const { + clearLocation, currentLocation, currentPlace, + endSession, locationStatus, - locationUpdatedAt, - recommendationMode, selectedMode, session, resetSession, setLocation, setLocationStatus, + setMode, setPlace, setRecommendationMode, + setSessionRecapId, + startSession, } = useTravelSessionStore(); - const featuredPlaylistParams = useMemo( - () => ({ - location: currentLocation, - locationRecommendationEnabled: profile.locationRecommendationEnabled, - place: currentPlace, - recommendationMode, - }), - [ - currentLocation, - currentPlace, - profile.locationRecommendationEnabled, - recommendationMode, - ], - ); - const moodRecommendationParams = useMemo( - () => ({ - currentPlace, - moodFilter: selectedMoodFilter, - preferredGenres: profile.preferredGenres, - preferredMoods: profile.preferredMoods, - recommendationMode, - topFilter: selectedTopFilter, - travelStyles: profile.travelStyles, - }), - [ - currentPlace, - profile.preferredGenres, - profile.preferredMoods, - profile.travelStyles, - recommendationMode, - selectedMoodFilter, - selectedTopFilter, - ], - ); - const featuredCacheKey = useMemo( - () => createFeaturedPlaylistsCacheKey(featuredPlaylistParams), - [featuredPlaylistParams], - ); - const moodCacheKey = useMemo( - () => createMoodRecommendationsCacheKey(moodRecommendationParams), - [moodRecommendationParams], - ); - const featuredFallback = useRecommendationCacheStore((state) => state.featuredFallback); - const moodFallback = useRecommendationCacheStore((state) => state.moodFallback); - const isUsingCachedFeaturedPlaylists = featuredFallback?.key === featuredCacheKey; - const isUsingCachedMoodRecommendations = moodFallback?.key === moodCacheKey; - const isAuthenticated = authStatus === 'authenticated'; - const nearbyPlacesQuery = useNearbyPlacesQuery({ - enabled: isAuthenticated && profile.locationRecommendationEnabled, + enabled: + status === "authenticated" && + isHydrated && + profile.locationRecommendationEnabled, location: currentLocation, - radiusMeters: 2000, + radiusMeters: NEARBY_TOUR_RADIUS_METERS, }); - const featuredPlaylistsQuery = useFeaturedPlaylistsQuery(featuredPlaylistParams, { - enabled: isAuthenticated, - }); - const moodRecommendationsQuery = useMoodRecommendationsQuery(moodRecommendationParams, { - enabled: isAuthenticated, - }); - const recentMusicLogsQuery = useRecentMusicLogsQuery({ enabled: isAuthenticated }); - const musicLogs = [ - ...momentLogs.slice(0, 6).map(momentLogToMusicLogItem), - ...(recentMusicLogsQuery.data ?? []), - ].slice(0, 10); - const placeInfoMessage = - profile.locationRecommendationEnabled && - currentLocation && - !nearbyPlacesQuery.isFetching && - (nearbyPlacesQuery.data?.length ?? 0) === 0 - ? '주변 관광지 결과가 없어도 기본 추천은 계속 사용할 수 있어요.' - : nearbyPlacesQuery.isError - ? '주변 관광지를 불러오지 못했지만 기본 추천은 계속 사용할 수 있어요.' - : undefined; - - useEffect(() => { - if (!isMoodRecommendationFilter(selectedMoodFilter)) { - setSelectedMoodFilter('전체'); - } - }, [selectedMoodFilter, setSelectedMoodFilter]); - - useEffect(() => { - if (!isHomeTopFilter(selectedTopFilter)) { - setSelectedTopFilter('전체'); - } - }, [selectedTopFilter, setSelectedTopFilter]); - - useEffect(() => { - if (!nearbyPlacesQuery.data) { - return; - } - - const nextPlace = nearbyPlacesQuery.data[0]; - - if (nextPlace?.id !== currentPlace?.id) { - setPlace(nextPlace); - } - }, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]); - - const handleSelectRecommendation = async (item: MoodRecommendation) => { - setActionMessage(undefined); - - if (item.playlistId) { - router.push(`/playlist/${item.playlistId}`); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId: item.playlistId, - type: 'playlist_open', - value: item.playlistId, - }), - ); - return; - } + const nearestTourPlace = currentLocation + ? nearbyPlacesQuery.data?.find( + (place) => place.source === "tour-api" && Boolean(place.location), + ) + : undefined; + const cachedTourPlace = + currentPlace?.source === "tour-api" && currentPlace.location + ? currentPlace + : undefined; + const activeCurrentPlace = currentLocation + ? nearestTourPlace + : cachedTourPlace; + const tourPlaceStatus = activeCurrentPlace + ? "ready" + : !currentLocation + ? "unavailable" + : !profile.locationRecommendationEnabled + ? "disabled" + : nearbyPlacesQuery.isFetching + ? "loading" + : nearbyPlacesQuery.isError + ? "error" + : "empty"; + const sessionMomentCount = momentLogs.filter( + (log) => log.sessionId === session.id, + ).length; + + useEffect( + function synchronizeRecommendationMode() { + setRecommendationMode("travel"); + }, + [setRecommendationMode], + ); - setTrack(item.track); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId: item.playlistId, - trackId: item.track.id, - type: 'track_selected', - value: 'home_mood_recommendation', - }), - ); - setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); - }; - const handleSelectFeaturedPlaylist = useCallback( - async (playlist: FeaturedPlaylist) => { - if (creatingPlaylistId) { + useEffect( + function loadInitialLocationAfterLogin() { + if ( + status !== "authenticated" || + !isHydrated || + !profile.completedOnboarding || + !profile.locationRecommendationEnabled || + currentLocation || + locationStatus !== "idle" + ) { return; } - setActionMessage(undefined); - setCreatingPlaylistId(playlist.id); - - try { - const contextualPlaylist = await playlistApi.getRecommendedPlaylist({ - location: currentLocation ?? currentPlace?.location, - mood: resolvePlaylistMood(selectedMoodFilter, profile.preferredMoods), - moodTags: getMoodTagsFromFilter(selectedMoodFilter), - placeId: currentPlace?.id, - preferredGenres: profile.preferredGenres, - preferredMoods: profile.preferredMoods, - state: resolvePlaylistState(selectedMode), - travelMode: selectedMode, - }); - const nextPlaylistId = contextualPlaylist?.id ?? playlist.id; + setLocationStatus("loading"); + + void requestForegroundLocationWithStatus() + .then((result) => { + if (useAuthStore.getState().status !== "authenticated") { + return; + } + + if (result.location) { + setLocation(result.location); + return; + } - if (contextualPlaylist) { - queryClient.setQueryData( - playlistQueryKeys.detail(nextPlaylistId), - contextualPlaylist, + if (result.status === "denied") { + clearLocation(); + } + setLocationStatus( + result.status === "denied" ? "denied" : "unavailable", ); - } - - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext({ - moodFilter: selectedMoodFilter, - source: contextualPlaylist.context?.source, - }), - playlistId: nextPlaylistId, - type: 'playlist_open', - value: nextPlaylistId, - }), - ); - router.push(`/playlist/${nextPlaylistId}`); - } catch { - setActionMessage('맞춤 플레이리스트를 만들지 못했어요. 잠시 후 다시 시도해주세요.'); - } finally { - setCreatingPlaylistId(undefined); - } + }) + .catch(() => { + if (useAuthStore.getState().status === "authenticated") { + setLocationStatus("unavailable"); + } + }); }, [ - addRecommendationEvent, - creatingPlaylistId, + clearLocation, currentLocation, - currentPlace, - profile.preferredGenres, - profile.preferredMoods, - selectedMode, - selectedMoodFilter, + isHydrated, + locationStatus, + profile.completedOnboarding, + profile.locationRecommendationEnabled, + setLocation, + setLocationStatus, + status, ], ); - const handleSelectMusicLog = useCallback((item: MusicLogItem) => { - router.push(`/recap-share/${item.recapShareId ?? item.id}`); - }, []); - const handleSelectTopFilter = useCallback( - (filter: string) => { - if (filter === selectedTopFilter) { - return; - } - setSelectedTopFilter(filter); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext({ topFilter: filter }), - type: 'top_filter_change', - value: filter, - }), - ); - }, - [addRecommendationEvent, selectedTopFilter, setSelectedTopFilter], - ); - const handleSelectMoodFilter = useCallback( - (filter: string) => { - if (filter === selectedMoodFilter) { - return; + useEffect( + function redirectIncompleteOnboarding() { + if (isHydrated && !profile.completedOnboarding) { + router.replace("/onboarding" as never); } - - setSelectedMoodFilter(filter); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext({ moodFilter: filter }), - type: 'mood_filter_change', - value: filter, - }), - ); }, - [addRecommendationEvent, selectedMoodFilter, setSelectedMoodFilter], + [isHydrated, profile.completedOnboarding], ); - const handleSelectRecommendationMode = useCallback( - (mode: typeof recommendationMode) => { - if (mode === recommendationMode) { + + useEffect( + function synchronizeNearestTourPlace() { + if ( + !currentLocation || + !nearbyPlacesQuery.isSuccess || + !nearestTourPlace + ) { return; } - setRecommendationMode(mode); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext({ recommendationMode: mode }), - type: 'recommendation_mode_change', - value: mode, - }), - ); + if (nearestTourPlace.id !== currentPlace?.id) { + setPlace(nearestTourPlace); + } }, - [addRecommendationEvent, recommendationMode, setRecommendationMode], + [ + currentLocation, + currentPlace?.id, + nearestTourPlace, + nearbyPlacesQuery.isSuccess, + setPlace, + ], ); - const handleEnableLocationRecommendation = useCallback(async () => { - const nextProfile = { - companionType: profile.companionType, - locationRecommendationEnabled: true, - preferredGenres: profile.preferredGenres, - preferredMoods: profile.preferredMoods, - travelStyles: profile.travelStyles, - }; - setActionMessage(undefined); + if (!authHydrated || status === "checking") { + return ; + } - try { - await meApi.updateProfile(nextProfile); - updateProfile(nextProfile); - return true; - } catch { - setActionMessage('위치 추천 설정을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.'); - return false; + if (status !== "authenticated") { + return ( + + ); + } + + if (!isHydrated || !profile.completedOnboarding) { + return isHydrated ? : ; + } + + const openModeSheet = () => { + if (session.status === "ended") { + resetSession(); } - }, [profile, updateProfile]); - const handleRefreshLocation = useCallback(async () => { - if (locationStatus === 'loading') { + + setIsModeSheetVisible(true); + }; + const handleSelectMode = (mode: TravelMode) => { + setMode(mode); + }; + const handleStartTravel = async () => { + if (isStartingTravel) { return; } - setLocationStatus('loading'); + const nextMode = selectedMode ?? "walk"; - try { - const result = await requestForegroundLocationWithStatus(); + if (!selectedMode) { + setMode(nextMode); + } - if (result.location) { - setLocation(result.location); - return; - } + setIsStartingTravel(true); + setMapMessage(undefined); - setLocationStatus(result.status === 'denied' ? 'denied' : 'unavailable'); + try { + const startLocation = currentLocation ?? activeCurrentPlace?.location; + const startedAt = new Date().toISOString(); + const initialRoutePoints = startLocation + ? [createRoutePoint(startLocation, new Date(startedAt))] + : undefined; + const serverSession = await travelSessionApi.createTravelSession({ + location: startLocation, + routePoints: initialRoutePoints, + startedAt, + travelMode: nextMode, + }); + + startSession({ + id: serverSession?.id, + routePoints: serverSession?.routePoints ?? initialRoutePoints, + startedAt: serverSession?.startedAt ?? startedAt, + }); } catch { - setLocationStatus('unavailable'); + const startLocation = currentLocation ?? activeCurrentPlace?.location; + const startedAt = new Date().toISOString(); + + startSession({ + routePoints: startLocation + ? [createRoutePoint(startLocation, new Date(startedAt))] + : undefined, + startedAt, + }); + setMapMessage( + "서버 여행 세션 연결에 실패해서 로컬 여행모드로 먼저 시작했어요.", + ); + } finally { + setIsStartingTravel(false); + setIsModeSheetVisible(false); } - }, [locationStatus, setLocation, setLocationStatus]); - const handleSetCurrentLocation = useCallback(async () => { - if (!profile.locationRecommendationEnabled) { - const didEnable = await handleEnableLocationRecommendation(); - - if (!didEnable) { - return; - } + }; + const handleEndTravel = async () => { + if (isEndingTravel || session.status !== "active") { + return; } - void handleRefreshLocation(); - }, [ - handleEnableLocationRecommendation, - handleRefreshLocation, - profile.locationRecommendationEnabled, - ]); + const endingSession = session; + const endedAt = new Date().toISOString(); + const localRecapId = createSessionRecapId(endingSession.id); - return ( - - - - - + setIsEndingTravel(true); + setMapMessage(undefined); - - - router.push('/camera' as never)} - onOpenPlaylist={handleSelectFeaturedPlaylist} - onRetry={() => void featuredPlaylistsQuery.refetch()} - playlist={featuredPlaylistsQuery.data?.[0]} - travelLabel={resolveCurrentTravelLabel(selectedMode, profile.travelStyles)} - /> - - {recommendationMode === 'travel' ? ( - - router.push('/recap')} - onOpenTravel={() => router.push('/(tabs)/travel' as never)} - selectedMode={selectedMode} - startedAt={session.startedAt} - status={session.status} - /> - - ) : null} + try { + await flushPendingMomentActions(); - - - - - void featuredPlaylistsQuery.refetch()} - /> + const latestSessionLogs = useMomentLogStore + .getState() + .logs.filter((log) => log.sessionId === endingSession.id); - - - - - {actionMessage ? ( - - {actionMessage} - - ) : null} + endSession(); + setIsEndConfirmVisible(false); - - - - - {currentTrack ? : null} - - ); -} + if (latestSessionLogs.length === 0) { + setSessionRecapId(undefined); + setMapMessage( + "여행을 종료했어요. 남긴 리캡이 없어 로그는 만들지 않았어요.", + ); + return; + } -export default function HomeScreen() { - const { isHydrated: authHydrated, status } = useAuthStore(); - const { isHydrated, profile } = useUserProfileStore(); + useTravelLogSyncStore.getState().queueFinalization({ + endedAt, + location: currentLocation ?? activeCurrentPlace?.location, + routePoints: endingSession.routePoints, + sessionId: endingSession.id, + templateId: "album", + title: `${latestSessionLogs[0]?.placeName ?? "여행"} 로그`, + }); + const syncResult = await flushPendingTravelLogFinalizations(); + const recapId = + syncResult.createdRecapIds[endingSession.id] ?? localRecapId; + + setSessionRecapId(recapId); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); + + if (recapId === localRecapId) { + setMapMessage( + "서버 동기화가 끝나면 여행 로그가 자동으로 완성돼요. 지금은 기기 기록을 보여드릴게요.", + ); + } - useEffect(() => { - if (isHydrated && !profile.completedOnboarding) { - router.replace('/onboarding' as never); + router.push(`/recap-share/${recapId}`); + } catch { + endSession(); + setSessionRecapId(localRecapId); + setIsEndConfirmVisible(false); + setMapMessage( + "서버 로그 생성에 실패해 기기에 저장된 여행 로그를 먼저 보여드려요.", + ); + router.push(`/recap-share/${localRecapId}`); + } finally { + setIsEndingTravel(false); } - }, [isHydrated, profile.completedOnboarding]); + }; + const mapOverlayBottomInset = currentTrack + ? getMiniPlayerBottom(insets.bottom) + layout.miniPlayerHeight + 14 + : getTabBarHeight(insets.bottom) + 14; + const mapOverlayTopInset = insets.top + 12; - if (!authHydrated || status === 'checking') { - return ; - } + return ( + + + {mapMessage ? ( + + + {mapMessage} + + + ) : null} - if (status !== 'authenticated') { - return ; - } + + router.push({ + params: { returnTo: "map" }, + pathname: "/camera", + } as never) + } + isEndingTravel={isEndingTravel} + onEndTravel={() => setIsEndConfirmVisible(true)} + onOpenRecap={(recapId) => router.push(`/recap-share/${recapId}`)} + onStartTravel={openModeSheet} + overlayBottomInset={mapOverlayBottomInset} + overlayTopInset={mapOverlayTopInset} + sessionStatus={session.status} + tourPlaceStatus={tourPlaceStatus} + variant="page" + /> + - if (!isHydrated || !profile.completedOnboarding) { - return isHydrated ? : ; - } + {currentTrack ? : null} - return ; + setIsModeSheetVisible(false)} + onSelectMode={handleSelectMode} + onStart={() => void handleStartTravel()} + selectedMode={selectedMode} + submitLabel={isStartingTravel ? "시작 중" : "여행 시작"} + visible={isModeSheetVisible} + /> + + setIsEndConfirmVisible(false)} + onConfirm={() => void handleEndTravel()} + visible={isEndConfirmVisible} + /> + + ); } diff --git a/app/(tabs)/music.tsx b/app/(tabs)/music.tsx new file mode 100644 index 0000000..597ebb6 --- /dev/null +++ b/app/(tabs)/music.tsx @@ -0,0 +1,1116 @@ +import { Redirect, router } from "expo-router"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { + useFeaturedPlaylistsQuery, + useMoodRecommendationsQuery, +} from "@/api/homeQueries"; +import { libraryApi } from "@/api/libraryApi"; +import { meApi } from "@/api/meApi"; +import { PlaylistMlMood, PlaylistMlState } from "@/api/playlistApi"; +import { + playlistQueryKeys, + usePlaylistCurationQuery, + useRecommendedPlaylistQuery, +} from "@/api/playlistQueries"; +import { syncRecommendationEvent } from "@/api/recommendationEventApi"; +import { AppText } from "@/components/AppText"; +import { + useNearbyPlacesQuery, + useReverseGeocodedPlaceQuery, +} from "@/api/tourQueries"; +import { MiniPlayer } from "@/components/MiniPlayer"; +import { FeaturedPlaylistSection } from "@/components/home/FeaturedPlaylistSection"; +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, +} from "@/components/home/MoodRecommendationSection"; +import { Screen } from "@/components/Screen"; +import { getHomeContentBottomPadding } from "@/constants/layout"; +import { useHomeFilterStore } from "@/store/homeFilterStore"; +import { useLibraryStore } from "@/store/libraryStore"; +import { usePlayerStore } from "@/store/playerStore"; +import { useRecommendationEventStore } from "@/store/recommendationEventStore"; +import { + createFeaturedPlaylistsCacheKey, + createMoodRecommendationsCacheKey, + useRecommendationCacheStore, +} from "@/store/recommendationCacheStore"; +import { queryClient } from "@/providers/queryClient"; +import { useAuthStore } from "@/store/authStore"; +import { useTravelSessionStore } from "@/store/travelSessionStore"; +import { useUserProfileStore } from "@/store/userProfileStore"; +import { + FeaturedPlaylist, + MoodRecommendation, + PlaceContext, + PlaylistCuration, + Track, +} from "@/types/domain"; +import { toLibraryPlaylistSummary } from "@/utils/libraryPlaylistSummary"; +import { requestForegroundLocationWithStatus } from "@/utils/location"; +import { getMoodTagsFromFilter } from "@/utils/moodTags"; +import { createRecommendationEventContext } from "@/utils/recommendationEventContext"; +import { getPlaceDisplayTitle } from "@/utils/placeLabel"; + +const moodFilterToMlMood: Record = { + 감성적인: "감성적인", + 로컬한: "설레는", + 설레는: "설레는", + 시원한: "시원한", + 신나는: "신나는", + 잔잔한: "잔잔한", + 청량한: "시원한", + 활기찬: "신나는", +}; + +const EVERYDAY_RECOMMENDATION_MODE = "everyday" as const; +const DEFAULT_PLACE_RECOMMENDATION_STATE: PlaylistMlState = "산책"; + +function resolvePlaylistMood( + filter: string, + preferredMoods: string[], +): PlaylistMlMood { + if (filter !== "전체" && moodFilterToMlMood[filter]) { + return moodFilterToMlMood[filter]; + } + + return ( + preferredMoods.map((mood) => moodFilterToMlMood[mood]).find(Boolean) ?? + "잔잔한" + ); +} + +function resolveCurrentMoodLabel(filter: string, preferredMoods: string[]) { + if (filter !== "전체") { + return filter; + } + + return preferredMoods[0] ?? "잔잔한"; +} + +function resolvePlaceBasedRecommendationState( + place?: PlaceContext, +): PlaylistMlState { + const placeText = [place?.title, place?.category, place?.address] + .filter(Boolean) + .join(" "); + + if ( + /바다|해변|해수욕|해안|항구|선착|해양|섬|beach|ocean|sea/i.test(placeText) + ) { + return "바다"; + } + + if (/카페|커피|디저트|베이커리|찻집|cafe|coffee/i.test(placeText)) { + return "카페"; + } + + if (/야경|전망|전망대|타워|루프탑|스카이|night|view/i.test(placeText)) { + return "야경"; + } + + if (/드라이브|해안도로|도로|휴게소|drive|road/i.test(placeText)) { + return "드라이브"; + } + + return DEFAULT_PLACE_RECOMMENDATION_STATE; +} + +function resolvePlaceLabel(place?: PlaceContext) { + return getPlaceDisplayTitle(place, place?.category ?? "선택한 지역"); +} + +function toFeaturedPlaylist(playlist: PlaylistCuration): FeaturedPlaylist { + return { + id: playlist.id, + regionName: playlist.regionName, + description: playlist.reason, + durationText: playlist.durationText, + trackCount: playlist.trackCount, + }; +} + +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] = + useState(); + const [ + selectedMusicPlaylistEyebrowLabel, + setSelectedMusicPlaylistEyebrowLabel, + ] = useState("Music Playlist"); + const { + selectedMoodFilter, + setSelectedMoodFilter, + } = useHomeFilterStore(); + const { currentTrack, setTrack } = usePlayerStore(); + const { + isLiked, + isSaved, + likedTracks, + savedTracks, + seedFromPlaylist, + setLikeState, + setSaveState, + } = useLibraryStore(); + const addRecommendationEvent = useRecommendationEventStore( + (state) => state.addEvent, + ); + const { profile, updateProfile } = useUserProfileStore(); + const { + clearLocation, + currentLocation, + currentPlace, + locationStatus, + locationUpdatedAt, + setLocation, + setLocationStatus, + setPlace, + } = useTravelSessionStore(); + const recommendationLocation = currentPlace?.location ?? currentLocation; + const featuredPlaylistParams = useMemo( + () => ({ + location: recommendationLocation, + locationRecommendationEnabled: profile.locationRecommendationEnabled, + place: currentPlace, + recommendationMode: EVERYDAY_RECOMMENDATION_MODE, + }), + [ + currentPlace, + profile.locationRecommendationEnabled, + recommendationLocation, + ], + ); + const moodRecommendationParams = useMemo( + () => ({ + currentPlace, + moodFilter: selectedMoodFilter, + preferredGenres: profile.preferredGenres, + preferredMoods: profile.preferredMoods, + recommendationMode: EVERYDAY_RECOMMENDATION_MODE, + travelStyles: profile.travelStyles, + }), + [ + currentPlace, + profile.preferredGenres, + profile.preferredMoods, + profile.travelStyles, + selectedMoodFilter, + ], + ); + const recommendedPlaylistInput = useMemo( + () => ({ + location: recommendationLocation, + mood: resolvePlaylistMood(selectedMoodFilter, profile.preferredMoods), + moodTags: getMoodTagsFromFilter(selectedMoodFilter), + placeId: currentPlace?.id, + preferredGenres: profile.preferredGenres, + preferredMoods: profile.preferredMoods, + state: resolvePlaceBasedRecommendationState(currentPlace), + }), + [ + currentPlace?.id, + currentPlace?.address, + currentPlace?.category, + currentPlace?.title, + profile.preferredGenres, + profile.preferredMoods, + recommendationLocation, + selectedMoodFilter, + ], + ); + + const featuredCacheKey = useMemo( + () => createFeaturedPlaylistsCacheKey(featuredPlaylistParams), + [featuredPlaylistParams], + ); + const moodCacheKey = useMemo( + () => createMoodRecommendationsCacheKey(moodRecommendationParams), + [moodRecommendationParams], + ); + const featuredFallback = useRecommendationCacheStore( + (state) => state.featuredFallback, + ); + const moodFallback = useRecommendationCacheStore( + (state) => state.moodFallback, + ); + const isUsingCachedFeaturedPlaylists = + featuredFallback?.key === featuredCacheKey; + const isUsingCachedMoodRecommendations = moodFallback?.key === moodCacheKey; + const isAuthenticated = authStatus === "authenticated"; + const isMusicPlaylistSheetVisible = Boolean(selectedMusicPlaylistId); + + const nearbyPlacesQuery = useNearbyPlacesQuery({ + enabled: isAuthenticated && profile.locationRecommendationEnabled, + location: currentLocation, + radiusMeters: 2000, + }); + const shouldReverseGeocode = + isAuthenticated && + profile.locationRecommendationEnabled && + Boolean(currentLocation) && + !nearbyPlacesQuery.isFetching && + (nearbyPlacesQuery.data?.length ?? 0) === 0; + const reverseGeocodedPlaceQuery = useReverseGeocodedPlaceQuery({ + enabled: shouldReverseGeocode, + location: currentLocation, + }); + const featuredPlaylistsQuery = useFeaturedPlaylistsQuery( + featuredPlaylistParams, + { + enabled: isAuthenticated, + }, + ); + const recommendedPlaylistQuery = useRecommendedPlaylistQuery( + recommendedPlaylistInput, + { + enabled: isAuthenticated && Boolean(recommendedPlaylistInput.location), + }, + ); + const { + data: recommendedPlaylist, + isError: isRecommendedPlaylistError, + isFetching: isRecommendedPlaylistFetching, + isLoading: isRecommendedPlaylistLoading, + refetch: refetchRecommendedPlaylist, + } = recommendedPlaylistQuery; + const { + data: selectedMusicPlaylist, + isError: isSelectedMusicPlaylistError, + isFetching: isSelectedMusicPlaylistFetching, + isLoading: isSelectedMusicPlaylistLoading, + refetch: refetchSelectedMusicPlaylist, + } = usePlaylistCurationQuery(selectedMusicPlaylistId, { + enabled: isAuthenticated && isMusicPlaylistSheetVisible, + }); + const moodRecommendationsQuery = useMoodRecommendationsQuery( + moodRecommendationParams, + { + enabled: isAuthenticated, + }, + ); + const currentSoundtrackPlaylist = useMemo( + () => + recommendedPlaylist ? toFeaturedPlaylist(recommendedPlaylist) : undefined, + [recommendedPlaylist], + ); + const displayedFeaturedPlaylists = useMemo(() => { + if (!currentSoundtrackPlaylist) { + return featuredPlaylistsQuery.data; + } + + return [ + currentSoundtrackPlaylist, + ...(featuredPlaylistsQuery.data ?? []).filter( + (playlist) => playlist.id !== currentSoundtrackPlaylist.id, + ), + ]; + }, [currentSoundtrackPlaylist, featuredPlaylistsQuery.data]); + const currentSoundtrackSummary = useMemo( + () => + recommendedPlaylist + ? toLibraryPlaylistSummary(recommendedPlaylist) + : undefined, + [recommendedPlaylist], + ); + const selectedMusicPlaylistSummary = useMemo( + () => + selectedMusicPlaylist + ? toLibraryPlaylistSummary(selectedMusicPlaylist) + : undefined, + [selectedMusicPlaylist], + ); + const currentSoundtrackLikedTrackIds = useMemo( + () => + new Set( + recommendedPlaylist?.tracks + .filter((track) => + likedTracks.some((record) => record.track.id === track.id), + ) + .map((track) => track.id) ?? [], + ), + [likedTracks, recommendedPlaylist?.tracks], + ); + const currentSoundtrackSavedTrackIds = useMemo( + () => + new Set( + recommendedPlaylist?.tracks + .filter((track) => + savedTracks.some((record) => record.track.id === track.id), + ) + .map((track) => track.id) ?? [], + ), + [recommendedPlaylist?.tracks, savedTracks], + ); + const selectedMusicPlaylistLikedTrackIds = useMemo( + () => + new Set( + selectedMusicPlaylist?.tracks + .filter((track) => + likedTracks.some((record) => record.track.id === track.id), + ) + .map((track) => track.id) ?? [], + ), + [likedTracks, selectedMusicPlaylist?.tracks], + ); + const selectedMusicPlaylistSavedTrackIds = useMemo( + () => + new Set( + selectedMusicPlaylist?.tracks + .filter((track) => + savedTracks.some((record) => record.track.id === track.id), + ) + .map((track) => track.id) ?? [], + ), + [savedTracks, selectedMusicPlaylist?.tracks], + ); + const placeInfoMessage = + shouldReverseGeocode && reverseGeocodedPlaceQuery.isFetching + ? "주변 관광지 대신 한국어 지역명을 확인하고 있어요." + : shouldReverseGeocode && + !reverseGeocodedPlaceQuery.isFetching && + !reverseGeocodedPlaceQuery.data + ? "지역명은 확인하지 못했지만 기본 추천은 계속 사용할 수 있어요." + : undefined; + + useEffect(() => { + if (!isMoodRecommendationFilter(selectedMoodFilter)) { + setSelectedMoodFilter("전체"); + } + }, [selectedMoodFilter, setSelectedMoodFilter]); + + useEffect(() => { + if (!currentLocation) { + return; + } + + if ( + nearbyPlacesQuery.isFetching || + (shouldReverseGeocode && reverseGeocodedPlaceQuery.isFetching) + ) { + return; + } + + const nextPlace = + nearbyPlacesQuery.data?.[0] ?? reverseGeocodedPlaceQuery.data ?? undefined; + + if (nextPlace?.id !== currentPlace?.id) { + setPlace(nextPlace); + } + }, [ + currentLocation, + currentPlace?.id, + nearbyPlacesQuery.data, + nearbyPlacesQuery.isFetching, + reverseGeocodedPlaceQuery.data, + reverseGeocodedPlaceQuery.isFetching, + setPlace, + shouldReverseGeocode, + ]); + + useEffect(() => { + if (!recommendedPlaylist) { + return; + } + + queryClient.setQueryData( + playlistQueryKeys.detail(recommendedPlaylist.id), + recommendedPlaylist, + ); + }, [recommendedPlaylist]); + + useEffect(() => { + if (!recommendedPlaylist || !currentSoundtrackSummary) { + return; + } + + seedFromPlaylist( + recommendedPlaylist.id, + recommendedPlaylist.tracks, + currentSoundtrackSummary, + ); + }, [currentSoundtrackSummary, recommendedPlaylist, seedFromPlaylist]); + + useEffect(() => { + if (!selectedMusicPlaylist || !selectedMusicPlaylistSummary) { + return; + } + + seedFromPlaylist( + selectedMusicPlaylist.id, + selectedMusicPlaylist.tracks, + selectedMusicPlaylistSummary, + ); + }, [seedFromPlaylist, selectedMusicPlaylist, selectedMusicPlaylistSummary]); + + const handleSelectRecommendation = async (item: MoodRecommendation) => { + setActionMessage(undefined); + + if (item.playlistId) { + setIsSoundtrackSheetVisible(false); + setSelectedMusicPlaylistEyebrowLabel("나의 무드 추천"); + setSelectedMusicPlaylistId(item.playlistId); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext(), + playlistId: item.playlistId, + type: "playlist_open", + value: item.playlistId, + }), + ); + return; + } + + setTrack(item.track); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext(), + playlistId: item.playlistId, + trackId: item.track.id, + type: "track_selected", + value: "home_mood_recommendation", + }), + ); + setActionMessage( + "이 곡을 선택했어요. 하단 음악 패널에서 외부 앱으로 듣거나 리캡에 담을 수 있어요.", + ); + }; + const handleSelectFeaturedPlaylist = useCallback( + (playlist: FeaturedPlaylist) => { + const isCurrentRecommendation = playlist.id === recommendedPlaylist?.id; + + setActionMessage(undefined); + + if (isCurrentRecommendation && recommendedPlaylist) { + queryClient.setQueryData( + playlistQueryKeys.detail(recommendedPlaylist.id), + recommendedPlaylist, + ); + } + + setIsSoundtrackSheetVisible(false); + setSelectedMusicPlaylistEyebrowLabel("Music Playlist"); + setSelectedMusicPlaylistId(playlist.id); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext({ + source: isCurrentRecommendation + ? recommendedPlaylist?.context?.source + : "featured-playlist", + }), + playlistId: playlist.id, + type: "playlist_open", + value: playlist.id, + }), + ); + }, + [addRecommendationEvent, recommendedPlaylist], + ); + const handleSelectMoodFilter = useCallback( + (filter: string) => { + if (filter === selectedMoodFilter) { + return; + } + + setSelectedMoodFilter(filter); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext({ moodFilter: filter }), + type: "mood_filter_change", + value: filter, + }), + ); + }, + [addRecommendationEvent, selectedMoodFilter, setSelectedMoodFilter], + ); + const handleEnableLocationRecommendation = useCallback(async () => { + const nextProfile = { + companionType: profile.companionType, + locationRecommendationEnabled: true, + preferredGenres: profile.preferredGenres, + preferredMoods: profile.preferredMoods, + travelStyles: profile.travelStyles, + }; + + setActionMessage(undefined); + + try { + await meApi.updateProfile(nextProfile); + updateProfile(nextProfile); + return true; + } catch { + setActionMessage( + "위치 추천 설정을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.", + ); + return false; + } + }, [profile, updateProfile]); + const handleRefreshLocation = useCallback(async () => { + if (locationStatus === "loading") { + return; + } + + setLocationStatus("loading"); + + try { + const result = await requestForegroundLocationWithStatus(); + + if (result.location) { + setPlace(undefined); + setLocation(result.location); + return; + } + + if (result.status === "denied") { + clearLocation(); + } + setLocationStatus(result.status === "denied" ? "denied" : "unavailable"); + } catch { + setLocationStatus("unavailable"); + } + }, [ + clearLocation, + locationStatus, + setLocation, + 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(); + + if (!didEnable) { + return; + } + } + + void handleRefreshLocation(); + }, [ + handleEnableLocationRecommendation, + handleRefreshLocation, + profile.locationRecommendationEnabled, + ]); + + const handleRefreshCurrentSoundtrack = useCallback(() => { + setActionMessage(undefined); + + if (!recommendedPlaylistInput.location) { + void handleSetCurrentLocation(); + return; + } + + void refetchRecommendedPlaylist(); + }, [ + handleSetCurrentLocation, + recommendedPlaylistInput.location, + refetchRecommendedPlaylist, + ]); + const handleOpenCurrentSoundtrack = useCallback(() => { + setActionMessage(undefined); + + if (!recommendedPlaylistInput.location) { + handleRefreshCurrentSoundtrack(); + return; + } + + setSelectedMusicPlaylistId(undefined); + setIsSoundtrackSheetVisible(true); + + if (!recommendedPlaylist) { + void refetchRecommendedPlaylist(); + return; + } + + queryClient.setQueryData( + playlistQueryKeys.detail(recommendedPlaylist.id), + recommendedPlaylist, + ); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext({ + moodFilter: selectedMoodFilter, + source: recommendedPlaylist.context?.source, + }), + playlistId: recommendedPlaylist.id, + type: "playlist_open", + value: recommendedPlaylist.id, + }), + ); + }, [ + addRecommendationEvent, + handleRefreshCurrentSoundtrack, + recommendedPlaylist, + recommendedPlaylistInput.location, + refetchRecommendedPlaylist, + selectedMoodFilter, + ]); + const handleCloseCurrentSoundtrack = useCallback(() => { + setIsSoundtrackSheetVisible(false); + }, []); + const handleSelectCurrentSoundtrackTrack = useCallback( + (track: Track) => { + if (!recommendedPlaylist) { + return; + } + + const context = createRecommendationEventContext({ + moodFilter: selectedMoodFilter, + source: recommendedPlaylist.context?.source, + }); + + setActionMessage(undefined); + setTrack( + track, + recommendedPlaylist.id, + recommendedPlaylist.tracks, + currentSoundtrackSummary, + ); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: recommendedPlaylist.id, + trackId: track.id, + type: "track_selected", + value: "home_current_soundtrack", + }), + ); + setActionMessage( + "이 곡을 선택했어요. 하단 음악 패널에서 외부 앱으로 듣거나 리캡에 담을 수 있어요.", + ); + }, + [ + addRecommendationEvent, + currentSoundtrackSummary, + recommendedPlaylist, + selectedMoodFilter, + setTrack, + ], + ); + const handleToggleCurrentSoundtrackLike = useCallback( + (track: Track) => { + const nextLiked = !isLiked(track.id); + const context = createRecommendationEventContext({ + moodFilter: selectedMoodFilter, + source: recommendedPlaylist?.context?.source, + }); + + setActionMessage(undefined); + setLikeState( + track, + nextLiked, + recommendedPlaylist?.id, + currentSoundtrackSummary, + ); + void libraryApi + .updateTrackState(track.id, { + action: nextLiked ? "like" : "unlike", + context, + playlistId: recommendedPlaylist?.id, + }) + .catch(() => { + setLikeState( + track, + !nextLiked, + recommendedPlaylist?.id, + currentSoundtrackSummary, + ); + setActionMessage("서버 저장에 실패해서 좋아요 상태를 되돌렸어요."); + }); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: recommendedPlaylist?.id, + trackId: track.id, + type: nextLiked ? "track_like" : "track_unlike", + }), + ); + }, + [ + addRecommendationEvent, + currentSoundtrackSummary, + isLiked, + recommendedPlaylist, + selectedMoodFilter, + setLikeState, + ], + ); + const handleToggleCurrentSoundtrackSave = useCallback( + (track: Track) => { + const nextSaved = !isSaved(track.id); + const context = createRecommendationEventContext({ + moodFilter: selectedMoodFilter, + source: recommendedPlaylist?.context?.source, + }); + + setActionMessage(undefined); + setSaveState( + track, + nextSaved, + recommendedPlaylist?.id, + currentSoundtrackSummary, + ); + void libraryApi + .updateTrackState(track.id, { + action: nextSaved ? "save" : "unsave", + context, + playlistId: recommendedPlaylist?.id, + }) + .catch(() => { + setSaveState( + track, + !nextSaved, + recommendedPlaylist?.id, + currentSoundtrackSummary, + ); + setActionMessage("서버 저장에 실패해서 저장 상태를 되돌렸어요."); + }); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: recommendedPlaylist?.id, + trackId: track.id, + type: nextSaved ? "track_save" : "track_unsave", + }), + ); + }, + [ + addRecommendationEvent, + currentSoundtrackSummary, + isSaved, + recommendedPlaylist, + selectedMoodFilter, + setSaveState, + ], + ); + const handleCloseMusicPlaylistSheet = useCallback(() => { + setSelectedMusicPlaylistId(undefined); + }, []); + const handleSelectMusicPlaylistTrack = useCallback( + (track: Track) => { + if (!selectedMusicPlaylist) { + return; + } + + const context = createRecommendationEventContext({ + moodFilter: selectedMoodFilter, + source: selectedMusicPlaylist.context?.source ?? "featured-playlist", + }); + + setActionMessage(undefined); + setTrack( + track, + selectedMusicPlaylist.id, + selectedMusicPlaylist.tracks, + selectedMusicPlaylistSummary, + ); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: selectedMusicPlaylist.id, + trackId: track.id, + type: "track_selected", + value: "home_music_playlist", + }), + ); + setActionMessage( + "이 곡을 Soundlog 음악으로 선택했어요. 하단 패널에서 저장하거나 리캡에 담을 수 있어요.", + ); + }, + [ + addRecommendationEvent, + selectedMoodFilter, + selectedMusicPlaylist, + selectedMusicPlaylistSummary, + setTrack, + ], + ); + const handleToggleMusicPlaylistLike = useCallback( + (track: Track) => { + const nextLiked = !isLiked(track.id); + const context = createRecommendationEventContext({ + moodFilter: selectedMoodFilter, + source: selectedMusicPlaylist?.context?.source ?? "featured-playlist", + }); + + setActionMessage(undefined); + setLikeState( + track, + nextLiked, + selectedMusicPlaylist?.id, + selectedMusicPlaylistSummary, + ); + void libraryApi + .updateTrackState(track.id, { + action: nextLiked ? "like" : "unlike", + context, + playlistId: selectedMusicPlaylist?.id, + }) + .catch(() => { + setLikeState( + track, + !nextLiked, + selectedMusicPlaylist?.id, + selectedMusicPlaylistSummary, + ); + setActionMessage("서버 저장에 실패해서 좋아요 상태를 되돌렸어요."); + }); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: selectedMusicPlaylist?.id, + trackId: track.id, + type: nextLiked ? "track_like" : "track_unlike", + }), + ); + }, + [ + addRecommendationEvent, + isLiked, + selectedMoodFilter, + selectedMusicPlaylist, + selectedMusicPlaylistSummary, + setLikeState, + ], + ); + const handleToggleMusicPlaylistSave = useCallback( + (track: Track) => { + const nextSaved = !isSaved(track.id); + const context = createRecommendationEventContext({ + moodFilter: selectedMoodFilter, + source: selectedMusicPlaylist?.context?.source ?? "featured-playlist", + }); + + setActionMessage(undefined); + setSaveState( + track, + nextSaved, + selectedMusicPlaylist?.id, + selectedMusicPlaylistSummary, + ); + void libraryApi + .updateTrackState(track.id, { + action: nextSaved ? "save" : "unsave", + context, + playlistId: selectedMusicPlaylist?.id, + }) + .catch(() => { + setSaveState( + track, + !nextSaved, + selectedMusicPlaylist?.id, + selectedMusicPlaylistSummary, + ); + setActionMessage("서버 저장에 실패해서 저장 상태를 되돌렸어요."); + }); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: selectedMusicPlaylist?.id, + trackId: track.id, + type: nextSaved ? "track_save" : "track_unsave", + }), + ); + }, + [ + addRecommendationEvent, + isSaved, + selectedMoodFilter, + selectedMusicPlaylist, + selectedMusicPlaylistSummary, + setSaveState, + ], + ); + + return ( + + + + + setIsPlacePickerVisible(true)} + place={currentPlace} + placeCount={nearbyPlacesQuery.data?.length ?? 0} + placeInfoMessage={placeInfoMessage} + status={locationStatus} + updatedAt={locationUpdatedAt} + /> + + + + void featuredPlaylistsQuery.refetch()} + /> + + void moodRecommendationsQuery.refetch()} + selectedMoodFilter={selectedMoodFilter} + /> + + {actionMessage ? ( + + {actionMessage} + + ) : null} + + void refetchRecommendedPlaylist()} + onSelectTrack={handleSelectCurrentSoundtrackTrack} + onToggleLike={handleToggleCurrentSoundtrackLike} + onToggleSave={handleToggleCurrentSoundtrackSave} + playlist={recommendedPlaylist} + savedTrackIds={currentSoundtrackSavedTrackIds} + visible={isSoundtrackSheetVisible} + /> + void refetchSelectedMusicPlaylist()} + onSelectTrack={handleSelectMusicPlaylistTrack} + onToggleLike={handleToggleMusicPlaylistLike} + onToggleSave={handleToggleMusicPlaylistSave} + playlist={selectedMusicPlaylist} + savedTrackIds={selectedMusicPlaylistSavedTrackIds} + visible={isMusicPlaylistSheetVisible} + /> + setIsPlacePickerVisible(false)} + onSelect={handleSelectManualPlace} + visible={isPlacePickerVisible} + /> + {currentTrack ? : null} + + ); +} + +export default function HomeScreen() { + const { isHydrated: authHydrated, status } = useAuthStore(); + const { isHydrated, profile } = useUserProfileStore(); + + useEffect(() => { + if (isHydrated && !profile.completedOnboarding) { + router.replace("/onboarding" as never); + } + }, [isHydrated, profile.completedOnboarding]); + + if (!authHydrated || status === "checking") { + return ; + } + + if (status !== "authenticated") { + return ( + + ); + } + + if (!isHydrated || !profile.completedOnboarding) { + return isHydrated ? : ; + } + + return ; +} diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index fefc714..6faed61 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -1,52 +1,30 @@ -import { Feather } from '@expo/vector-icons'; import { router } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; -import { Pressable, ScrollView, View } from 'react-native'; +import { ScrollView, View } from 'react-native'; import { meApi } from '@/api/meApi'; import { useNearbyPlacesQuery } from '@/api/tourQueries'; import { AppText } from '@/components/AppText'; -import { LocationContextCard } from '@/components/home/LocationContextCard'; import { AuthAccountCard } from '@/components/my/AuthAccountCard'; +import { MySettingsRow } from '@/components/my/MySettingsRow'; import { PermissionSettingsCard } from '@/components/my/PermissionSettingsCard'; +import { PageHeader } from '@/components/PageHeader'; import { Screen } from '@/components/Screen'; +import { SectionTitle } from '@/components/SectionTitle'; import { useNativePermissionSettings } from '@/hooks/useNativePermissionSettings'; -import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import { useUserProfileStore } from '@/store/userProfileStore'; import { requestForegroundLocationWithStatus } from '@/utils/location'; -type MyMenuItem = { - description?: string; - icon: keyof typeof Feather.glyphMap; - label: string; - onPress?: () => void; -}; - -function formatEventTime(value?: string) { - if (!value) { - return '아직 없음'; - } - - const date = new Date(value); - - if (Number.isNaN(date.getTime())) { - return '확인 불가'; - } - - return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; -} - export default function MyScreen() { const { profile, resetOnboarding, updateProfile } = useUserProfileStore(); const [profileMessage, setProfileMessage] = useState(); - const { clearEvents, events, isHydrated } = useRecommendationEventStore(); const permissionSettings = useNativePermissionSettings(); const { + clearLocation, currentLocation, currentPlace, locationStatus, - locationUpdatedAt, setLocation, setLocationStatus, setPlace, @@ -61,6 +39,21 @@ export default function MyScreen() { ...profile.preferredMoods.slice(0, 1), ...profile.travelStyles.slice(0, 1), ].join(' · '); + const currentPlaceSummary = + locationStatus === 'loading' + ? '확인 중' + : currentPlace?.title + ? currentPlace.title + : currentLocation + ? '주변 관광지 없음' + : locationStatus === 'denied' + ? '권한 꺼짐' + : '확인 필요'; + const currentPlaceDescription = nearbyPlacesQuery.isLoading + ? '현재 위치 주변 관광지를 확인하고 있어요.' + : nearbyPlacesQuery.data?.length + ? `주변 관광지 ${nearbyPlacesQuery.data.length}곳을 추천에 반영하고 있어요.` + : '현재 장소를 다시 확인할 수 있어요.'; useEffect(() => { if (!nearbyPlacesQuery.data) { @@ -69,7 +62,7 @@ export default function MyScreen() { const nextPlace = nearbyPlacesQuery.data[0]; - if (nextPlace?.id !== currentPlace?.id) { + if (nextPlace && nextPlace.id !== currentPlace?.id) { setPlace(nextPlace); } }, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]); @@ -89,7 +82,9 @@ export default function MyScreen() { await meApi.updateProfile(nextProfile); updateProfile(nextProfile); } catch { - setProfileMessage('위치 추천 설정을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.'); + setProfileMessage( + '위치 추천 설정을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.', + ); } }, [profile, updateProfile]); @@ -108,69 +103,89 @@ export default function MyScreen() { return; } + if (result.status === 'denied') { + clearLocation(); + } setLocationStatus(result.status === 'denied' ? 'denied' : 'unavailable'); } catch { setLocationStatus('unavailable'); } - }, [locationStatus, setLocation, setLocationStatus]); - - const menuItems: MyMenuItem[] = [ - { - description: selectedSummary || '아직 저장된 취향 정보가 없어요.', - icon: 'sliders', - label: '취향 수정', - onPress: () => - router.push({ - pathname: '/onboarding', - params: { mode: 'edit' }, - } as never), - }, - { - description: '위치 · 카메라 · 사진', - icon: 'map-pin', - label: '권한 설정', - }, - { - description: '데이터 수집과 보관, 삭제 요청 방법을 확인합니다.', - icon: 'shield', - label: '개인정보 처리방침', - onPress: () => router.push('/legal/privacy' as never), - }, - { - description: '서비스 이용 조건과 사용자 콘텐츠 기준을 확인합니다.', - icon: 'file-text', - label: '서비스 이용약관', - onPress: () => router.push('/legal/terms' as never), - }, - { - description: '온보딩을 다시 볼 수 있도록 초기화합니다.', - icon: 'rotate-ccw', - label: '온보딩 초기화', - onPress: () => { - resetOnboarding(); - router.replace('/onboarding' as never); - }, - }, - ]; + }, [clearLocation, locationStatus, setLocation, setLocationStatus]); return ( - 마이 + - - 내 추천 프로필 - - {profile.completedOnboarding ? '취향 설정 완료' : '취향 설정 전'} - - - {selectedSummary || '취향을 입력하면 홈 추천 필터가 더 자연스럽게 맞춰져요.'} - + + + + router.push({ + pathname: '/onboarding', + params: { mode: 'edit' }, + } as never) + } + rightText={selectedSummary || '설정 전'} + /> + router.push('/library' as never)} + /> + + router.push({ + pathname: '/recap', + params: { view: 'all' }, + } as never) + } + /> + + + + + void handleRefreshLocation()} + rightText={currentPlaceSummary} + /> + void handleEnableLocationRecommendation() + } + rightText={profile.locationRecommendationEnabled ? '켜짐' : '꺼짐'} + /> + {profileMessage ? ( + + {profileMessage} + + ) : null} - - + + router.push('/legal/privacy' as never)} + /> + router.push('/legal/terms' as never)} /> - {profileMessage ? ( - - {profileMessage} - - ) : null} - - - - {menuItems.map((item) => ( - - - - - - {item.label} - {item.description ? ( - - {item.description} - - ) : null} - - {item.onPress ? ( - - ) : null} - - ))} {__DEV__ ? ( - - - - - 추천 피드백 로그 - - - {isHydrated ? `${events.length}개` : '동기화 중'} - - - 마지막 이벤트 {formatEventTime(events[0]?.createdAt)} - - {events[0] ? ( - - {events[0].type} - {events[0].value ? ` · ${events[0].value}` : ''} - - ) : null} - - - 초기화 - - + + + { + resetOnboarding(); + router.replace('/onboarding' as never); + }} + /> ) : null} diff --git a/app/(tabs)/travel.tsx b/app/(tabs)/travel.tsx deleted file mode 100644 index 96a5fbd..0000000 --- a/app/(tabs)/travel.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TravelScreen } from '@/components/travel/TravelScreen'; - -export default function TravelTabScreen() { - return ; -} diff --git a/app/_layout.tsx b/app/_layout.tsx index 5d4e406..a80dfdb 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -18,6 +18,10 @@ export default function RootLayout() { + + + + diff --git a/app/auth/callback.tsx b/app/auth/callback.tsx index 8599da1..8a1b52c 100644 --- a/app/auth/callback.tsx +++ b/app/auth/callback.tsx @@ -1,29 +1,51 @@ -import { Feather } from '@expo/vector-icons'; import { router } from 'expo-router'; import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { IconButton } from '@/components/IconButton'; +import { PageHeader } from '@/components/PageHeader'; import { Screen } from '@/components/Screen'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; export default function AuthCallbackScreen() { return ( - - - + + + router.replace('/auth/login' as never)} + /> + } + title="로그인 연결" + /> + + + + + + + + 로그인 화면에서 이메일과 비밀번호를 입력해 다시 진행해주세요. + + + router.replace('/auth/login' as never)} + > + + 로그인으로 돌아가기 + + - - 로그인 연결을 확인 중이에요 - - - 계정 연결 결과를 확인하고 있어요. 잠시만 기다려주세요. - - router.replace('/auth/login' as never)} - > - 로그인으로 돌아가기 - ); } diff --git a/app/auth/login.tsx b/app/auth/login.tsx index cbc560e..3b4695e 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -1,21 +1,23 @@ -import { Feather } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { router } from 'expo-router'; -import { useState } from 'react'; -import { Pressable, ScrollView, TextInput, View } from 'react-native'; +import { Feather } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { useState } from "react"; +import { Pressable, ScrollView, TextInput, View } from "react-native"; -import { useLoginMutation, useRegisterMutation } from '@/api/authQueries'; -import { AppText } from '@/components/AppText'; -import { BrandLogo } from '@/components/BrandLogo'; -import { Screen } from '@/components/Screen'; -import { useAuthStore } from '@/store/authStore'; -import { useUserProfileStore } from '@/store/userProfileStore'; -import { migrateLocalDataToAccount } from '@/utils/localDataMigration'; +import { useLoginMutation, useRegisterMutation } from "@/api/authQueries"; +import { AppText } from "@/components/AppText"; +import { IconButton } from "@/components/IconButton"; +import { PageHeader } from "@/components/PageHeader"; +import { Screen } from "@/components/Screen"; +import { SectionTitle } from "@/components/SectionTitle"; +import { SettingsRow } from "@/components/SettingsRow"; +import { useAuthStore } from "@/store/authStore"; +import { useUserProfileStore } from "@/store/userProfileStore"; +import { migrateLocalDataToAccount } from "@/utils/localDataMigration"; -type AuthMode = 'login' | 'register'; +type AuthMode = "login" | "register"; function getNextRoute(completedOnboarding: boolean) { - return (completedOnboarding ? '/' : '/onboarding') as never; + return (completedOnboarding ? "/" : "/onboarding") as never; } function getErrorMessage(error: unknown) { @@ -23,24 +25,21 @@ function getErrorMessage(error: unknown) { return error.message; } - return '로그인에 실패했어요. 잠시 후 다시 시도해주세요.'; + return "로그인에 실패했어요. 잠시 후 다시 시도해주세요."; } export default function LoginScreen() { - const [mode, setMode] = useState('login'); - const [displayName, setDisplayName] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); + const [mode, setMode] = useState("login"); + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [hasAcceptedRequiredTerms, setHasAcceptedRequiredTerms] = + useState(false); const loginMutation = useLoginMutation(); const registerMutation = useRegisterMutation(); const isPending = loginMutation.isPending || registerMutation.isPending; - const { - clearAuthError, - errorMessage, - finishLogin, - setAuthError, - setStatus, - } = useAuthStore(); + const { clearAuthError, errorMessage, finishLogin, setAuthError, setStatus } = + useAuthStore(); const { profile, updateProfile } = useUserProfileStore(); const handleModePress = (nextMode: AuthMode) => { @@ -56,22 +55,29 @@ export default function LoginScreen() { const normalizedEmail = email.trim().toLowerCase(); const trimmedDisplayName = displayName.trim(); - if (!normalizedEmail.includes('@')) { - setAuthError('이메일을 확인해주세요.'); + if (!normalizedEmail.includes("@")) { + setAuthError("이메일을 확인해주세요."); return; } if (password.length < 8) { - setAuthError('비밀번호는 8자 이상이어야 해요.'); + setAuthError("비밀번호는 8자 이상이어야 해요."); return; } - setStatus('checking'); + if (mode === "register" && !hasAcceptedRequiredTerms) { + setAuthError( + "계정을 만들려면 이용약관과 개인정보 처리방침에 동의해주세요.", + ); + return; + } + + setStatus("checking"); clearAuthError(); try { const session = - mode === 'login' + mode === "login" ? await loginMutation.mutateAsync({ email: normalizedEmail, password, @@ -83,9 +89,13 @@ export default function LoginScreen() { }); const didCompleteOnboarding = - profile.completedOnboarding || Boolean(session.profile?.completedOnboarding); + profile.completedOnboarding || + Boolean(session.profile?.completedOnboarding); - if (!profile.completedOnboarding && session.profile?.completedOnboarding) { + if ( + !profile.completedOnboarding && + session.profile?.completedOnboarding + ) { updateProfile(session.profile); } @@ -93,7 +103,7 @@ export default function LoginScreen() { void migrateLocalDataToAccount(); router.replace(getNextRoute(didCompleteOnboarding)); } catch (error) { - setStatus('unauthenticated'); + setStatus("unauthenticated"); setAuthError(getErrorMessage(error)); } }; @@ -103,7 +113,7 @@ export default function LoginScreen() { - - - - - Soundlog account - - - + router.replace("/onboarding" as never)} + /> + } + title="Soundlog" + /> - - 여행의 사운드를{'\n'}내 계정에 저장해요 + + 계정으로 계속하기 - 이메일 계정으로 취향, 좋아요, 순간 기록, Recap을 서버에 동기화할 수 + 이메일 계정으로 취향, 좋아요, 리캡, 여행 로그를 서버에 동기화할 수 있어요. Soundlog 이용은 로그인 후 시작할 수 있습니다. - - - - - - - - - Soundlog 자체 계정 - - - 외부 계정 연결 없이 이메일과 비밀번호로 기록을 이어둘 수 있어요. - - - - - + + + + - - {(['login', 'register'] as const).map((item) => { + + {(["login", "register"] as const).map((item) => { const isActive = mode === item; return ( handleModePress(item)} > - {item === 'login' ? '로그인' : '가입'} + {item === "login" ? "로그인" : "가입"} ); })} - {mode === 'register' ? ( + {mode === "register" ? ( { @@ -225,13 +222,43 @@ export default function LoginScreen() { placeholderTextColor="rgba(255,255,255,0.35)" returnKeyType="done" secureTextEntry - textContentType={mode === 'login' ? 'password' : 'newPassword'} + textContentType={mode === "login" ? "password" : "newPassword"} value={password} /> + {mode === "register" ? ( + { + setHasAcceptedRequiredTerms((accepted) => !accepted); + clearAuthError(); + }} + > + + {hasAcceptedRequiredTerms ? ( + + ) : null} + + + 이용약관과 개인정보 처리방침에 동의합니다. (필수) + + + ) : null} + {errorMessage ? ( - - + + + {errorMessage} @@ -239,29 +266,31 @@ export default function LoginScreen() { { void handleSubmit(); }} > - + {isPending - ? '처리 중...' - : mode === 'login' - ? '로그인' - : '계정 만들기'} + ? "처리 중..." + : mode === "login" + ? "로그인" + : "계정 만들기"} - 계속 진행하면 Soundlog 정책에 동의한 것으로 간주됩니다. + {mode === "register" + ? "필수 약관에 동의한 뒤 계정을 만들 수 있어요." + : "로그인하면 기존 계정의 설정과 기록을 불러옵니다."} router.push('/legal/terms' as never)} + onPress={() => router.push("/legal/terms" as never)} > 이용약관 @@ -270,7 +299,7 @@ export default function LoginScreen() { | router.push('/legal/privacy' as never)} + onPress={() => router.push("/legal/privacy" as never)} > 개인정보 처리방침 diff --git a/app/legal/privacy.tsx b/app/legal/privacy.tsx index 6e8543d..ee3747d 100644 --- a/app/legal/privacy.tsx +++ b/app/legal/privacy.tsx @@ -1,29 +1,29 @@ -import { LegalDocumentScreen } from '@/components/legal/LegalDocumentScreen'; -import { SOUNDLOG_SUPPORT_EMAIL } from '@/constants/legal'; +import { LegalDocumentScreen } from "@/components/legal/LegalDocumentScreen"; +import { SOUNDLOG_SUPPORT_EMAIL } from "@/constants/legal"; const privacySections = [ { - title: '수집하는 정보', - body: 'Soundlog는 자체 계정 가입과 로그인을 위해 사용자가 입력한 이름, 이메일, 비밀번호 인증 정보를 처리할 수 있습니다. 사용자가 입력한 음악 취향, 여행 스타일, 동행 유형, 좋아요와 저장한 음악, 여행 순간 기록, Recap 생성에 필요한 사진, 위치, 시간, 장소, 음악 정보를 저장할 수 있습니다.', + title: "수집하는 정보", + body: "Soundlog는 자체 계정 가입과 로그인을 위해 사용자가 입력한 이름, 이메일, 비밀번호 인증 정보를 처리할 수 있습니다. 사용자가 입력한 음악 취향, 여행 스타일, 동행 유형, 좋아요와 저장한 음악, 리캡과 여행 로그 생성에 필요한 사진, 위치, 시간, 장소, 음악 정보를 저장할 수 있습니다.", }, { - title: '위치와 사진 권한', - body: '위치 권한은 현재 장소에 맞는 추천과 여행 순간의 장소 기록을 위해 사용합니다. 카메라와 사진 보관함 권한은 사용자가 직접 촬영한 순간을 저장하고 Recap 이미지를 보관함에 저장하기 위해 사용합니다. 백그라운드 위치 추적은 사용하지 않습니다.', + title: "위치와 사진 권한", + body: "위치 권한은 현재 장소에 맞는 추천과 여행 순간의 장소 기록을 위해 사용합니다. 카메라와 사진 보관함 권한은 사용자가 직접 촬영한 순간을 저장하고 Recap 이미지를 보관함에 저장하기 위해 사용합니다. 백그라운드 위치 추적은 사용하지 않습니다.", }, { - title: '이용 목적', - body: '수집된 정보는 위치 기반 음악 추천, 여행 로그 저장, Recap 생성, 계정 동기화, 오류 대응, 서비스 안정성 개선에 사용됩니다. 광고 추적이나 제3자 광고 목적의 판매에는 사용하지 않습니다.', + title: "이용 목적", + body: "수집된 정보는 위치 기반 음악 추천, 여행 로그 저장, Recap 생성, 계정 동기화, 오류 대응, 서비스 안정성 개선에 사용됩니다. 광고 추적이나 제3자 광고 목적의 판매에는 사용하지 않습니다.", }, { - title: '제3자 서비스', - body: '로그인에는 Soundlog 자체 계정이 사용되고, 장소 정보에는 공공 관광 데이터 API가 사용될 수 있습니다. Soundlog는 음악 추천과 기록 UI를 앱 안에서 제공하며, 음원 재생 서비스 계정 정보는 수집하지 않습니다.', + title: "제3자 서비스", + body: "로그인에는 Soundlog 자체 계정이 사용되고, 장소 정보에는 공공 관광 데이터 API가 사용될 수 있습니다. Soundlog는 음악 추천과 기록 UI를 앱 안에서 제공하며, 음원 재생 서비스 계정 정보는 수집하지 않습니다.", }, { - title: '보관과 삭제', - body: '계정 데이터와 여행 기록은 사용자가 서비스를 이용하는 동안 보관됩니다. 삭제를 원하면 My 화면의 계정 삭제 요청 또는 문의 메일을 통해 요청할 수 있으며, 확인 후 처리됩니다.', + title: "보관과 삭제", + body: "계정 데이터와 여행 기록은 사용자가 서비스를 이용하는 동안 보관됩니다. My 화면의 계정 삭제를 실행하면 계정, 인증 토큰, 여행 기록, 리캡, 보관함과 커뮤니티 데이터가 즉시 삭제됩니다. 삭제 후에는 복구할 수 없습니다.", }, { - title: '문의', + title: "문의", body: `개인정보와 데이터 삭제 문의는 ${SOUNDLOG_SUPPORT_EMAIL} 으로 보낼 수 있습니다.`, }, ]; diff --git a/app/legal/terms.tsx b/app/legal/terms.tsx index bad003f..ed66958 100644 --- a/app/legal/terms.tsx +++ b/app/legal/terms.tsx @@ -1,29 +1,29 @@ -import { LegalDocumentScreen } from '@/components/legal/LegalDocumentScreen'; -import { SOUNDLOG_SUPPORT_EMAIL } from '@/constants/legal'; +import { LegalDocumentScreen } from "@/components/legal/LegalDocumentScreen"; +import { SOUNDLOG_SUPPORT_EMAIL } from "@/constants/legal"; const termsSections = [ { - title: '서비스 이용', - body: 'Soundlog는 위치와 여행 맥락을 바탕으로 음악 추천, 순간 기록, Recap 생성을 제공하는 서비스입니다. 사용자는 본인의 기기와 계정에서 발생하는 활동에 대한 책임이 있습니다.', + title: "서비스 이용", + body: "Soundlog는 위치와 여행 맥락을 바탕으로 음악 추천, 단일 리캡 저장, 여행 로그 생성을 제공하는 서비스입니다. 사용자는 본인의 기기와 계정에서 발생하는 활동에 대한 책임이 있습니다.", }, { - title: '계정 기반 이용', - body: 'Soundlog의 추천, 좋아요, 여행 기록, Recap 기능은 로그인된 Soundlog 계정에서 사용할 수 있습니다. 온보딩과 약관 확인은 로그인 전에도 볼 수 있지만, 앱의 주요 기능 이용에는 계정 로그인이 필요합니다.', + title: "계정 기반 이용", + body: "Soundlog의 추천, 좋아요, 여행 기록, Recap 기능은 로그인된 Soundlog 계정에서 사용할 수 있습니다. 온보딩과 약관 확인은 로그인 전에도 볼 수 있지만, 앱의 주요 기능 이용에는 계정 로그인이 필요합니다.", }, { - title: '사용자 콘텐츠', - body: '사용자가 촬영하거나 저장한 사진, 장소, 음악 메모, Recap 자료의 권리는 사용자에게 있습니다. Soundlog는 서비스 제공과 동기화, 공유 기능 제공을 위해 필요한 범위에서만 이를 처리합니다.', + title: "사용자 콘텐츠", + body: "사용자가 촬영하거나 저장한 사진, 장소, 음악 메모, Recap 자료의 권리는 사용자에게 있습니다. Soundlog는 서비스 제공과 동기화, 공유 기능 제공을 위해 필요한 범위에서만 이를 처리합니다.", }, { - title: '외부 서비스', - body: '장소 정보 등 공공 관광 데이터 제공자의 서비스가 사용될 수 있으며, 해당 데이터 제공자의 정책이 함께 적용될 수 있습니다. Soundlog는 앱 안에서 음악 추천과 기록 UI를 제공하며 외부 음원 재생을 보장하지 않습니다.', + title: "외부 서비스", + body: "장소 정보 등 공공 관광 데이터 제공자의 서비스가 사용될 수 있으며, 해당 데이터 제공자의 정책이 함께 적용될 수 있습니다. Soundlog는 앱 안에서 음악 추천과 기록 UI를 제공하며 외부 음원 재생을 보장하지 않습니다.", }, { - title: '제한 사항', - body: '타인의 권리를 침해하는 콘텐츠, 불법적인 목적의 이용, 서비스 안정성을 해치는 행위는 허용되지 않습니다. 필요한 경우 서비스 이용이 제한될 수 있습니다.', + title: "제한 사항", + body: "타인의 권리를 침해하는 콘텐츠, 불법적인 목적의 이용, 서비스 안정성을 해치는 행위는 허용되지 않습니다. 필요한 경우 서비스 이용이 제한될 수 있습니다.", }, { - title: '문의와 변경', + title: "문의와 변경", body: `약관 또는 서비스 이용 문의는 ${SOUNDLOG_SUPPORT_EMAIL} 으로 보낼 수 있습니다. 약관이 변경되는 경우 앱 또는 스토어 고지를 통해 안내합니다.`, }, ]; diff --git a/app/(tabs)/library.tsx b/app/library.tsx similarity index 67% rename from app/(tabs)/library.tsx rename to app/library.tsx index 7b12a35..7d93d1b 100644 --- a/app/(tabs)/library.tsx +++ b/app/library.tsx @@ -1,5 +1,5 @@ import { LibraryScreen } from '@/components/library/LibraryScreen'; -export default function LibraryTabScreen() { +export default function LibraryScreenRoute() { return ; } diff --git a/app/(tabs)/playlist/[id].tsx b/app/playlist/[id].tsx similarity index 100% rename from app/(tabs)/playlist/[id].tsx rename to app/playlist/[id].tsx diff --git a/app/(tabs)/recap-share/[id].tsx b/app/recap-share/[id].tsx similarity index 100% rename from app/(tabs)/recap-share/[id].tsx rename to app/recap-share/[id].tsx diff --git a/app/(tabs)/travel-room/[roomId].tsx b/app/travel-room/[roomId].tsx similarity index 100% rename from app/(tabs)/travel-room/[roomId].tsx rename to app/travel-room/[roomId].tsx diff --git a/docs/README.md b/docs/README.md index 90a31b3..b4ec0f7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ Soundlog 문서는 목적별로 관리합니다. 공모전 기획, RN 프론트 ## Product +- [리캡·로그 도메인 기준](product/RECAP_LOG_DOMAIN_MODEL.md): **최우선 기준 문서.** 리캡, 여행 로그, 여행 세션, GPS 경로, 로그 지도 핀, 레거시 코드 용어의 의미 - [서비스 기획서](product/SOUNDLOG_APP_PLANNING.md): 서비스 배경, 핵심 가치, 주요 기능, 데이터 활용 방향 - [여행 사운드트랙 로그 기획 명세](product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md): 외부 음악 링크, MomentLog, Recap 중심 MVP 피벗과 화면별 와이어프레임/명세 - [여행 사운드트랙 HTML 와이어프레임](product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html): 브라우저에서 보는 화면 와이어프레임과 기능 설명 diff --git a/docs/codex/TEST_MANAGER.md b/docs/codex/TEST_MANAGER.md index 57e2225..793d184 100644 --- a/docs/codex/TEST_MANAGER.md +++ b/docs/codex/TEST_MANAGER.md @@ -6,9 +6,8 @@ ## 검수 결과 -- 온보딩 완료 후 홈 필터 기본값이 뒤집혀 있다. - - 현재: `preferredMoods[0]` -> 상단 필터, `travelStyles[0]` -> 무드 필터 - - 기대: 상단 필터는 추천 범위이므로 `전체`, 무드 필터는 `preferredMoods[0]` +- 음악추천 화면은 별도의 추천 범위 필터를 두지 않고 무드 필터만 사용한다. +- 온보딩 완료 후 무드 필터는 `preferredMoods[0]`을 사용한다. - 서버 API가 붙은 뒤에는 앱 실행 중 mock/server 전환을 제공하지 않는다. - Moment Log, Recap, Library, Player, Session 상태를 한 화면에서 빠르게 seed/clear할 방법이 없다. - 화면 이동 테스트가 탭/카드 탐색에 의존해 QA 속도가 느리다. @@ -23,13 +22,12 @@ 2. 테스트 매니저 패널 - 페이지 이동: 홈, 온보딩, 플레이리스트 상세, Recap 리스트, Recap 공유, 보관함, 마이, 카메라 - - 추천 조건: 홈 상단 필터, 무드 필터, 여행 모드 + - 추천 조건: 무드 필터, 여행 모드 - 위치/세션: 서울/부산 위치 seed, 위치 거부/불가 상태, 여행 시작/종료/리셋 - 데이터 seed: 샘플 Moment Log, 보관함 좋아요/저장, 현재 재생곡 - 데이터 clear: Moment Log, 보관함, 추천 이벤트, 플레이어 3. 미완성/기획 불일치 보정 - - 온보딩 완료 시 상단 필터는 `전체`, 무드 필터는 첫 선호 무드로 설정한다. - - 홈 필터와 무드 필터 개념을 계속 분리한다. + - 온보딩 완료 시 무드 필터는 첫 선호 무드로 설정한다. ## 주요 파일 diff --git a/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md b/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md index d70924a..425cc08 100644 --- a/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md +++ b/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md @@ -4,12 +4,13 @@ ## Design Direction -- Soundlog는 어두운 앱 배경 위에 음악 카드, 여행 상태, Recap을 겹겹이 올리는 구조입니다. +- Soundlog는 어두운 앱 배경 위에 페이지 제목, 라임 섹션 제목, 평평한 설정/정보 행이 이어지는 구조입니다. - 기본 화면은 `#070B1F`, 카드와 컨트롤은 `#080D18`, `#090E1B`, `#171B2A` 계열을 사용합니다. - 선택 상태와 핵심 CTA는 라임 `#B7E628`입니다. 라임은 "지금 선택됨", "여행 시작", "Recap 보기"처럼 행동이 분명한 곳에 씁니다. - 파랑은 일상 모드나 보조 정보, 골드는 특별한 음악/Recap 강조, 보라색은 브랜드 로고 중심으로 제한합니다. - 앱 기본 표면에는 장식용 그라데이션, 새 폰트, 과한 네온 효과를 추가하지 않습니다. 현재 앱의 Recap 이미지, 플레이어, 미디어 fallback처럼 콘텐츠를 표현하는 곳에서만 제한적으로 씁니다. -- UI는 둥근 칩, 둥근 카드, 높은 대비 텍스트, 44px 이상 터치 영역을 기본으로 합니다. +- 일반 정보와 설정은 카드로 감싸지 않습니다. 카드는 앨범 이미지, Recap 공유 결과물, 지도, 반복 미디어 항목, 모달처럼 경계가 실제로 필요한 경우에만 씁니다. +- 칩과 세그먼트 컨트롤은 옵션 선택에만 사용하고, 모든 터치 영역은 44px 이상을 유지합니다. ## Code Entry @@ -19,7 +20,10 @@ import { BrandLogo, Chip, IconButton, + PageHeader, Screen, + SectionTitle, + SettingsRow, SoundlogButton, SoundlogMetric, SoundlogSectionHeader, @@ -29,7 +33,7 @@ import { } from '@/design-system'; ``` -`src/design-system/index.ts`는 새 컴포넌트를 다시 만드는 곳이 아니라, 현재 RN 앱에 구현된 컴포넌트와 스타일 recipe를 일관되게 꺼내 쓰는 조립 지점입니다. 토큰 원천은 `src/constants/colors.ts`와 `tailwind.config.js`이고, 새 화면은 `SoundlogSurface`, `SoundlogButton`, `SoundlogSectionHeader`, `SoundlogMetric` 같은 조립 단위를 우선 사용합니다. +`src/design-system/index.ts`는 새 컴포넌트를 다시 만드는 곳이 아니라, 현재 RN 앱에 구현된 컴포넌트와 스타일 recipe를 일관되게 꺼내 쓰는 조립 지점입니다. 토큰 원천은 `src/constants/colors.ts`와 `tailwind.config.js`입니다. 새 일반 화면은 `PageHeader`, `SectionTitle`, `SettingsRow`, `IconButton`을 우선하고, `SoundlogSurface`와 `SoundlogMetric`은 기존 미디어·모달 화면 호환용으로만 사용합니다. ## Source Of Truth @@ -39,11 +43,16 @@ import { | -------------------------- | -------------------------------------------------------------- | | 기본 화면 배경과 safe area | `src/components/Screen.tsx` | | 텍스트 래퍼 | `src/components/AppText.tsx` | +| 기본 페이지 제목 | `src/components/PageHeader.tsx` | +| 라임 섹션 제목 | `src/components/SectionTitle.tsx` | +| 설정/정보 행 | `src/components/SettingsRow.tsx` | +| 평평한 화면 조립 기준 | `app/(tabs)/my.tsx` | | 필터/태그 칩 | `src/components/Chip.tsx` | | 상단 로고와 모드 세그먼트 | `src/components/home/HomeHeader.tsx` | | 추천 플레이리스트 카드 | `src/components/home/FeaturedPlaylistCard.tsx` | -| 여행 상태 카드 | `src/components/travel/TravelStatusCard.tsx` | -| Recap 리스트 카드 | `src/components/recap/RecapListCard.tsx` | +| 여행모드 화면 | `src/components/travel/TravelScreen.tsx` | +| 로그 격자 | `src/components/recap/RecapListScreen.tsx` | +| 지도 리캡 클러스터 | `src/components/travel/recap-map/RecapMapSection.tsx` | | 라이브 사운드맵 | `src/components/travel/live-sound-map/LiveSoundMapSection.tsx` | | 조립용 프리미티브 | `src/design-system/primitives.tsx` | @@ -69,10 +78,11 @@ import { | Use | Current Pattern | | --------------- | ------------------------------------------------ | -| Screen title | `text-[28px] font-semibold text-white` | +| Screen title | `text-[30px] font-semibold leading-9 text-white` | | Hero card title | `text-[30px] font-semibold leading-9 text-white` | -| Section title | `text-[22px] font-semibold text-white` | -| Card title | `text-[18px] font-bold leading-6 text-white` | +| Section title | `text-[20px] font-semibold text-soundlog-lime` | +| Row label | `text-[15px] font-medium text-white/88` | +| Media title | `text-[18px] font-semibold leading-6 text-white` | | Body | `text-sm leading-6 text-white/60` | | Caption | `text-xs text-white/45` | | Chip label | `text-[13px] font-medium` | @@ -84,17 +94,17 @@ import { | Use | Current Pattern | | ------------------------- | --------------------------------------- | | Screen horizontal padding | `px-5` | -| Section gap | `mt-4`, `mt-5`, `gap-4` 중심 | +| Section gap | `mt-7` 중심 | | Icon button | `h-11 w-11 rounded-full` | +| Settings row | `min-h-[52px] py-2` | | Default chip | `min-h-[38px] rounded-full px-5` | | Small chip | `min-h-[28px] rounded-full px-3` | -| Compact card | `rounded-[12px]` | -| Metric card | `rounded-[14px]`, `rounded-[18px]` | -| Mode control | `rounded-[20px]` + inner `rounded-full` | -| Active travel card | `rounded-[22px]` | -| Ended/idle travel cards | `rounded-[28px]`, `rounded-[30px]` | +| Repeated media card | `rounded-lg` | +| Input / primary command | `rounded-xl` | +| Segmented control | outer/inner `rounded-full` | +| Recap share artwork | template-specific | -새 화면의 카드 radius는 먼저 18, 20, 22 중에서 고릅니다. 화면을 대표하는 큰 상태 카드는 28 또는 30까지 허용합니다. +새 화면은 먼저 카드가 필요한지 판단합니다. 단순 제목, 설명, 값, 이동 액션은 `SettingsRow`로 표현하고 배경 카드나 테두리를 추가하지 않습니다. 카드가 필요한 반복 미디어 항목은 기본 `rounded-lg`를 사용하며, Recap 공유 결과물은 템플릿 표현을 위해 별도 반경을 허용합니다. ## Primitive Components @@ -141,6 +151,38 @@ import { ``` +### PageHeader + +일반 페이지의 30px 제목입니다. 뒤로가기나 우측 명령은 아이콘/텍스트 슬롯으로 전달합니다. + +```tsx +} + title="보관함" +/> +``` + +### SectionTitle + +페이지 안의 작업 단위를 20px 라임 제목으로 구분합니다. 일반 페이지에서 작은 eyebrow나 카드 제목을 새로 만들지 않습니다. + +```tsx + +``` + +### SettingsRow + +설정뿐 아니라 장소, 음악, 이동 거리, 오류 재시도처럼 `label + value/description + action` 구조인 정보를 표현합니다. + +```tsx + +``` + ### EmptyState 비어 있음, 권한 전, 데이터 없음 상태에 씁니다. 새 화면에서 빈 상태 문구와 CTA를 임의 스타일로 만들지 말고 이 컴포넌트를 먼저 씁니다. @@ -149,9 +191,9 @@ import { 프리미티브는 새 화면을 빠르게 조립하기 위한 얇은 컴포넌트입니다. 기존 화면을 강제로 갈아엎기 위한 레이어가 아니라, 앞으로 추가되는 화면에서 반복 className 복붙을 줄이기 위한 기준입니다. -### SoundlogSurface +### SoundlogSurface (Compatibility) -카드 표면을 만들 때 씁니다. 현재 RN 앱의 `bg-white/10`, `border-white/10`, 20~30 radius 패턴을 variant로 제공합니다. +반복 미디어 항목, 모달, 실제 도구 프레임처럼 경계가 필요한 기존 화면에서만 씁니다. 일반 페이지 섹션이나 상태 안내를 `hero`, `glass` 카드로 감싸지 말고 `SectionTitle + SettingsRow`로 표현합니다. 새 카드의 기본 반경은 `rounded-lg`입니다. ```tsx @@ -164,9 +206,9 @@ import { | Variant | Role | | ---------- | --------------------------------- | -| `base` | 기본 카드, `bg-soundlog-card` | -| `glass` | 여행/상태성 카드, `bg-white/10` | -| `hero` | 화면을 대표하는 큰 상태 카드 | +| `base` | 기존 반복 카드 호환 | +| `glass` | 기존 모달·도구 카드 호환 | +| `hero` | 신규 사용 금지 | | `media` | Recap/이미지 중심 카드 | | `elevated` | 세그먼트 컨트롤 같은 raised shell | @@ -201,15 +243,13 @@ CTA와 보조 액션에 씁니다. 모든 variant는 최소 44px 이상 터치 /> ``` -### SoundlogMetric +### SoundlogMetric (Compatibility) -여행 시간, 저장한 순간, 기록된 음악처럼 짧은 지표를 표현합니다. `TravelStatusCard`의 metric 패턴을 그대로 가져왔습니다. +기존 미디어 결과물 안의 짧은 지표만 표현합니다. 일반 페이지의 여행 시간, 리캡 개수, 위치 상태는 `SettingsRow`의 `rightText`를 사용합니다. ```tsx - - - - + + ``` ## Class Recipes @@ -229,13 +269,13 @@ import { AppText, soundlogRecipes } from '@/design-system'; | Recipe | Role | | ------------------------ | ------------------------------------- | | `screen.content` | `px-5 pt-4` 기본 화면 안쪽 여백 | -| `card.glass` | 여행/상태성 카드의 흰색 10% 표면 | +| `card.glass` | 기존 미디어/모달의 흰색 10% 표면 | | `card.media` | Recap처럼 이미지/미디어가 중심인 카드 | | `button.primary` | 56px 라임 CTA | | `button.icon` | 44px 원형 아이콘 버튼 | | `control.segmentedShell` | 홈 모드 세그먼트 바깥 컨테이너 | -| `travel.activeCard` | 여행 진행 중 카드 | -| `recap.listCard` | Recap 리스트 카드 | +| `travel.activeCard` | 기존 여행 진행 카드 호환 recipe | +| `recap.listCard` | 기존 Recap 카드 호환 recipe | 프리미티브로 표현 가능한 경우에는 recipe를 직접 붙이기보다 프리미티브를 먼저 씁니다. 예를 들어 버튼은 `soundlogRecipes.button.primary`를 직접 복붙하기보다 `SoundlogButton variant="primary"`를 우선 선택합니다. @@ -244,22 +284,23 @@ import { AppText, soundlogRecipes } from '@/design-system'; ### Standard App Screen ```tsx - - - - Library - - {children} + + + + + + + + ``` -### Segmented Mode Control +### Segmented Control 홈의 `HomeHeader` 패턴을 재사용합니다. -- 바깥 컨테이너: `rounded-[20px] bg-soundlog-elevated/80 p-2` -- 안쪽 트랙: `rounded-full bg-black/25 p-1` -- 선택 탭: 일상 모드는 blue, 여행 모드는 lime +- 바깥 트랙: `rounded-full border border-white/10 bg-white/[0.06] p-1` +- 선택 탭: 라임 또는 흰색 배경 - 선택 탭 text: `text-soundlog-inverse` ### Filter Bar @@ -274,23 +315,23 @@ import { AppText, soundlogRecipes } from '@/design-system'; `FeaturedPlaylistCard`와 `MoodRecommendationCard`가 기준입니다. -- 추천 플레이리스트: `h-[260px] w-[180px] rounded-[20px] border border-white/10 bg-soundlog-card p-4` -- 무드 카드: `h-[136px] w-[136px] rounded-[12px] p-5` +- 추천 플레이리스트: `h-[260px] w-[180px] rounded-lg border border-white/10 bg-soundlog-card p-4` +- 무드 카드: `h-[136px] w-[136px] rounded-lg p-4` - 제목은 1-2줄 제한, 설명은 1-2줄 제한을 기본으로 둡니다. ### Travel Status -`TravelStatusCard`를 기준으로 여행 진행 상태를 표현합니다. +새 여행모드 화면은 지도 위 상태와 CTA, 또는 `PageHeader -> SectionTitle -> SettingsRow` 구조로 표현합니다. `TravelStatusCard`와 `travel.activeCard`는 기존 여행 화면 호환용이며 새 일반 화면의 기준으로 사용하지 않습니다. -- active: 라임 border, 라임 status dot, compact metric, 음악 row, 44px CTA -- ended: 28 radius card, metric grid, 56px primary CTA -- idle: 30 radius card, 라임 원형 아이콘, 큰 시작 CTA +- active: 지도 위에는 작은 상태 표시와 44px CTA를 사용합니다. +- ended: 여행 요약은 평평한 정보 행과 라임 완료 CTA로 구성합니다. +- idle: 여행 시작은 지도 탭에서만 명확한 단일 CTA로 제공합니다. ### Recap And Share Recap은 앱에서 가장 감성적인 영역이지만, 기본 표면 규칙은 유지합니다. -- 리스트 카드는 이미지 또는 fallback 콘텐츠가 중심이고 텍스트는 흰색/60 이하 보조 텍스트로 정리합니다. +- 로그 격자와 지도 핀 목록은 이미지 또는 fallback 콘텐츠가 중심이고 텍스트는 흰색/60 이하 보조 텍스트로 정리합니다. - 공유 액션은 `ShareActionButton`, `ShareActionList`를 먼저 조립합니다. - Recap 미리보기는 `RecapPreviewCard`를 기준으로 템플릿만 바꿉니다. - 스포티파이 그린, 이미지 fallback, Recap 장식색처럼 콘텐츠나 외부 브랜드가 명확한 색은 해당 컴포넌트 안에서만 제한적으로 유지합니다. @@ -298,6 +339,7 @@ Recap은 앱에서 가장 감성적인 영역이지만, 기본 표면 규칙은 ## Do - `bg-soundlog-*`, `text-white/*`, `border-white/*` 토큰을 우선 사용합니다. +- 일반 페이지는 `PageHeader -> SectionTitle -> SettingsRow` 순서로 먼저 조립합니다. - 선택/확정/시작 액션에는 라임을 씁니다. - 아이콘 버튼은 `IconButton` 또는 같은 44px 원형 패턴을 유지합니다. - 긴 제목, 위치명, 곡명은 `numberOfLines`로 줄 수를 제한합니다. @@ -306,6 +348,7 @@ Recap은 앱에서 가장 감성적인 영역이지만, 기본 표면 규칙은 ## Don't - 일반 배경이나 카드에 새 장식 그라데이션을 만들지 않습니다. +- 단순 정보나 설정 행을 큰 둥근 카드로 감싸지 않습니다. - 새 폰트나 과한 letter spacing을 추가하지 않습니다. - 라임을 모든 장식 요소에 뿌리지 않습니다. 라임은 상태와 행동의 언어입니다. - 카드 안에 또 다른 큰 장식 카드를 중첩하지 않습니다. @@ -315,6 +358,7 @@ Recap은 앱에서 가장 감성적인 영역이지만, 기본 표면 규칙은 ## Design QA Checklist - 새 화면의 최상위가 `Screen`인지 확인합니다. +- 페이지 제목이 `PageHeader`, 섹션 제목이 `SectionTitle`, 행 정보가 `SettingsRow`를 재사용하는지 확인합니다. - 새 색상이 필요하면 먼저 `src/constants/colors.ts`, `tailwind.config.js`, `soundlogDesignTokens`에 들어갈 역할 이름을 정합니다. - 주요 CTA가 하나만 강하게 보이는지 확인합니다. - iPhone SE 폭에서도 버튼 텍스트와 카드 제목이 넘치지 않는지 확인합니다. diff --git a/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md b/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md index 29fba1a..dcb4faf 100644 --- a/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md +++ b/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md @@ -1,5 +1,7 @@ # Soundlog 로그인/소셜 로그인 구현 계획 +> **Superseded:** 이 문서의 소셜 로그인 설계는 과거 검토안이다. 현재 MVP 인증 기준은 Soundlog 자체 이메일/비밀번호이며, 실제 구현과 제품 명세는 `docs/implementation/2026-07-02-first-party-login-plan.md` 및 `docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md`를 따른다. + ## 1. 목표 Soundlog에 계정 로그인을 붙이는 인증 플로우를 설계한다. 현재 MVP 정책은 게스트 사용을 제공하지 않고, 온보딩 소개와 약관 확인을 제외한 주요 앱 기능을 로그인 후 사용할 수 있게 한다. diff --git a/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md b/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md index 495674f..0b67022 100644 --- a/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md +++ b/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md @@ -7,7 +7,7 @@ Figma 기준 화면은 `390 x 844` 모바일 화면에 맞춰 설계되어 있으며, 주요 구성은 다음과 같다. - 상단 상태 영역 -- 사용자 아바타 및 상단 필터 칩 +- 음악추천 페이지 헤더 - `Music Playlist` 대표 플레이리스트 캐러셀 - `나의 무드에 맞는 음악 추천` 섹션 - `Music Log` 섹션 @@ -104,44 +104,21 @@ RN에서는 다음 방식 중 하나를 사용한다. ### 역할 -사용자 프로필 진입과 상단 필터 칩을 제공한다. - -### Figma 기준 요소 - -- 좌측 32px 아바타 -- `All`, `Mood1`, `Mood2`, `Mood3` 칩 -- 우측 프로필 아바타가 화면 우상단에 크게 노출된 버전이 있으나, 실제 앱에서는 과밀해질 수 있음 +음악추천 화면의 제목과 현재 페이지 맥락을 제공한다. ### RN 구현 권장 상단 헤더는 Safe Area 안쪽에 둔다. ```txt -row - AvatarButton - Horizontal ChipList - Spacer - ProfileButton(optional) +PageHeader + title: 음악추천 ``` -### 상태 - -| 상태 | 설명 | -| ---------------------- | ------------------------------------------------------------- | -| `selectedTopFilter` | 전체, 근처, 지역 트렌드, 내 취향, 저장 많은 등 추천 범위 필터 | -| `user.profileImageUrl` | 사용자 프로필 이미지 | - ### 개발 메모 -- 초기 MVP에서는 `Mood1`, `Mood2` 대신 실제 서비스 언어로 바꾼다. -- 상단 필터는 무드가 아니라 추천 범위/소스 필터로 사용한다. -- 감정 키워드는 `나의 무드에 맞는 음악 추천` 섹션의 칩으로 분리한다. - -권장 문구: - -```txt -전체 / 근처 / 지역 트렌드 / 내 취향 / 저장 많은 -``` +- 추천 범위/소스용 상단 필터는 제공하지 않는다. +- 감정 키워드는 `무드 추천` 섹션의 칩에서만 선택한다. --- @@ -223,7 +200,7 @@ type FeaturedPlaylist = { ### Figma 기준 요소 - 타이틀: `나의 무드에 맞는 음악 추천` -- 칩: `전체`, `잔잔한`, `신나는`, `감성적인`, `청량한`, `활기찬`, `로컬한` +- 칩: `전체`, `잔잔한`, `신나는`, `시원한`, `설레는`, `감성적인` - 카드: 보라색, 골드, 다크 계열의 정사각형/라운드 카드 - 카드 텍스트: `Music Genre`, `Music`, `and let the city` @@ -234,7 +211,7 @@ MoodRecommendationSection은 앱 추천 경험의 중심이다. Figma의 더미 권장 칩: ```txt -전체 / 잔잔한 / 신나는 / 감성적인 / 청량한 / 활기찬 / 로컬한 +전체 / 잔잔한 / 신나는 / 시원한 / 설레는 / 감성적인 ``` 여행 모드와 분리되는 감정/분위기 후보: diff --git a/docs/frontend/RECAP_SHARE_PAGE_RN_BUILD_DOC.md b/docs/frontend/RECAP_SHARE_PAGE_RN_BUILD_DOC.md index 0b3e6f9..f25079f 100644 --- a/docs/frontend/RECAP_SHARE_PAGE_RN_BUILD_DOC.md +++ b/docs/frontend/RECAP_SHARE_PAGE_RN_BUILD_DOC.md @@ -1,5 +1,7 @@ # Soundlog 리캡 공유 페이지 제작 문서 +> **Current domain rule:** 제품의 Recap은 카메라 저장 1회이고, Log는 같은 여행모드 세션의 Recap 집합이다. Log 상세의 템플릿은 읽기 전용이며 지도에는 해당 Log의 Recap 핀과 세션 경로만 표시한다. 세부 기준은 [리캡·로그 도메인 기준](../product/RECAP_LOG_DOMAIN_MODEL.md)을 우선한다. + ## 1. 문서 목적 이 문서는 Figma의 `Music log` 화면 중 리캡 공유 페이지를 React Native 기반 Soundlog 앱에서 구현하기 위한 제작 기준서이다. diff --git a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md index 11fd53a..4084224 100644 --- a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md +++ b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md @@ -1,5 +1,7 @@ # Soundlog React Native 프론트엔드 기획 포인트 +> 리캡, 로그, 여행 세션, GPS 경로, 로그 상세 지도 작업은 [리캡·로그 도메인 기준](../product/RECAP_LOG_DOMAIN_MODEL.md)을 최우선으로 적용한다. + ## 1. 문서 목적 이 문서는 Soundlog를 React Native 앱으로 구현할 때 프론트엔드 관점에서 미리 정리해야 할 기획 포인트, 구현 리스크, 화면별 상태, 권한, 데이터 흐름, UX 의사결정 사항을 정리한다. @@ -16,10 +18,11 @@ Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔 핵심 기준은 다음과 같다. -- 홈에서 현재 장소, 추천 플레이리스트, 선택한 음악 상태, 순간 저장 버튼이 즉시 보여야 한다. +- 지도에서 현재 장소, 여행모드 상태, 주변 리캡 필터가 즉시 보여야 한다. +- 음악추천에서 현재 장소 기반 추천 플레이리스트와 선택한 음악 상태가 즉시 보여야 한다. - 여행 중 주요 액션은 1~2탭 안에 끝나야 한다. - 음악 추천 결과가 마음에 들지 않을 때 검색 화면으로 보내지 않고 무드 조정 버튼으로 해결해야 한다. -- 카메라 버튼은 항상 접근 가능한 중심 액션으로 유지한다. +- 리캡 작성은 하단 중앙 카메라 버튼과 지도 CTA처럼 기록 맥락이 분명한 진입점에서 접근 가능한 중심 액션으로 유지한다. ### 2.2 추천보다 “추천 가능한 상태 만들기”가 먼저 @@ -32,7 +35,7 @@ Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔 - 사용자 선택 무드 태그 - 현재 선택한 음악 - 좋아요, 저장, 다음/이전 이동, 외부 링크 열기, 무드 조정 피드백 -- 순간 저장 시점의 위치, 시간, 음악, 사진 +- 리캡 저장 시점의 위치, 시간, 음악, 사진 이 데이터가 끊기면 추천과 Recap 품질이 함께 떨어지므로, 입력 상태와 실패 상태를 화면에서 자연스럽게 처리해야 한다. @@ -72,22 +75,26 @@ MVP 단계에서는 Expo가 적합하다. 온보딩, 위치, 카메라, 이미 ### 4.1 Bottom Tab 구조 -MVP 기준 탭은 4개로 단순화한다. +2026년 7월 재정렬 기준 MVP 주요 목적지 탭은 4개로 단순화한다. 앱의 첫 화면은 지도이며, 카메라는 콘텐츠 탭이 아니라 하단 중앙의 리캡 작성 액션이다. 하단 바는 `지도 · 음악추천 · 중앙 카메라 액션 · 로그 · 마이`의 5개 위치로 보인다. | 탭 | 목적 | 주요 화면 | | --- | --- | --- | -| 홈 | 현재 여행과 추천의 중심 | 현재 위치, 태그, 추천 PL, 미니 플레이어 | -| Recap | 지난 여행 기록 확인 | Recap 리스트, Recap 상세 | -| 보관함 | 좋아요/저장 음악 확인 | 좋아요 음악, 저장 플레이리스트 | +| 지도 | 여행모드 진입과 주변 공개 리캡 탐색 | 지도, 필터 칩, 여행모드 CTA, 공개/내 리캡 핀 | +| 음악추천 | 일상모드 위치 기반 사운드트랙 추천 | 오늘의 사운드트랙, 무드 조정, 외부 음악 링크 | +| 로그 | 공개 로그 탐색과 내 로그 관리 | 다른사람 보기, 모든 사람 보기, 격자형 로그, 공개/비공개 토글 | | 마이 | 계정/권한 관리 | 취향 수정, 위치/카메라 권한, 설정 | -중앙 카메라 버튼은 탭과 별도로 Floating Action Button 형태로 둔다. 이 버튼은 홈뿐 아니라 Recap/보관함에서도 접근 가능하게 할지, 여행 세션 중에만 노출할지 정책이 필요하다. +카메라는 독립 콘텐츠 탭으로 두지 않는다. 진입점은 하단 중앙 카메라 버튼과 지도 탭의 `기록 남기기`처럼 기록 맥락이 분명한 CTA다. 로그 탭은 격자로 로그를 탐색하고 공개 범위를 관리하는 화면이며, 새 리캡 작성은 하단 중앙 카메라 버튼에서 시작한다. + +음악추천 탭은 장소 기반 추천만 담당한다. 여행모드 시작, 여행모드 전환, 여행 세션 종료 CTA는 제공하지 않으며, 여행모드 진입은 지도 탭에서만 수행한다. 권장안은 다음과 같다. -- 여행 세션 시작 전: 카메라 버튼 비활성 또는 “여행 시작” 유도 -- 여행 세션 중: 중앙 카메라 버튼 상시 노출 -- 여행 종료 후: Recap 생성 CTA로 전환 +- 여행 세션 시작 전: 지도는 `여행모드 시작` CTA를 보여준다. +- 여행 세션 중: 지도는 카메라 버튼형 `기록 남기기` CTA를 보여주고, 생성된 리캡을 현재 로그에 묶는다. +- 로그 탭: `다른사람 보기`와 `모든 사람 보기` 두 탭으로 구성하고, 모든 사람 보기에서 내 로그의 공개/비공개를 설정한다. +- 카메라 화면 하단에는 좌측 `갤러리`, 중앙 촬영 버튼, 우측 `추천사진` CTA를 제공한다. +- 공개/비공개 선택은 로그 탭의 `모든 사람 보기` 또는 상세 화면에서 처리한다. ### 4.2 화면 목록 @@ -96,14 +103,14 @@ MVP 기준 탭은 4개로 단순화한다. | P-01 | 온보딩 | 서비스 가치 전달 | | P-02 | 취향 설문 | 음악/여행 취향 수집 | | P-03 | 권한/취향 | 위치, 카메라, 취향 설정 | -| P-04 | 홈 / 현재 여행 | 추천과 외부 음악 링크의 메인 허브 | -| P-05 | 플레이리스트 추천 상세 | 추천 이유, 곡 목록, 무드 조정 | +| P-04 | 지도 / 여행모드 | 여행모드 시작, 주변 공개 리캡, 내 리캡 지도 확인 | +| P-05 | 음악 추천 / 일상모드 | 위치 기반 사운드트랙, 추천 이유, 곡 목록, 무드 조정 | | P-06 | 미니/풀 링크 패널 | 현재 선택 곡과 외부 음악 링크 확인 | -| P-07 | 순간 저장 카메라 | 사진 촬영 및 로그 생성 | -| P-08 | 순간 저장 확인 | 위치, 음악, 무드 확인 후 저장 | -| P-09 | Recap 리스트 | 여행별 기록 확인 | -| P-10 | Recap 상세 | 앨범/LP/필름/영상 결과 확인 | -| P-11 | 보관함 | 좋아요 음악, 저장 PL | +| P-07 | 리캡 작성 도구 | 사진 촬영, 갤러리, 추천사진 또는 사진 없이 리캡 생성 | +| P-08 | 리캡 저장 확인 | 장소, 위치, 음악, 무드, 촬영 시간, 템플릿 확인 후 저장 | +| P-09 | 로그 탐색 | 남의 공개 로그와 내 로그를 격자로 확인하고 상세로 진입 | +| P-10 | 로그 상세 | 시간순 리캡, 여행 경로, 요약, 공유용 결과 확인 및 공개 범위 설정 | +| P-11 | 저장 음악 | 좋아요 음악, 저장 PL. MVP에서는 음악추천/마이에서 진입 | | P-12 | 마이페이지 | 연동, 권한, 취향 관리 | --- @@ -146,7 +153,7 @@ Soundlog는 위치, 카메라, 사진 라이브러리 권한이 필요하다. 권장 순서는 다음과 같다. 1. 위치 권한: 현재 장소 기반 추천 설명 후 요청 -2. 카메라 권한: 순간 저장 기능 진입 시 요청 +2. 카메라 권한: 리캡 작성 기능 진입 시 요청 3. 사진 권한: Recap 이미지 저장 또는 업로드 시 요청 4. 외부 음악 앱 이동: 곡을 선택한 순간 검색 링크를 열고, 별도 계정 연동은 MVP 범위에서 제외 @@ -157,26 +164,32 @@ Soundlog는 위치, 카메라, 사진 라이브러리 권한이 필요하다. - “나중에 하기”를 허용하고, 앱이 제한 모드로 동작하게 한다. - 설정 앱으로 이동하는 딥링크를 제공한다. -### 5.4 홈 / 현재 여행 +### 5.4 지도 / 여행모드 -홈은 Soundlog의 핵심 화면이다. 추천 카드, 현재 위치, 상황 태그, 무드 태그, 미니 플레이어, 카메라 버튼이 한 화면에서 충돌하지 않아야 한다. +지도는 Soundlog의 첫 화면이다. 현재 위치, 여행모드 상태, 지도 필터, 공개/내 리캡 핀, 여행모드 CTA가 한 화면에서 충돌하지 않아야 한다. 프론트 고려사항은 다음과 같다. - 위치 조회 중, 위치 실패, 위치 권한 거부, 주변 관광지 없음 상태를 각각 분리한다. -- 상황 태그와 무드 태그는 추천 API 요청 파라미터와 직접 연결한다. -- 태그 변경 시 즉시 재요청할지, “적용” 버튼을 둘지 결정해야 한다. -- 추천 카드가 로딩 중일 때 기존 추천을 유지할지 스켈레톤으로 대체할지 정해야 한다. -- 현재 여행 세션이 없을 때는 “여행 시작” 상태를 명확히 보여준다. +- `장소 보기`, `전체 리캡`, `내 리캡` 필터가 마커와 CTA 표시를 직접 바꾼다. +- `전체 리캡`은 300m 이내 공개 리캡만 보여준다. +- `내 리캡`은 현재 위치 반경을 적용하지 않고 private/public 여부와 관계없이 좌표가 있는 내 전체 리캡을 보여준다. +- `전체 리캡`과 `내 리캡`은 현재 지도 축척에 맞춰 겹치는 좌표를 숫자 원형 클러스터로 묶는다. 반경은 줌 레벨별 거리표로 갱신해 작은 지도 이동에는 안정적으로 유지하고, 확대하면 자동으로 작은 묶음 또는 개별 핀으로 풀리며 축소하면 더 큰 지역 단위로 다시 묶인다. +- 클러스터를 누르면 해당 묶음에 포함된 리캡 목록을 열고, 목록의 상세 액션으로 읽기 전용 리캡 상세에 진입한다. +- 내 리캡 진입 시 특정 핀으로 확대하지 않고 방문 지역 전체를 도시 단위 이상의 축척으로 조망한다. +- 현재 여행 세션이 없을 때는 `여행모드 시작` 상태를 명확히 보여준다. +- 여행 세션 중에는 `기록 남기기`로 리캡 작성 도구를 연다. +- 여행 세션 중에는 foreground 위치 watch로 이동 좌표를 로컬에 누적하고, 디바운스된 클라이언트 푸시, 앱 상태 전환, 여행 종료 또는 Recap 생성 시 서버에 저장한다. 서버 폴링은 사용하지 않는다. +- 로그 지도는 저장된 routePoints가 있으면 그 좌표를 선으로 연결하고, routePoints가 없으면 리캡 촬영 위치 흐름으로 fallback한다. 권장 UI 우선순위는 다음과 같다. 1. 현재 위치/여행 상태 -2. 상황 태그 -3. 무드 태그 -4. 추천 플레이리스트 -5. 미니 플레이어 -6. 주변 장소 정보 +2. 지도 필터 +3. 지도 핀 +4. 여행모드 CTA +5. 선택 마커 프리뷰 +6. 미니 플레이어 ### 5.5 플레이리스트 추천 상세 @@ -202,32 +215,36 @@ MVP에서 미니/풀 링크 패널은 복잡한 음악 앱 수준의 재생 제 - 백그라운드 음원 제어, 인앱 음원 제공, 외부 플랫폼 상태 제어는 MVP 범위에서 제외한다. - MVP에서는 실제 음원 스트리밍이나 외부 플랫폼 재생 제어를 제공하지 않고, 선택한 곡 정보와 외부 음악 링크를 Soundlog 기록 UI에 유지한다. -### 5.7 순간 저장 카메라 +### 5.7 리캡 작성 도구 -카메라 버튼은 Soundlog의 핵심 액션이다. 저장 실패나 위치 누락이 발생해도 사용자의 순간을 잃지 않도록 설계해야 한다. +리캡 작성 도구는 Soundlog의 핵심 액션이다. 사진 촬영, 앨범 선택, 추천사진, 사진 없이 기록하기를 지원하며 저장 실패나 위치 누락이 발생해도 사용자의 순간을 잃지 않도록 설계해야 한다. 프론트 고려사항은 다음과 같다. - 촬영 시점의 위치, 시간, 현재 음악을 함께 캡처한다. +- 촬영 시간은 촬영 시각으로 고정해 표시하며 사용자가 수정하거나 이동하지 않는다. +- 별도 문구 스티커는 제공하지 않는다. +- 앨범/LP/필름/지도 템플릿 선택은 확인 화면 미리보기와 최종 저장 이미지에 즉시 반영한다. +- 지도 템플릿은 리캡의 내부 GPS 좌표를 중심으로 지도를 보여주고, 선택한 곡명과 아티스트를 해당 위치 핀에 결합해 표시한다. 위치가 없으면 지도형 폴백과 위치 없음 상태를 사용한다. - 위치 조회가 늦을 경우 마지막으로 확인된 위치를 임시 사용한다. - 음악 정보가 없을 경우 “음악 없음” 상태로 저장 가능해야 한다. - 업로드 실패 시 로컬 임시 저장 후 재시도 큐에 넣는다. - 촬영 후 확인 화면에서 장소/무드/음악을 수정할 수 있게 한다. -### 5.8 Recap 리스트 +### 5.8 로그 탐색 -Recap은 지도보다 리스트뷰 중심으로 단순하게 시작한다. +로그 탭은 여행모드에서 생성된 로그만 격자형 피드로 보여준다. 여행모드 밖에서 만든 독립 리캡은 로그로 위장해 노출하지 않는다. 프론트 고려사항은 다음과 같다. - 여행 날짜, 대표 장소, 대표 이미지, 대표 음악을 카드에 표시한다. -- Recap 생성 중, 생성 완료, 생성 실패 상태를 분리한다. +- 로그 동기화 중, 완료, 실패 상태를 분리한다. - 생성 실패 시 재시도 버튼을 제공한다. - 이미지가 많은 화면이므로 리스트 성능과 이미지 캐싱을 고려한다. -### 5.9 Recap 상세 +### 5.9 로그 상세 -Recap 상세는 공유 가능한 결과물을 보는 화면이다. 화면 비율과 저장/공유 UX가 중요하다. +로그 상세는 같은 여행 세션의 리캡과 이동 경로를 읽기 전용으로 회고하고 공유 결과물을 보는 화면이다. 화면 비율과 저장/공유 UX가 중요하다. 프론트 고려사항은 다음과 같다. @@ -236,9 +253,9 @@ Recap 상세는 공유 가능한 결과물을 보는 화면이다. 화면 비율 - 공유 시 이미지 파일 생성, 저장 권한, 공유 시트 호출을 고려한다. - 결과물 렌더링이 기기마다 깨지지 않도록 고정 비율 캔버스를 사용한다. -### 5.10 보관함 +### 5.10 저장 음악 -보관함은 좋아요한 음악과 저장한 플레이리스트를 확인하는 화면이다. +저장 음악은 좋아요한 음악과 저장한 플레이리스트를 확인하는 보조 화면이다. 2026년 7월 IA에서는 하단 탭이 아니며, 음악추천 또는 마이페이지에서 진입한다. 프론트 고려사항은 다음과 같다. @@ -279,16 +296,17 @@ type TravelSession = { ```ts type PlaceContext = { - poiId: string; - name: string; - address: string; - lat: number; - lng: number; - category: string; - contentType: 'tour' | 'culture' | 'festival' | 'leports' | 'food' | 'shopping' | 'stay'; + id: string; + title: string; + address?: string; + attribution?: string; + location?: GeoPoint; + category?: string; + contentType?: string; imageUrl?: string; overview?: string; - distance?: number; + distanceMeters?: number; + source: 'reverse-geocode' | 'seed' | 'tour-api' | 'user'; }; ``` @@ -311,9 +329,10 @@ type PlaylistRecommendation = { ### 6.4 MomentLog ```ts +// 구현의 MomentLog는 이전 명칭을 유지하지만 제품 의미는 단일 Recap이다. type MomentLog = { id: string; - sessionId: string; + sessionId: string | null; imageUri?: string; createdAt: string; location?: GeoPoint; @@ -326,10 +345,11 @@ type MomentLog = { }; ``` -### 6.5 Recap +### 6.5 Travel Log ```ts -type Recap = { +// 여행모드에서 같은 sessionId로 묶인 Recap들의 Log 요약이다. +type TravelLog = { id: string; sessionId: string; title: string; @@ -355,8 +375,9 @@ TanStack Query로 관리할 데이터는 다음과 같다. - 추천 플레이리스트 조회 - 플레이리스트 상세 - 좋아요/저장 목록 -- Recap 리스트 -- Recap 상세 +- 여행 Log 목록 +- 여행 Log 상세 +- 독립 Recap 캡처 및 지도 마커 - 사용자 프로필 서버 상태는 캐싱, 재시도, 로딩, 실패 처리가 중요하다. @@ -371,7 +392,7 @@ Zustand 또는 Jotai로 관리할 데이터는 다음과 같다. - 현재 위치 - 현재 선택한 곡 - 미니 플레이어 열림/닫힘 -- 임시 MomentLog +- 임시 Recap(`MomentLog` 레거시 타입) ### 7.3 로컬 영속 상태 @@ -380,8 +401,8 @@ Zustand 또는 Jotai로 관리할 데이터는 다음과 같다. - 온보딩 완료 여부 - 마지막 선택 모드/무드 - 최근 위치 -- 임시 저장된 순간 기록 -- 네트워크 실패 후 재시도해야 할 로그 +- 임시 저장된 Recap +- 네트워크 실패 후 재시도해야 할 Recap과 여행 Log --- @@ -405,16 +426,21 @@ Zustand 또는 Jotai로 관리할 데이터는 다음과 같다. 예시 API는 다음과 같다. ```txt -GET /v1/places/nearby?lat=&lng=&radius= +GET /v1/tour/nearby-places?lat=&lng=&radiusMeters= +GET /v1/tour/reverse-geocode?lat=&lng= GET /v1/recommendations/playlists?x=&y=&state=&mood= POST /v1/recommendation-events POST /v1/tracks/:trackId/like POST /v1/tracks/:trackId/save POST /v1/moment-logs +GET /v1/recap-captures +GET /v1/recap-markers GET /v1/recaps GET /v1/recaps/:id ``` +음악추천 화면의 장소 표시는 관광공사 주변 장소를 우선 사용한다. 결과가 없으면 서버 역지오코딩으로 한국어 지역명을 받고, 외부 위치 데이터의 출처를 함께 표시한다. GPS 좌표는 추천 계산과 지도 내부 데이터로만 사용하고 사용자용 장소명으로 문자열화하지 않는다. + --- ## 9. 권한 및 실패 상태 @@ -433,13 +459,13 @@ GET /v1/recaps/:id ### 9.2 카메라 권한 -카메라 권한이 없더라도 순간 저장 기능을 완전히 막기보다 사진 없는 로그 저장을 허용할 수 있다. +카메라 권한이 없더라도 리캡 작성 기능을 완전히 막기보다 사진 없는 Recap 저장을 허용할 수 있다. | 상태 | UI 대응 | | --- | --- | -| 권한 허용 | 촬영 후 로그 생성 | -| 권한 거부 | 설정 이동 안내, 사진 없는 로그 저장 옵션 제공 | -| 촬영 실패 | 재촬영, 임시 로그 저장 | +| 권한 허용 | 촬영 후 Recap 생성 | +| 권한 거부 | 설정 이동 안내, 사진 없는 Recap 저장 옵션 제공 | +| 촬영 실패 | 재촬영, 임시 Recap 저장 | ### 9.3 외부 음악 링크 실패 @@ -461,12 +487,12 @@ Soundlog는 이미지와 위치, 리스트가 많기 때문에 모바일 성능 - 관광공사 이미지는 원본이 클 수 있으므로 서버 또는 클라이언트에서 리사이징한다. - 리스트에는 썸네일 크기 이미지를 사용한다. -- Recap 상세에서만 고해상도 이미지를 로드한다. +- Log/Recap 상세에서만 고해상도 이미지를 로드한다. - 이미지 로딩 실패 fallback 이미지를 준비한다. ### 10.2 리스트 성능 -- Recap 리스트, 좋아요 음악 리스트는 FlashList 또는 FlatList 최적화를 적용한다. +- Log 격자, Recap 핀 목록, 좋아요 음악 리스트는 FlashList 또는 FlatList 최적화를 적용한다. - 카드 높이를 안정적으로 유지한다. - 무한 스크롤 또는 페이지네이션을 사용한다. @@ -491,7 +517,7 @@ Soundlog는 이미지와 위치, 리스트가 많기 때문에 모바일 성능 프론트 대응은 다음과 같다. - 마지막 추천 플레이리스트를 로컬 캐싱한다. -- 순간 저장 로그는 네트워크 실패 시 로컬에 저장한다. +- 리캡 저장 요청은 네트워크 실패 시 로컬 큐에 저장한다. - 네트워크 복구 후 자동 동기화한다. - Recap 생성 요청 실패 시 재시도 가능하게 한다. - 오프라인 상태에서는 “최근 추천 기반으로 계속 듣기”를 제공한다. @@ -543,7 +569,8 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 권장 방향은 다음과 같다. -- 홈은 기능 중심으로 간결하게 구성한다. +- 지도는 기능 중심으로 간결하게 구성한다. +- 음악추천은 장소 기반 추천과 외부 링크 액션이 바로 보이게 구성한다. - Recap은 감성적 비주얼을 강조한다. - 마이페이지는 명확하고 신뢰감 있게 구성한다. - 태그와 버튼은 한 손 조작을 고려해 충분한 터치 영역을 둔다. @@ -555,13 +582,13 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 ### 14.1 1차 MVP 1. 온보딩 및 취향 설문 -2. 홈 화면 구조 +2. 지도 탭과 여행모드 CTA 3. 위치 권한 및 현재 위치 상태 -4. 관광 모드/무드 태그 선택 -5. 추천 플레이리스트 카드 +4. 지도 필터 칩과 공개/내 리캡 핀 +5. 음악추천 탭과 추천 플레이리스트 카드 6. 미니 플레이어 UI -7. 카메라 버튼 및 순간 저장 플로우 -8. Recap 리스트/상세 더미 화면 +7. 리캡 작성 도구 및 저장 플로우 +8. 로그 격자와 공개 범위 화면 9. 마이페이지 연동 상태 화면 ### 14.2 2차 MVP @@ -569,7 +596,7 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 1. 실제 추천 API 연동 2. 좋아요/저장 API 연동 3. 여행 세션 생성/종료 -4. 순간 저장 로그 서버 동기화 +4. Recap 서버 동기화와 여행 Log 귀속 5. Recap 생성 상태 처리 6. 음악 플랫폼 딥링크 또는 SDK 연동 @@ -588,17 +615,17 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 ### 15.1 핵심 플로우 -- 신규 사용자가 온보딩을 완료하고 홈까지 도달할 수 있는가? +- 신규 사용자가 온보딩을 완료하고 지도 탭까지 도달할 수 있는가? - 위치 권한을 거부해도 앱을 둘러볼 수 있는가? - 현재 상황/무드 태그를 바꾸면 추천 상태가 바뀌는가? -- 카메라 버튼으로 순간 저장이 가능한가? +- 지도 CTA 등 기록 진입점으로 리캡 저장이 가능한가? - 음악 정보가 없는 상태에서도 로그를 저장할 수 있는가? - 여행 종료 후 Recap이 생성 중/완료/실패 상태로 구분되는가? ### 15.2 실패 상태 - 네트워크가 끊겨도 최근 추천을 볼 수 있는가? -- 순간 저장 업로드 실패 시 로컬에 남는가? +- Recap 업로드 실패 시 로컬 큐에 남는가? - 관광 이미지 로딩 실패 시 화면이 깨지지 않는가? - 외부 음악 링크를 열지 못했을 때 다시 시도 안내가 나오는가? - 위치 조회 실패 시 수동 지역 선택이 가능한가? @@ -606,8 +633,8 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 ### 15.3 모바일 UX - 한 손 조작이 가능한가? -- 카메라 버튼이 주요 화면에서 잘 보이는가? -- 하단 미니 플레이어가 탭바나 카메라 버튼과 겹치지 않는가? +- 지도 CTA와 로그 격자 탐색이 한 손으로 사용하기 쉬운가? +- 하단 미니 플레이어가 탭바와 겹치지 않는가? - 작은 화면에서 태그 텍스트가 잘리지 않는가? - 리스트 스크롤이 버벅이지 않는가? @@ -631,4 +658,4 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 Soundlog의 React Native 프론트엔드는 단순히 화면을 예쁘게 구현하는 것보다, 여행 중 끊기지 않는 경험을 만드는 것이 중요하다. 핵심은 사용자가 현재 위치와 상황을 직접 복잡하게 설명하지 않아도 앱이 추천 가능한 상태를 만들고, 사용자가 인상적인 순간을 놓치지 않도록 사진·장소·음악·시간을 안정적으로 묶어 저장하는 것이다. -따라서 MVP 프론트 개발은 홈, 위치 권한, 관광 모드/무드 태그, 추천 카드, 외부 링크용 미니 플레이어, 순간 저장, Recap 리스트를 중심으로 시작하는 것이 적절하다. 이후 실제 추천 API, 외부 음악 앱 링크 품질, 영상 Recap을 단계적으로 확장한다. +따라서 MVP 프론트 개발은 지도 탭, 위치 권한, 여행모드 CTA, 지도 필터/리캡 핀, 음악추천 탭, 외부 링크용 미니 플레이어, 리캡 작성 도구, 로그 격자와 상세를 중심으로 시작하는 것이 적절하다. 이후 실제 추천 API, 외부 음악 앱 링크 품질, 영상 리캡을 단계적으로 확장한다. diff --git a/docs/implementation/2026-06-24-ios-apple-kakao-login-plan.md b/docs/implementation/2026-06-24-ios-apple-kakao-login-plan.md index 82703ba..a8febbd 100644 --- a/docs/implementation/2026-06-24-ios-apple-kakao-login-plan.md +++ b/docs/implementation/2026-06-24-ios-apple-kakao-login-plan.md @@ -1,5 +1,7 @@ # iOS Apple + Kakao Social Login Plan +> **Archived proposal:** 2026-07-02 자체 이메일/비밀번호 로그인으로 MVP 정책이 변경되어 현재 구현 기준이 아니다. 소셜 로그인 재도입 시 참고용으로만 사용한다. + ## Goal iOS 앱에서 실제 소셜 로그인 진입점을 Apple과 Kakao 두 개로 제한한다. Google 버튼은 사용자 플로우에서 제거하고, 주요 앱 기능은 로그인 이후에만 사용할 수 있게 한다. 키가 없는 개발/프리뷰 환경에서는 dev fallback 또는 안내 상태로 앱이 깨지지 않아야 한다. diff --git a/docs/product/RECAP_LOG_DOMAIN_MODEL.md b/docs/product/RECAP_LOG_DOMAIN_MODEL.md new file mode 100644 index 0000000..bf9ec1b --- /dev/null +++ b/docs/product/RECAP_LOG_DOMAIN_MODEL.md @@ -0,0 +1,554 @@ +# Soundlog Recap / Log Domain Model + +> Status: **Canonical / 반드시 우선 적용** +> Last updated: 2026-07-13 +> Scope: Soundlog 모바일 앱, 서버 API, DB, 기획 문서, 화면 문구 + +이 문서는 Soundlog의 `Recap`과 `Log`를 정의하는 단일 기준 문서다. 다른 문서, 화면, 변수명, API 이름이 이 문서와 충돌하면 이 문서를 우선한다. + +## 1. 한 문장 정의 + +> **리캡은 한 순간의 기록이고, 같은 여행모드에서 만든 리캡들이 모이면 하나의 로그가 된다.** + +공식 수식은 다음과 같다. + +```text +Recap = 카메라 저장 1회로 생성한 단일 기록 +Log = 동일한 TravelSession에 속한 Recap 1개 이상의 시간순 집합 +TravelSession = 여행모드 시작부터 종료까지의 기록 컨테이너 + GPS 이동 경로 +``` + +`Recap`을 꾸민 결과물로만 부르거나, `Moment`를 별도 제품 단위로 노출하거나, 여행모드 밖의 리캡을 1개짜리 로그로 부르면 안 된다. + +## 2. 핵심 개념 + +### 2.1 Recap + +리캡은 사용자가 카메라 흐름에서 저장한 **한 번의 여행 순간**이다. + +예시: + +- DDP 앞에서 촬영한 사진 1장 +- 사용자가 입력한 장소명 `동대문디자인플라자` +- 촬영 시각 +- 내부적으로 저장한 GPS 위치 +- 당시 선택한 음악 +- 무드 +- 촬영 후 선택한 표현 템플릿 + +리캡은 다음 특징을 가진다. + +| 규칙 | 설명 | +| ------------- | ------------------------------------------------------------------------- | +| 생성 단위 | 카메라 저장 1회당 리캡 1개 | +| 생성 위치 | 카메라 촬영/갤러리/추천사진/사진 없이 기록하기 이후의 저장 확인 화면 | +| 템플릿 선택 | 촬영 후 저장 확인 단계에서만 선택 | +| 여행모드 연관 | 여행모드 ON이면 현재 여행 세션에 연결, OFF이면 독립 리캡 | +| 위치 | GPS 좌표는 내부 데이터로 저장하며 화면에 좌표 문자열로 표시하지 않음 | +| 장소명 | 사용자가 직접 입력하는 표시 이름. GPS 좌표나 주소와 다른 값 | +| 공개 범위 | 기본은 `private`; 위치가 있는 경우에만 `public` 전환 가능 | +| 수정 책임 | 리캡 생성 단계에서 내용을 결정하고, 로그 상세는 이를 읽기 전용으로 보여줌 | + +사진, 음악, GPS 위치, 장소명이 일부 없어도 리캡 저장 자체는 가능하다. 다만 위치가 없는 리캡은 공개 지도 핀으로 전환할 수 없다. + +### 2.2 Log + +로그는 **하나의 여행모드 세션에서 만든 리캡들의 집합**이다. + +로그는 사용자가 직접 빈 문서로 만드는 콘텐츠가 아니다. 여행모드를 시작하면 여행 세션이 열리고, 그 세션에서 리캡을 저장할 때 로그의 구성원이 된다. 여행모드를 종료하면 해당 세션의 리캡과 경로가 하나의 로그로 확정된다. + +| 규칙 | 설명 | +| -------------- | ------------------------------------------------------------ | +| 식별 기준 | `TravelSession.id` 또는 그와 1:1 대응하는 `sessionId` | +| 구성원 | 같은 `sessionId`를 가진 리캡만 포함 | +| 정렬 | 촬영 시각 오름차순 | +| 최소 구성 | 리캡 1개 이상. 여행 중 리캡이 1개뿐이어도 로그 | +| 빈 여행 | 리캡이 0개인 여행 세션은 로그 탭에 노출하지 않음 | +| 독립 리캡 | 여행모드 밖에서 만든 리캡은 로그에 포함하지 않음 | +| 자동 병합 금지 | 장소, 날짜, 거리, 음악이 같아도 다른 여행 세션은 합치지 않음 | +| 종료 후 표현 | 로그 상세는 읽기 전용 회고 화면이며 템플릿 편집기가 아님 | + +따라서 아래 표현은 틀리다. + +```text +잘못된 표현: 여행모드 밖의 리캡은 1개짜리 로그다. +올바른 표현: 여행모드 밖의 리캡은 독립 리캡이며 어떤 로그에도 속하지 않는다. +``` + +### 2.3 TravelSession + +여행 세션은 로그를 만드는 실행 컨텍스트다. + +```text +idle -> 여행모드 시작 -> active -> 여행모드 종료 -> ended +``` + +여행 세션이 담당하는 데이터: + +- 시작 시각과 종료 시각 +- 여행 상태와 무드 +- 세션 중 생성한 리캡 ID 목록 +- 세션 중 수집한 GPS 경로점 `routePoints` +- 서버 동기화 상태 +- 최종 로그 ID + +여행 세션과 로그는 개념상 구분한다. + +- 여행 세션: 이동 중인 상태와 수집 과정 +- 로그: 여행 종료 후 다시 보는 결과 + +### 2.4 RoutePoint + +경로점은 여행모드 중 수집한 GPS 샘플이다. IP 주소로 위치를 계산하지 않는다. + +```ts +type RoutePoint = { + lat: number; + lng: number; + recordedAt: string; + accuracyMeters?: number; +}; +``` + +경로점은 리캡이 아니다. 사용자가 카메라를 누르지 않아도 여행모드가 활성화되어 있으면 이동 경로를 만들기 위해 수집될 수 있다. + +현재 MVP 구현은 앱이 foreground에 있는 동안 GPS 경로를 기록한다. background 위치 추적은 권한, 배터리, 스토어 심사 정책을 별도로 확정한 뒤 추가한다. + +## 3. 관계와 불변 규칙 + +```mermaid +erDiagram + USER ||--o{ TRAVEL_SESSION : starts + USER ||--o{ RECAP : creates + TRAVEL_SESSION ||--o{ RECAP : contains + TRAVEL_SESSION ||--o{ ROUTE_POINT : records + TRAVEL_SESSION ||--o| LOG : finalizes_as + LOG ||--|{ RECAP : consists_of + + RECAP { + string id + string userId + string sessionId nullable + string placeName nullable + number lat nullable + number lng nullable + string recordedAt + string templateId + string visibility + } + + LOG { + string id + string userId + string sessionId + string startedAt + string endedAt + string visibility + } +``` + +반드시 지켜야 하는 불변 규칙: + +1. 리캡 하나는 최대 하나의 여행 세션에만 속한다. +2. `sessionId`가 없는 리캡은 독립 리캡이며 로그 구성원이 아니다. +3. `sessionId`가 있는 리캡은 해당 세션의 로그 구성원이다. +4. 로그 하나는 정확히 하나의 여행 세션과 대응한다. +5. 서로 다른 `sessionId`의 리캡을 장소나 날짜가 가깝다는 이유로 합치지 않는다. +6. 여행 세션에 리캡이 1개만 있어도 유효한 로그다. +7. 여행 세션에 리캡이 0개면 로그 탭에 표시할 로그를 생성하지 않는다. +8. 로그의 리캡 개수는 현재 남아 있는 구성원 수와 항상 일치해야 한다. +9. 로그 지도는 그 로그에 속한 리캡 위치만 핀으로 표시한다. +10. 로그 경로선은 그 로그의 여행 세션에서 수집한 경로점만 연결한다. +11. 다른 여행 로그, 주변 공개 리캡, 관광지 추천 핀은 로그 상세 지도에 섞지 않는다. +12. 로그 상세에서 리캡 템플릿을 수정하지 않는다. + +## 4. 생성 흐름 + +### 4.1 여행모드 밖에서 리캡 생성 + +```mermaid +flowchart LR + A["중앙 카메라 버튼"] --> B["촬영 / 갤러리 / 추천사진"] + B --> C["저장 확인"] + C --> D["장소명·음악·무드 확인"] + D --> E["표현 템플릿 선택"] + E --> F["독립 Recap 저장"] +``` + +결과: + +- 리캡은 생성된다. +- `sessionId`는 없다. +- 로그는 생성되지 않는다. +- 로그 탭에는 나타나지 않는다. +- 공개 설정 시 메인 지도의 리캡 핀으로는 나타날 수 있다. + +### 4.2 여행모드에서 리캡 생성 + +```mermaid +flowchart LR + A["여행모드 시작"] --> B["TravelSession active"] + B --> C["GPS 경로 수집"] + C --> D["카메라로 Recap 저장"] + D --> E["현재 sessionId 연결"] + E --> F["추가 Recap과 경로 누적"] + F --> G["여행모드 종료"] + G --> H["Log 확정"] +``` + +결과: + +- 세션에서 만든 모든 리캡은 같은 `sessionId`를 가진다. +- 각 리캡은 독립적인 사진, 장소, 음악, 시각, 템플릿을 유지한다. +- 로그는 리캡을 복제하지 않고 구성원 관계로 묶는다. +- GPS 경로는 세션 단위로 저장한다. + +## 5. 로그 탭과 로그 상세 + +### 5.1 로그 탭 + +로그 탭은 여행 로그를 탐색하는 화면이다. 독립 리캡 보관함이 아니다. + +로그 카드 표시 조건: + +```text +sessionId가 있고 + 해당 세션의 리캡이 1개 이상인 경우 +``` + +카드 정보: + +- 로그 대표 이미지 +- 대표 장소명 +- 리캡 개수 +- 여행 날짜 또는 시작 시각 +- 공개/비공개 상태 + +`다른사람 보기`는 다른 사용자가 공개한 여행 로그만 보여준다. `모든 사람 보기`는 내가 볼 권한이 있는 여행 로그를 합쳐 보여준다. 어떤 탭에서도 여행모드 밖의 독립 리캡을 로그 카드로 위장해서 보여주면 안 된다. + +### 5.2 로그 상세 + +로그 상세의 목적은 편집이 아니라 여행 회고다. + +표시 순서: + +1. 로그 제목, 여행 날짜, 공개 범위 +2. 시간순 사운드로그: 로그에 속한 리캡을 한 장씩 감상 +3. GPS 여행 로드맵 +4. 이동 시간, 이동 거리, 방문 장소 요약 +5. 사용한 음악 요약 +6. 고정 `SOUNDLOG` 카드 형식의 공유용 결과 +7. 이미지 저장과 OS 공유 + +로그 상세에서 제거해야 하는 기능: + +- 표현 템플릿 변경 +- 음악 스티커 위치 편집 +- 시간/문구 드래그 편집 +- 새 리캡 만들기 CTA +- 주변 사람 또는 주변 관광지 핀 + +템플릿과 꾸미기 값은 리캡 생성 단계에서 확정하고, 로그 상세에서는 저장된 결과를 읽기 전용으로 렌더링한다. + +`공유용 리캡`은 리캡에 저장된 `album`, `LP`, `film`, `map` 템플릿과 무관하게 항상 기존 `SOUNDLOG` 카드 형식으로 만든다. 여행 로그에서는 가장 최근 리캡을 대표 카드로 사용하고, 독립 리캡에서는 해당 리캡 자체를 사용한다. + +## 6. 로그 지도 규칙 + +로그 지도는 메인 지도와 데이터 목적이 다르다. + +| 지도 | 목적 | 허용 핀 | +| -------------- | ------------------------------------------------ | ---------------------------- | +| 메인 지도 | 주변 공개 리캡 발견, 내 리캡 탐색, 여행모드 시작 | 필터에 맞는 주변/내 리캡 핀 | +| 로그 상세 지도 | 특정 여행을 시간과 공간으로 회고 | **현재 로그 구성 리캡 핀만** | + +메인 지도의 `전체 리캡`과 `내 리캡`은 현재 줌 레벨에서 시각적으로 겹치는 가까운 좌표를 숫자 원형 클러스터로 묶는다. 축소할수록 더 넓은 범위를 묶고 확대할수록 개별 핀으로 분리하며, 클러스터 선택 시 포함된 리캡 목록과 각 리캡의 읽기 전용 상세 진입점을 제공한다. 이 클러스터는 지도 표시 방식일 뿐 서로 다른 리캡이나 로그의 소유 관계를 병합하지 않는다. + +로그 지도 렌더링 규칙: + +1. 로그의 `routePoints`가 2개 이상이면 시간순으로 선을 연결한다. +2. 로그에 속한 리캡 중 GPS 위치가 있는 리캡만 번호 핀으로 표시한다. +3. 번호는 리캡 촬영 시각 오름차순으로 `1, 2, 3...`을 부여한다. +4. 핀을 누르면 해당 리캡의 사진, 장소명, 음악, 시각을 미리보기로 보여준다. +5. 미리보기의 상세 액션은 해당 리캡 감상 화면으로 이동한다. +6. 다른 로그의 리캡, 주변 공개 리캡, 현재 관광지, 추천 장소는 표시하지 않는다. +7. 경로 데이터가 없고 리캡 위치만 있으면 리캡 위치를 시간순으로 연결한 보조 경로를 사용한다. +8. 경로와 리캡 위치가 모두 없으면 빈 지도를 억지로 만들지 않고 위치 기록 없음 상태를 보여준다. +9. 좌표 숫자를 장소명 대신 사용자에게 노출하지 않는다. + +예시: + +```text +서울 여행 Log A + Recap 1: 광화문, 10:12, GPS 있음 -> 지도 핀 1 + Recap 2: 익선동, 13:40, GPS 있음 -> 지도 핀 2 + Recap 3: 장소명만 입력, GPS 없음 -> 사운드로그에는 표시, 지도 핀 없음 + +부산 여행 Log B + Recap 4: 광안리, 20:05, GPS 있음 -> Log A 지도에는 절대 표시하지 않음 +``` + +## 7. 위치, 장소명, 주소의 구분 + +세 값은 서로 대체하지 않는다. + +| 값 | 입력 주체 | 용도 | 사용자 노출 | +| -------- | ------------------------ | ------------------------ | ------------------------ | +| GPS 위치 | 기기 위치 센서 | 지도 핀, 경로, 거리 계산 | 좌표 숫자는 숨김 | +| 장소명 | 사용자 | 기억을 위한 이름 | 리캡/로그에 표시 | +| 주소 | 역지오코딩 또는 관광 API | 검색과 위치 설명 | 주소 UI가 있을 때만 표시 | + +금지 사례: + +```text +장소명 = "현재 위치 37.786, -122.406" +장소명 = IP 주소 +주소 필드에 사용자 장소 별칭 저장 +``` + +장소명이 비어 있으면 `장소 미입력`처럼 명시적인 빈 상태를 사용한다. GPS 좌표를 문자열로 변환해 채우지 않는다. + +## 8. 공개 범위와 개인정보 + +기본 원칙은 모든 기록을 private로 저장하고 사용자가 명시적으로 public으로 바꾸는 것이다. + +소유자 로그 상세: + +- 로그의 모든 리캡을 볼 수 있다. +- 저장된 전체 GPS 경로를 볼 수 있다. +- private/public 리캡 핀을 모두 볼 수 있다. + +다른 사용자 로그 상세: + +- 공개된 로그와 공개 허용된 리캡만 볼 수 있다. +- 정확한 전체 이동 경로 `routePoints`는 제공하지 않는다. +- 서버가 허용한 공개 리캡 위치만 핀으로 표시한다. +- 비공개 리캡의 존재, 위치, 음악, 사진을 추론할 수 있는 개수 정보도 노출하지 않는다. + +서버는 로그 상세 응답을 만들 때 요청자가 소유자인지 확인하고 경로 데이터를 제한해야 한다. 클라이언트에서 숨기는 것만으로 개인정보 보호를 구현하면 안 된다. + +## 9. 삭제와 수정 규칙 + +### 리캡 삭제 + +- 리캡을 삭제하면 소속 로그에서도 즉시 제외한다. +- 삭제 후 로그의 리캡이 1개 남아도 로그는 유지한다. +- 마지막 리캡을 삭제해 0개가 되면 해당 로그는 로그 탭에서 숨긴다. +- 여행 세션 경로 데이터의 보존/삭제는 계정 데이터 정책에 따른다. + +### 리캡 수정 + +- 장소명, 음악 등 리캡 자체 데이터 수정은 해당 리캡에만 반영한다. +- 수정된 리캡이 로그의 대표 리캡이면 로그 카드의 대표 정보도 다시 계산한다. +- 다른 여행 로그의 대표 정보에는 영향을 주지 않는다. + +### 로그 수정 + +- 로그 제목과 공개 범위 같은 로그 메타데이터만 수정할 수 있다. +- 로그 상세에서 개별 리캡의 표현 템플릿을 다시 고르지 않는다. +- 세션이 다른 리캡을 임의로 옮기거나 합치는 기능은 별도 기획 없이는 제공하지 않는다. + +## 10. 오프라인과 동기화 + +리캡은 네트워크가 없어도 로컬에 먼저 저장한다. + +```text +local/pending -> synced + -> failed -> retry -> synced +``` + +동기화 규칙: + +1. 로컬 리캡 ID를 idempotency key로 사용해 중복 생성을 막는다. +2. 여행모드 리캡의 `sessionId`를 재시도 과정에서도 잃지 않는다. +3. 표현 템플릿과 공개 범위도 재시도 payload에 보존한다. +4. 여행 종료 시 pending 리캡을 먼저 동기화하고 로그 생성을 시도한다. +5. 일부 리캡 동기화가 실패하면 로컬 로그를 우선 보여주며 구성원을 버리지 않는다. +6. 앱 재실행 후에도 활성 여행 세션과 GPS 경로를 복구한다. +7. 여행 종료 로그 생성 요청도 별도 영속 큐에 저장하고, 리캡 업로드가 모두 끝난 뒤 자동 재시도한다. +8. 오프라인 로컬 세션은 서버가 소유 리캡을 확인한 뒤 종료 세션으로 복구하므로 로컬 `sessionId`를 바꾸거나 버리지 않는다. + +## 11. 제품 용어와 레거시 코드 매핑 + +현재 코드와 서버에는 과거 기획에서 유래한 이름이 남아 있다. 새 기능은 아래 의미를 기준으로 이해해야 한다. + +| 제품 용어 | 현재 코드/서버 이름 | 실제 의미 | +| ------------- | ---------------------------------------------------------- | -------------------------------------- | +| Recap | `MomentLog`, `RecapShareMoment`, `/v1/recap-captures` | 카메라 저장 1회로 만든 단일 리캡 원본 | +| Log | 서버 `Recap`, `RecapShare`, `MomentLogGroup`, `/v1/recaps` | 여행 세션의 리캡 집합과 공유/회고 결과 | +| TravelSession | `TravelSession`, `sessionId` | 로그의 소속 경계와 여행 상태 | +| GPS 경로 | `RoutePoint[]`, `routePoints` | 여행모드 중 기록한 이동 경로 | + +중요: + +- 코드의 `MomentLog`를 사용자에게 `Moment` 또는 `모먼트 로그`로 노출하지 않는다. +- 서버의 `Recap` 모델이 제품의 여행 `Log` 역할도 하고 있다는 점을 인지한다. +- 대규모 이름 변경은 Prisma 마이그레이션, API 호환성, 앱 로컬 저장소 마이그레이션을 포함한 별도 작업으로 진행한다. +- 이름이 레거시라고 해서 제품 규칙을 레거시 의미로 구현하지 않는다. + +## 12. 권장 데이터 계약 + +제품 의미를 명시한 논리 모델은 다음과 같다. + +```ts +type Recap = { + id: string; + userId: string; + sessionId?: string; + photoUrl?: string; + location?: { lat: number; lng: number }; + placeName?: string; + recordedAt: string; + track?: Track; + moodTags: MoodTag[]; + note?: string; + templateId: "album" | "lp" | "film" | "map"; + visibility: "private" | "public"; +}; + +type TravelLog = { + id: string; + userId: string; + sessionId: string; + recapIds: string[]; + routePoints: RoutePoint[]; + startedAt: string; + endedAt: string; + title?: string; + visibility: "private" | "public"; +}; +``` + +구현에서 집계 필드 `recapCount`, 대표 이미지, 대표 장소, 대표 음악을 저장할 수 있지만, 원본 리캡 목록과 불일치하지 않도록 서버에서 갱신해야 한다. + +## 13. 화면 문구 규칙 + +사용자에게 사용하는 용어: + +| 상황 | 권장 문구 | +| ------------ | ---------------------------------------------- | +| 카메라 저장 | `리캡 저장하기` 또는 `이 순간 리캡으로 남기기` | +| 여행 중 누적 | `이번 여행에 리캡이 3개 쌓였어요` | +| 여행 종료 | `여행 로그가 완성됐어요` | +| 로그 카드 | `3개 리캡 · 서울 여행` | +| 로그 상세 | `여행 사운드로그`, `GPS 여행 경로` | +| 위치 없음 | `위치 기록 없음` | +| 장소 없음 | `장소 미입력` | + +사용자 화면에서 피해야 하는 용어: + +- Moment +- MomentLog +- 모먼트 로그 +- 좌표를 포함한 `현재 위치 37.123, 127.123` +- 여행모드 밖 리캡을 가리키는 `1개짜리 로그` + +## 14. 구현 체크리스트 + +리캡/로그 관련 작업은 아래 항목을 모두 확인한다. + +### 생성 + +- [ ] 카메라 저장 1회가 리캡 1개를 만드는가? +- [ ] 여행모드 ON이면 현재 `sessionId`가 저장되는가? +- [ ] 여행모드 OFF이면 `sessionId` 없이 독립 리캡이 되는가? +- [ ] 템플릿 선택이 촬영 후 저장 확인 화면에만 있는가? + +### 목록 + +- [ ] 로그 탭이 `sessionId`가 있는 여행 로그만 보여주는가? +- [ ] 리캡 1개짜리 여행 세션도 로그로 보여주는가? +- [ ] 독립 리캡을 로그 카드로 보여주지 않는가? +- [ ] 공유용 리캡이 템플릿과 무관하게 고정 `SOUNDLOG` 카드로 보이는가? + +### 상세 지도 + +- [ ] 현재 로그의 리캡만 번호 핀으로 보여주는가? +- [ ] 다른 로그와 주변 공개 핀이 섞이지 않는가? +- [ ] 현재 로그의 `routePoints`만 경로선으로 연결하는가? +- [ ] 경로가 없을 때 리캡 위치 fallback 또는 빈 상태가 동작하는가? +- [ ] 좌표 숫자를 장소명으로 표시하지 않는가? +- [ ] 같은 위치의 리캡이 묶음 핀으로 표시되고, 핀 선택 시 미리보기 목록과 읽기 전용 상세를 열 수 있는가? + +### 개인정보 + +- [ ] 소유자가 아닌 사용자에게 전체 경로를 서버가 반환하지 않는가? +- [ ] private 리캡의 위치와 존재가 공개 응답에 섞이지 않는가? +- [ ] public 전환 전에 위치 존재 여부를 검증하는가? + +### 동기화 + +- [ ] 오프라인 재시도에서 `sessionId`, `templateId`, 공개 범위를 보존하는가? +- [ ] 여행 종료 시 리캡과 경로를 잃지 않는가? +- [ ] 중복 요청이 중복 리캡/로그를 만들지 않는가? +- [ ] 앱 재실행 후 남은 여행 종료 로그 생성 큐가 자동 재시도되는가? + +## 15. 대표 수용 시나리오 + +### 시나리오 A: 여행 중 리캡 3개 + +1. 사용자가 여행모드를 시작한다. +2. GPS 경로 수집이 시작된다. +3. 광화문, 익선동, 남산에서 리캡을 각각 저장한다. +4. 세 리캡은 같은 `sessionId`를 가진다. +5. 여행모드를 종료한다. +6. 로그 탭에 리캡 3개짜리 여행 로그가 하나 나타난다. +7. 상세 지도에는 세 리캡 핀과 해당 세션의 이동 경로만 보인다. + +### 시나리오 B: 여행 중 리캡 1개 + +1. 사용자가 여행모드를 시작한다. +2. 리캡 하나를 저장하고 여행을 종료한다. +3. 리캡 개수가 하나여도 로그 탭에 여행 로그 하나가 나타난다. + +### 시나리오 C: 여행모드 밖 리캡 + +1. 여행모드를 시작하지 않고 중앙 카메라 버튼을 누른다. +2. 리캡을 저장한다. +3. 리캡은 독립 리캡으로 저장된다. +4. 로그 탭에는 나타나지 않는다. +5. 공개한 경우 메인 지도의 공개 리캡 핀으로는 나타날 수 있다. + +### 시나리오 D: 다른 여행 두 번 + +1. 오전 서울 여행에서 리캡 2개를 저장하고 종료한다. +2. 오후 새 여행모드를 시작해 같은 장소에서 리캡 1개를 저장한다. +3. 장소와 날짜가 같아도 `sessionId`가 다르므로 로그는 두 개다. +4. 각 로그 상세 지도에는 자기 세션의 리캡과 경로만 보인다. + +## 16. 변경 결정 기록 + +2026-07-13부터 Soundlog의 제품 용어는 다음으로 확정한다. + +```text +과거: Moment가 모여 Log가 되고, Recap은 Log를 꾸민 결과물 +현재: Recap이 모여 Log가 되며, Log는 여행모드에서 만든 Recap의 집합 +``` + +과거 API/DB 이름은 호환성을 위해 당분간 유지할 수 있지만, 모든 신규 기획과 UI는 현재 정의를 따라야 한다. + +## 17. 현재 구현 기준점 + +다른 작업 세션은 아래 파일에서 현재 구현을 확인한다. + +| 책임 | 프론트엔드 기준 파일 | +| ------------------------------------ | ----------------------------------------------------------- | +| 카메라 촬영과 Recap 로컬 저장 | `src/components/moment-capture/MomentCaptureScreen.tsx` | +| 촬영 후 템플릿/장소/공개 범위 설정 | `src/components/moment-capture/MomentReviewPanel.tsx` | +| Recap 오프라인 큐와 동기화 | `src/store/momentLogStore.ts`, `src/utils/momentLogSync.ts` | +| 여행 종료 Log 생성 영속 큐 | `src/store/travelLogSyncStore.ts`, `src/utils/travelLogSync.ts` | +| 여행 세션과 경로 로컬 영속화 | `src/store/travelSessionStore.ts` | +| foreground GPS 경로 수집 | `src/hooks/useTravelRouteTracking.ts` | +| `sessionId` 기반 Log 그룹 생성 | `src/utils/recapMappers.ts` | +| 여행 Log만 보여주는 격자 목록 | `src/components/recap/RecapListScreen.tsx` | +| Log 상세과 독립 Recap 상세 분기 | `src/components/recap-share/RecapShareScreen.tsx` | +| 현재 Log Recap 핀과 세션 경로 렌더링 | `src/components/recap-share/RecapRouteMap.tsx` | + +서버 기준점: + +| 책임 | 서버 기준 파일 | +| ---------------------------- | -------------------------------------------------- | +| Recap 원본과 Log 집계 서비스 | `SoundLogServer/src/services/soundlog.service.ts` | +| Prisma Recap/Log 레거시 모델 | `SoundLogServer/prisma/models/recap.prisma` | +| 여행 세션 모델 | `SoundLogServer/prisma/models/travel.prisma` | +| 서버 전용 도메인 계약 | `SoundLogServer/docs/recap-log-domain-contract.md` | + +경로 추적의 현재 보장 범위는 foreground다. background 추적을 구현하기 전에는 문서, QA, 사용자 문구에서 앱을 종료해도 계속 기록된다고 약속하면 안 된다. diff --git a/docs/product/SOUNDLOG_APP_PLANNING.md b/docs/product/SOUNDLOG_APP_PLANNING.md index 0813937..ff0e92a 100644 --- a/docs/product/SOUNDLOG_APP_PLANNING.md +++ b/docs/product/SOUNDLOG_APP_PLANNING.md @@ -1,10 +1,12 @@ # Soundlog 앱 기획 문서 +> **현재 용어 우선순위:** 리캡, 로그, 여행 세션, GPS 경로, 로그 지도에 관한 정의는 [리캡·로그 도메인 기준](RECAP_LOG_DOMAIN_MODEL.md)을 최우선으로 적용한다. 이 문서의 과거 `Moment`, `Music Log`, `Recap` 표현이 해당 기준과 충돌하면 과거 설명으로 본다. + ## 1. 서비스 정의 -**Soundlog는 여행자의 위치와 관광 맥락을 바탕으로 장소에 어울리는 음악을 추천하고, 여행 중 남긴 사진·장소·음악·시간 데이터를 하나의 감성 Recap으로 재구성하는 위치 기반 관광 음악 아카이빙 서비스이다.** +**Soundlog는 여행자의 위치와 관광 맥락을 바탕으로 장소에 어울리는 음악을 추천하고, 한 번의 촬영을 Recap으로 남기며, 여행모드에서 만든 Recap들을 GPS 이동 경로와 함께 하나의 Log로 모으는 위치 기반 관광 음악 아카이빙 서비스이다.** -여행에서 음악은 단순한 배경음이 아니라 장소의 분위기를 결정하고, 그 순간의 감정을 오래 기억하게 만드는 요소이다. Soundlog는 한국관광공사 OpenAPI를 통해 사용자의 현재 위치를 관광 맥락으로 해석하고, 음악 추천 및 여행 기록 생성에 연결한다. 사용자는 여행 중 화면을 오래 조작하지 않아도 현재 장소에 어울리는 플레이리스트를 추천받고, 여행이 끝난 뒤에는 자신의 여정을 앨범 커버, LP, 필름, 영상 형태의 Recap 콘텐츠로 다시 확인할 수 있다. +여행에서 음악은 단순한 배경음이 아니라 장소의 분위기를 결정하고, 그 순간의 감정을 오래 기억하게 만드는 요소이다. Soundlog는 한국관광공사 OpenAPI를 통해 사용자의 현재 위치를 관광 맥락으로 해석하고, 음악 추천 및 여행 기록 생성에 연결한다. 사용자는 여행 중 화면을 오래 조작하지 않아도 현재 장소에 어울리는 플레이리스트를 추천받고, 한 번의 촬영을 리캡으로 남긴다. 여행이 끝난 뒤에는 같은 여행 세션의 리캡과 GPS 경로를 하나의 로그 상세에서 다시 확인할 수 있다. ### 한 줄 소개 @@ -12,10 +14,29 @@ ### MVP 제품 방향 보강 -MVP의 Soundlog는 앱 안에서 음원을 직접 스트리밍하거나 외부 음악 앱의 재생 상태를 제어하는 서비스가 아니다. 사용자는 Soundlog에서 현재 장소와 무드에 맞는 곡을 추천받고, 실제 감상은 Spotify, YouTube Music, YouTube 등 외부 음악 링크로 이어간다. Soundlog 내부에서는 선택한 곡을 사진, 장소, 시간, 무드와 함께 저장하고, 여행이 끝난 뒤 하나의 사운드트랙 Recap으로 재구성하는 데 집중한다. +MVP의 Soundlog는 앱 안에서 음원을 직접 스트리밍하거나 외부 음악 앱의 재생 상태를 제어하는 서비스가 아니다. 사용자는 Soundlog에서 현재 장소와 무드에 맞는 곡을 추천받고, 실제 감상은 Spotify, YouTube Music, YouTube 등 외부 음악 링크로 이어간다. Soundlog 내부에서는 선택한 곡을 사진, 장소, 시간, 무드와 함께 Recap으로 저장하고, 여행이 끝난 뒤 같은 여행 세션의 Recap과 GPS 경로를 하나의 사운드트랙 Log로 모으는 데 집중한다. 자세한 피벗 기준, 화면 와이어프레임, 기능 명세는 [여행 사운드트랙 로그 기획 명세](SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md)를 따른다. +2026년 7월 기획 수정 이후의 탭 구조, 공개 리캡 지도, 리캡/로그 용어 정책은 +[리캡·로그 도메인 기준](RECAP_LOG_DOMAIN_MODEL.md)을 최우선 기준으로 삼는다. + +구현 태스크로 바로 쪼갤 수 있는 화면별 정책, API 계약, 데이터 모델, 예외 상태, 우선순위는 +[Soundlog Product Spec v0.3 Detailed](SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md)를 기준으로 삼는다. + +### 2026년 7월 현재 정보 구조 + +아래 구조가 현재 구현과 기획 검수의 우선 기준이다. 이 문서 하단의 과거 `홈`, `보관함` 표현과 충돌할 경우 이 구조와 v0.3 Detailed를 우선한다. + +| 탭 | 화면명 | 핵심 역할 | +| --- | --- | --- | +| 1 | 지도 | 앱 첫 화면. 여행모드 시작, 현재 위치, 주변 공개 리캡, 내 리캡 핀을 본다. | +| 2 | 음악추천 | 일상모드. 위치 기반 오늘의 사운드트랙과 외부 음악 링크를 제공한다. 여행모드 전환은 제공하지 않는다. | +| 3 | 로그 | 다른사람 공개 로그와 모든 로그를 격자로 탐색하고, 내 로그의 공개/비공개를 관리한다. | +| 4 | 마이 | 계정, 권한, 취향, 공개 설정을 관리한다. | + +카메라는 독립 콘텐츠 탭이 아니라 하단 중앙의 Recap 작성 액션이다. 하단 중앙 카메라 버튼과 지도 탭의 `기록 남기기`처럼 기록 맥락이 있는 CTA에서 호출된다. 로그 탭은 여행모드에서 생성된 로그만 `다른사람 보기`와 `모든 사람 보기` 두 탭으로 탐색하고 공개 범위를 관리한다. 카메라 화면 하단에는 좌측 `갤러리`, 중앙 촬영 버튼, 우측 `추천사진` CTA를 제공한다. 여행모드 중 저장한 Recap은 현재 Log에 묶이고, 여행모드 밖에서 저장한 Recap은 Log가 아닌 독립 Recap으로 관리한다. + ### 핵심 가치 - **맥락 기반 추천:** 단순 위치가 아니라 장소 유형, 시간대, 관광 모드, 무드, 사용자 취향을 함께 반영한다. @@ -59,21 +80,23 @@ Soundlog는 이 문제를 다음 세 가지 관점에서 해결한다. ### 4.2 Eyes-free 여행 몰입 UX -Soundlog는 여행 중 화면 조작을 최소화하는 방향으로 설계된다. 추천된 플레이리스트는 앱 안에서 장소와 곡 맥락을 보여주고, 사용자는 하단 미니 플레이어에서 현재 선택한 곡을 확인한 뒤 좋아요, 저장, 순간 기록 같은 Soundlog 자체 액션으로 이어진다. Soundlog MVP는 음원을 직접 스트리밍하거나 외부 플랫폼 재생 상태를 제어하지 않는다. +Soundlog는 여행 중 화면 조작을 최소화하는 방향으로 설계된다. 추천된 플레이리스트는 앱 안에서 장소와 곡 맥락을 보여주고, 사용자는 하단 미니 플레이어에서 현재 선택한 곡을 확인한 뒤 좋아요, 저장, Recap 만들기 같은 Soundlog 자체 액션으로 이어진다. Soundlog MVP는 음원을 직접 스트리밍하거나 외부 플랫폼 재생 상태를 제어하지 않는다. 추천이 현재 분위기와 맞지 않을 경우 복잡한 검색을 다시 하지 않아도 된다. 사용자는 “더 신나게”, “더 잔잔하게”, “더 감성적으로”, “더 로컬하게” 같은 무드 조정 옵션을 선택해 추천 방향을 즉시 바꿀 수 있다. -### 4.3 여행 순간 저장 +### 4.3 Recap 저장 -사용자는 여행 중 인상적인 장면을 발견했을 때 중앙 카메라 버튼으로 순간을 저장한다. 이때 사진만 저장되는 것이 아니라 사진 촬영 위치, 시간, 주변 관광지 정보, 당시 선택해 둔 음악, 선택한 무드 정보가 함께 기록된다. +사용자는 여행 중 인상적인 장면을 발견했을 때 지도 탭의 `기록 남기기` 같은 기록 CTA로 Recap을 저장한다. 이때 사진만 저장되는 것이 아니라 내부 GPS 위치, 사용자가 입력한 장소명, 시간, 당시 선택해 둔 음악, 선택한 무드 정보가 함께 기록된다. 이를 통해 하나의 사진은 단순 이미지가 아니라 “어디에서, 언제, 어떤 음악을 들으며, 어떤 분위기 속에서 남긴 장면인지”를 포함한 여행 순간이 된다. -### 4.4 Music Log와 Recap 생성 +### 4.4 Recap 누적과 여행 Log 생성 -Music Log는 여행 중 선택하거나 저장한 음악을 장소와 함께 보여주는 기록 영역이다. 사용자는 “그 여행지에서 저장했던 곡”, “그 장소에서 함께 남긴 음악”을 다시 확인할 수 있고, 음악을 통해 여행 당시의 장면을 복기할 수 있다. +Recap은 사진, 장소, 음악, 시간, 무드를 담은 한 번의 기록이다. 여행모드가 활성화되어 있으면 생성한 Recap에 현재 `sessionId`를 연결해 같은 여행 Log에 누적한다. 여행모드 밖에서 만든 Recap은 독립 Recap이며 Log로 취급하지 않는다. -여행이 종료되면 Soundlog는 저장된 사진, 방문 장소, 선택 음악, 시간, 무드 데이터를 기반으로 Recap을 생성한다. Recap은 앨범 커버형, LP형, 필름형, 영상형 콘텐츠로 제공되며, 대표 장소, 대표 사진, 대표 음악, 아티스트, 기록 시간이 함께 표시된다. +여행이 종료되면 Soundlog는 같은 세션의 Recap들을 촬영 시각 순으로 묶고, 여행모드 동안 foreground에서 저장한 GPS 경로를 함께 보존해 Log를 확정한다. 서버 폴링은 사용하지 않고, 클라이언트가 수집한 경로 좌표를 디바운스된 저장, 앱 상태 전환, 여행 종료, Log 생성 시점에 서버로 밀어 넣는다. + +Log 상세 지도는 해당 Log에 속한 Recap 위치만 번호 핀으로 표시하고, 해당 여행 세션의 경로만 선으로 연결한다. 정확한 이동 경로는 Log 소유자에게만 보여주고, 공개 Log를 보는 다른 사용자에게는 공개가 허용된 Recap 위치만 노출한다. --- @@ -83,44 +106,44 @@ Music Log는 여행 중 선택하거나 저장한 음악을 장소와 함께 보 온보딩은 서비스 개념을 이해하고 추천 품질에 필요한 최소 입력을 수집하는 단계이다. 현재 MVP에서는 게스트 사용을 제공하지 않고, 사용자가 Soundlog 계정으로 로그인한 뒤 위치 기반 추천 여부와 음악 취향, 여행 성향을 입력한다. -수집 항목은 다음과 같다. +MVP에서 직접 수집하는 항목은 다음과 같다. 장르, 아티스트, 동행 유형은 서버 모델 확장을 위해 유지할 수 있지만 현재 온보딩 필수 입력은 아니다. | 구분 | 수집 항목 | 예시 | | --- | --- | --- | -| 음악 취향 | 선호 장르 | K-POP, 팝, 인디, 발라드, 힙합, R&B, OST | | 음악 취향 | 선호 분위기 | 잔잔한, 신나는, 청량한, 감성적인, 활기찬 | -| 음악 취향 | 자주 듣는 아티스트/곡 | 사용자가 직접 입력 또는 검색 | -| 여행 성향 | 선호 여행지 | 바다, 도시, 자연, 카페거리, 축제 | -| 여행 성향 | 여행 스타일 | 산책형, 드라이브형, 사진형, 휴식형 | -| 동행 정보 | 동행 유형 | 혼자, 친구, 연인, 가족 | +| 여행 성향 | 여행 스타일 | 바다, 드라이브, 산책, 카페, 야경 | | 사용 환경 | 위치 접근 허용 | 현재 위치 기반 추천 사용 여부 | -### 5.2 홈 / 현재 여행 +### 5.2 지도 / 여행모드 -홈 화면은 현재 여행 세션의 중심 화면이다. 현재 위치, 주변 관광지, 여행 모드, 무드 태그, 추천 플레이리스트, 외부 링크용 미니 플레이어, 순간 저장 버튼을 제공한다. +지도 화면은 앱의 첫 화면이자 현재 여행 세션의 중심 화면이다. 현재 위치, 주변 공개 리캡, 내 리캡, 여행모드 상태, 여행모드 시작 CTA, 기록 남기기 CTA를 제공한다. 주요 구성은 다음과 같다. -- 위치 토글: 현재 위치 기반 추천을 켜고 끈다. -- 상황 태그: 기차 안, 비행기 안, 드라이브 중, 걷는 중, 쉬는 중 등 현재 상황을 선택한다. -- 무드 태그: 잔잔한, 신나는, 감성적인, 청량한 등 원하는 분위기를 선택한다. -- 추천 플레이리스트: 현재 맥락에 가장 잘 맞는 플레이리스트를 상단에 노출한다. -- 카메라 버튼: 현재 위치와 음악을 포함한 여행 로그를 생성한다. +- 장소 보기: 현재 위치와 주변 장소를 보고 여행모드를 시작한다. +- 전체 리캡: 현재 위치 300m 이내의 공개 리캡을 본다. 가까운 핀은 현재 지도 줌 레벨에 맞춰 숫자 원형 클러스터로 묶는다. +- 내 리캡: 현재 위치와 무관하게 내 private/public 리캡을 본다. 가까운 핀은 현재 지도 줌 레벨에 맞춰 숫자 원형 클러스터로 묶고, 진입 시 특정 핀으로 확대하지 않고 방문 지역 전체를 한눈에 조망한다. +- 지도 클러스터: 지도를 축소하면 화면에서 겹칠 만큼 가까운 리캡을 더 큰 지역 단위로 묶고, 확대하면 클러스터를 더 작은 묶음 또는 개별 핀으로 자동 분리한다. 클러스터 선택 시 포함된 리캡 목록을 열고 각 리캡 상세로 이동할 수 있다. +- 여행모드 CTA: 여행모드가 꺼져 있으면 `여행모드 시작`, 켜져 있으면 `기록 남기기`를 제공한다. +- 이동 경로 기록: 여행모드가 켜진 동안 앱 foreground에서 현재 위치 좌표를 샘플링하고, Recap에서는 해당 좌표들을 선으로 연결해 보여준다. ### 5.3 음악 큐레이션 음악 큐레이션 페이지는 장소 기반 플레이리스트를 보여주는 화면이다. 앨범 커버 이미지는 한국관광공사 OpenAPI에서 제공하는 관광 이미지 또는 사용자가 저장한 사진을 활용한다. +이 화면은 여행모드 시작 또는 전환 기능을 포함하지 않는다. 현재 위치와 주변 장소 맥락, 선택 무드, 사용자 취향을 바탕으로 추천만 수행하고, 여행모드 진입은 지도 화면의 CTA에서만 처리한다. + 주요 기능은 다음과 같다. - 추천 사유 제공: “광안리 해변 산책과 어울리는 청량한 플레이리스트”처럼 추천 맥락을 짧게 설명한다. +- 한국어 장소 표시: 관광공사 주변 장소를 우선하고, 결과가 없으면 서버 역지오코딩으로 동네·도시명을 표시한다. 숫자 좌표는 사용자 화면에 노출하지 않는다. - 음악 컨텍스트 UI: 앱 안에서 직접 재생하지 않고, 선택한 곡 정보를 Soundlog의 여행 기록 맥락으로 유지하며 필요할 때 외부 음악 링크로 이동한다. - 무드 조정: 더 신나게, 더 잔잔하게, 더 감성적으로, 더 로컬하게 옵션을 제공한다. - 좋아요/저장: 마음에 드는 곡과 플레이리스트를 저장한다. -### 5.4 여행 로그 생성 +### 5.4 Recap과 여행 Log 생성 -카메라 버튼 클릭 시 여행 로그가 생성된다. 기본 저장 정보는 다음과 같다. +카메라 작성 도구에서 저장하면 Recap 하나가 생성된다. 여행모드가 켜져 있으면 현재 `sessionId`의 Log에 묶이고, 꺼져 있으면 Log에 속하지 않는 독립 Recap으로 관리된다. 기본 저장 정보는 다음과 같다. - 사진 - 현재 위치 @@ -130,11 +153,11 @@ Music Log는 여행 중 선택하거나 저장한 음악을 장소와 함께 보 - 사용자가 선택한 관광 모드 - 사용자가 선택한 무드 태그 -### 5.5 Recap 페이지 +### 5.5 Log 페이지 -Recap 페이지는 지도와 리스트를 병행하기보다 리스트 기반으로 단순하게 구성한다. 사용자는 지난 여행 기록을 시간순으로 확인하고, 각 여행의 Recap 결과물을 다시 볼 수 있다. +Log 페이지는 여행모드에서 생성된 여행 Log를 격자형 목록으로 보여준다. 사용자는 특정 Log 상세에서 그 여행의 Recap을 시간순으로 확인하고, GPS 로드맵에서 해당 Log 구성 Recap만 핀으로 다시 볼 수 있다. -Recap 유형은 다음과 같다. +Recap 표현 템플릿은 촬영 후 저장 확인 화면에서 다음 중 하나를 선택한다. - 앨범 커버형: 대표 사진과 대표 음악을 조합한 정방형 이미지 - LP형: 음악 앨범 콘셉트의 기록 카드 @@ -158,12 +181,12 @@ Recap 유형은 다음과 같다. | --- | --- | --- | | 시작 | 온보딩 | 서비스 가치 전달 및 취향 데이터 수집 | | 시작 | 권한/취향 | 위치 추천 여부와 음악·여행 취향 설정 | -| 여행 | 홈 / 현재 여행 | 현재 위치, 관광 모드, 추천 플레이리스트 확인 | -| 여행 | 음악 큐레이션 | 추천 플레이리스트 상세 및 무드 조정 | +| 여행 | 지도 / 여행모드 | 현재 위치, 여행모드 시작, 공개/내 리캡 지도 확인 | +| 추천 | 음악추천 / 일상모드 | 위치 기반 플레이리스트와 무드 조정 | | 여행 | 음악 링크 패널 | 현재 선택 곡과 외부 음악 링크 확인 | -| 기록 | 순간 저장 | 사진, 장소, 음악, 시간 기반 로그 생성 | -| 회고 | Recap 리스트 | 생성된 여행 Recap 확인 | -| 회고 | Recap 상세 | 앨범 커버, LP, 필름, 영상 결과물 확인 및 공유 | +| 기록 | Recap 작성 도구 | 사진, 장소, 음악, 시간 기반 단일 Recap 생성 | +| 회고 | 여행 Log 탐색 | 남의 공개 Log, 내 Log 격자 보기, 내 Log 공개 범위 관리 | +| 회고 | Log 상세 | 소속 Recap, GPS 로드맵, 저장된 표현 결과 확인 및 공유 | | 관리 | 마이페이지 | 계정, 연동, 좋아요, 취향 정보 관리 | --- @@ -171,15 +194,15 @@ Recap 유형은 다음과 같다. ## 7. 사용자 플로우 1. 사용자가 앱에 진입한다. -2. 간편 로그인 후 음악 취향, 여행 스타일, 동행 유형 등 온보딩 설문을 완료한다. -3. 위치 기반 추천 여부와 음악·여행 취향을 설정한다. -4. 홈 화면에서 현재 위치 기반 추천을 확인한다. -5. 현재 상황 태그와 무드 태그를 선택해 추천 방향을 조정한다. -6. 추천된 플레이리스트에서 곡을 선택하고 외부 음악 앱 검색 링크를 연다. -7. 여행 중 인상적인 순간에 카메라 버튼을 눌러 여행 로그를 생성한다. -8. 여행 중 선택하거나 저장한 곡은 Music Log에 누적된다. -9. 여행 종료 후 Soundlog가 사진, 장소, 음악, 시간 데이터를 바탕으로 Recap을 생성한다. -10. 사용자는 Recap을 저장하거나 SNS에 공유한다. +2. Soundlog 이메일 계정으로 로그인하거나 가입한다. +3. 위치 기반 추천 여부, 여행 스타일과 음악 무드를 설정한다. +4. 지도 화면에서 현재 위치와 주변 공개/내 리캡 핀을 확인한다. +5. 여행모드 시작 CTA를 눌러 현재 여행 Log를 시작한다. +6. 음악추천 탭에서 현재 장소 기반 플레이리스트를 확인하고 외부 음악 앱 링크를 연다. +7. 여행 중 인상적인 순간에 `기록 남기기`로 Recap을 저장한다. +8. 여행 중 저장한 Recap은 현재 `sessionId`의 Log에 누적된다. +9. 여행 종료 후 같은 세션의 Recap과 GPS 경로로 Log를 확정한다. +10. 사용자는 Log 상세에서 Recap과 경로를 다시 보고, 로그 탭에서 공개 범위를 관리한다. --- @@ -219,7 +242,7 @@ Soundlog에서 한국관광공사 데이터는 부가 정보가 아니라 서비 | 좋아요/저장 | track_id, 추천 컨텍스트, 시각 | 선호 음악 및 지역 트렌드 집계 | | 무드 조정 | 조정 방향, 직전 추천 결과 | 추천 랭킹 보정 | | 위치 변동 | GPS, 주변 POI ID, 타임스탬프 | 여행 경로 및 Recap 생성 | -| 순간 저장 | 사진, 위치, 음악, 시간, 무드 | 여행 로그 생성 | +| 리캡 저장 | 사진, 위치, 음악, 시간, 무드 | 독립 리캡 또는 현재 여행 로그 구성원 생성 | --- @@ -283,8 +306,8 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 - 장소 맥락 기반 플레이리스트 추천 - 무드 조정 기능 - 미니 플레이어 UI -- 카메라 버튼 기반 여행 로그 생성 -- Recap 리스트 및 상세 화면 +- Recap 작성 도구 기반 독립 Recap/여행 Log 생성 +- 여행 Log 목록 및 상세 로드맵 화면 - 좋아요한 음악/플레이리스트 확인 - 마이페이지의 계정, 권한, 취향 관리 @@ -309,9 +332,9 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 음악 장르, 여행 스타일, 동행 유형, 위치 기반 추천 여부를 설정한다. -### P-03 홈 / 현재 여행 +### P-03 지도 / 여행모드 -현재 위치, 여행 모드, 상황 태그, 무드 태그, 추천 플레이리스트, 외부 링크용 미니 플레이어, 카메라 버튼을 제공한다. +현재 위치, 여행모드 상태, 장소/전체 리캡/내 리캡 필터, 공개/내 리캡 핀, 여행모드 시작 또는 기록 남기기 CTA를 제공한다. ### P-04 음악 큐레이션 @@ -319,19 +342,19 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 ### P-05 음악 링크 패널 -현재 선택한 곡, 현재 장소, 외부 음악 앱 링크, 순간 저장 진입을 제공한다. +현재 선택한 곡, 현재 장소, 외부 음악 앱 링크, 리캡 작성 진입을 제공한다. -### P-06 순간 저장 +### P-06 리캡 작성 -사진, 위치, 시간, 음악, 무드 태그를 함께 저장한다. +사진, 내부 GPS 위치, 사용자가 입력한 장소명, 시간, 음악, 무드, 문구와 표현 템플릿을 함께 저장한다. -### P-07 Recap 리스트 +### P-07 로그 탐색 -여행별 Recap을 리스트 형태로 확인한다. +여행모드에서 생성된 로그만 `다른사람 보기`와 `모든 사람 보기` 격자로 확인한다. -### P-08 Recap 상세 +### P-08 로그 상세 -앨범 커버형, LP형, 필름형, 영상형 결과물을 확인하고 저장 또는 공유한다. +같은 여행 세션의 리캡, GPS 경로, 여행 요약, 음악 요약과 고정 SOUNDLOG 공유 결과물을 확인한다. 이 화면에서는 리캡 템플릿을 수정하지 않는다. ### P-09 마이페이지 @@ -398,7 +421,7 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 두 번째 핵심 경험은 Eyes-free 여행 몰입 UX입니다. 여행 중 사용자가 스마트폰 화면을 오래 바라보는 순간 실제 풍경과 장소에 대한 몰입은 줄어들 수 있습니다. Soundlog는 추천 플레이리스트와 외부 음악 앱 검색 링크를 빠르게 제공하고, 하단 미니 플레이어와 간단한 무드 조정 기능을 통해 화면 조작을 최소화합니다. 사용자는 복잡한 검색 없이 “더 신나게”, “더 잔잔하게”, “더 감성적으로” 같은 선택만으로 음악 분위기를 바꿀 수 있습니다. -세 번째 핵심 경험은 여행 순간 저장과 Recap 생성입니다. 사용자가 여행 중 인상적인 장면을 발견했을 때 카메라 버튼을 누르면 사진뿐 아니라 촬영 위치, 시간, 주변 관광지 정보, 당시 선택해 둔 음악, 선택한 무드 정보가 함께 저장됩니다. 여행이 종료되면 Soundlog는 저장된 사진, 방문 장소, 선택 음악, 시간, 무드 데이터를 기반으로 앨범 커버형, LP형, 필름형, 영상형 Recap을 생성합니다. 사용자는 자신의 여행을 하나의 음악 앨범처럼 다시 감상하고, 완성된 Recap을 저장하거나 SNS에 공유할 수 있습니다. +세 번째 핵심 경험은 Recap 저장과 여행 Log 생성입니다. 사용자가 여행 중 인상적인 장면을 발견했을 때 하단 중앙 카메라 버튼 또는 지도 탭의 `기록 남기기`로 Recap을 만들면 사진뿐 아니라 내부 GPS 위치, 사용자가 입력한 장소명, 시간, 당시 선택해 둔 음악, 선택한 무드와 표현 템플릿이 함께 저장됩니다. 여행이 종료되면 Soundlog는 같은 `sessionId`의 Recap과 GPS 이동 경로를 하나의 Log로 확정합니다. 사용자는 Log 상세에서 자신의 여행을 하나의 음악 앨범처럼 다시 감상하고, 지도에서는 그 Log에 속한 Recap만 핀으로 확인할 수 있습니다. Soundlog의 기술적 핵심은 한국관광공사 OpenAPI 기반 장소 데이터와 음악 데이터를 연결하는 추천 파이프라인입니다. 현재 위치를 기준으로 관광지명, 좌표, 카테고리, 장소 설명, 대표 이미지, 행사·축제 정보 등을 수집하고, 이를 단순 위치 정보가 아닌 장소의 분위기와 관광 맥락으로 변환합니다. 이후 장소 설명, 카테고리, 관광 모드, 무드 태그를 음악 데이터와 매칭해 사용자 로그가 부족한 초기 상황에서도 장소에 어울리는 음악을 추천할 수 있습니다. 서비스 이용이 누적되면 외부 링크 열기, 다음/이전 이동, 좋아요, 저장, 무드 조정 데이터를 반영해 개인화 추천 비중을 점진적으로 높입니다. diff --git a/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_2.md b/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_2.md new file mode 100644 index 0000000..3f2105c --- /dev/null +++ b/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_2.md @@ -0,0 +1,256 @@ +# Soundlog Product Spec v0.2 + +> **Canonical domain notice:** 리캡과 로그의 최신 정의는 [리캡·로그 도메인 기준](RECAP_LOG_DOMAIN_MODEL.md)을 따른다. 특히 `Recap = 카메라 저장 1회`, `Log = 같은 여행모드 세션의 Recap 집합`이며 여행모드 밖의 독립 Recap은 Log가 아니다. + +## 1. Product Direction + +Soundlog is a location-based music travel log app. + +The product is not centered on in-app streaming. Soundlog helps users: + +1. discover music that fits the current place, +2. leave a sound log at that place, +3. turn moments into a recap, +4. discover public recaps left nearby by other users. + +Core sentence: + +> Soundlog lets users leave a sound log at a place and discover public sound recaps around them. + +## 2. Core Concepts + +### Moment + +A Moment is the smallest record unit. + +Required or optional data: + +- photo: optional for MVP, but recommended for richer recap quality +- location: optional fallback allowed, but public map exposure requires location +- place: optional place name or Tour API place id +- recordedAt: required +- track: optional +- caption: optional +- visibility: `private` or `public` + +### Log + +A Log is a trip-level group of Moments. + +- When travel mode is active, newly created Moments are attached to the active Log. +- When travel mode is inactive, a created Moment becomes a single-moment Log. +- A Log itself is primarily private. Public discovery happens through public Recaps derived from Moments or Logs. + +### Recap + +A Recap is the rendered, shareable result of one Moment or one Log. + +Supported MVP templates: + +- album +- film +- lp +- map + +Visibility: + +- `private`: visible only to owner +- `public`: shown as a map marker to nearby users + +### Public Recap Marker + +A Public Recap Marker is a map pin created from a public Recap. + +- It remains on the map unless the owner deletes it or moderation hides it. +- It is only returned to other users when they are within the configured discovery radius. +- MVP discovery radius: 300 meters. + +## 3. Tab Information Architecture + +### 3.1 Travel Mode + +Route: `/travel` + +Role: + +- Map-first page. +- Starts and manages travel mode. +- Shows nearby public recaps and my recaps on the map. + +Primary user jobs: + +- Start a travel log. +- See what public sound recaps exist near me. +- See my own recaps on the map. +- Open a nearby recap marker. + +Main UI: + +- native map +- current location marker +- marker filter chips +- travel start CTA +- active travel status +- selected marker preview card + +Filter chips: + +- `장소 보기`: default map mode. Shows travel mode CTA. +- `전체 리캡`: shows nearby public recaps from all users. Hides travel mode CTA. +- `내 리캡`: shows my public and private recaps. Hides travel mode CTA. + +Marker policy: + +- `전체 리캡` returns only public recaps within 300m of the user location. +- `내 리캡` includes every location-backed recap owned by the user, including private recaps, without applying the current-location radius. +- My recap markers are clustered by visited coordinates and open in an overview viewport that avoids tight automatic zoom. +- The 300m radius restriction applies only to `전체 리캡`. + +### 3.2 Everyday Mode + +Route: `/` + +Role: + +- Music recommendation page. +- Does not require an active travel session. + +Primary user jobs: + +- Get today's soundtrack based on current location. +- Adjust mood and recommendation scope. +- Select a track to use in future Moments/Recaps. + +Recommendation inputs: + +- current location +- nearby Tour API place +- place category and keywords +- selected mood +- optional user taste profile +- time of day + +### 3.3 Recap + +Route: `/recap` + +Role: + +- Public log exploration, all-log browsing, and owner's log management page. +- Opens the Camera tool from a floating CTA. + +Primary user jobs: + +- View public Logs from other users. +- View all visible Logs in a grid. +- Open a Log detail. +- Manage public/private visibility for my saved Logs. + +Top sections: + +- `다른사람 보기` +- `모든 사람 보기` + +Creation entry: + +- Bottom navigation has a center camera CTA for Moment creation. +- Camera is not a content tab. Camera is opened from contextual record flows such as the center camera CTA and Travel Mode. + +### 3.4 My Page + +Route: `/my` + +Role: + +- Account, taste, permissions, and privacy settings. + +## 4. Camera Policy + +Camera is a tool, not a main tab. + +Entry points: + +- Bottom navigation: center camera CTA +- Travel Mode: `기록 남기기` + +Behavior: + +- If travel mode is active, captured Moment is attached to the active Log. +- If travel mode is inactive, captured Moment becomes a single-moment Log. +- Music can be selected from current soundtrack, recommendation list, or a search flow. +- MVP fallback: allow "music none". + +## 5. API Contract Summary + +### Public Recap Discovery + +`GET /v1/recap-markers` + +Query: + +- `lat`: number +- `lng`: number +- `radiusMeters`: number, default 300 +- `scope`: `public` or `mine` + +Returns: + +- marker id +- recap id +- coordinate +- place name +- title +- owner alias +- track title +- artist name +- template id +- visibility +- distance meters + +### Recap Visibility + +`PATCH /v1/recaps/:id/visibility` + +Body: + +- `visibility`: `private` or `public` + +Returns: + +- updated recap summary + +### Create Recap + +`POST /v1/recaps` + +Existing endpoint remains, with additional fields: + +- `visibility` +- `title` +- `templateId` +- `momentLogIds` +- `sessionId` + +## 6. MVP Priority + +P0: + +- 4-tab structure: Travel Mode, Everyday Mode, Recap, My Page +- Camera tab removed from bottom navigation +- Travel map filters: place, all public recaps, my recaps +- Public recap marker API facade on frontend +- Recap visibility field in frontend/server contract +- Swagger/OpenAPI docs updated + +P1: + +- Music search during recap creation +- Public recap report/hide +- Nearby recap entry notification +- Merge single-moment Logs into a trip Log + +P2: + +- Companion shared Log +- Travel mate matching +- Live current-listening map diff --git a/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md b/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md new file mode 100644 index 0000000..a2d6d74 --- /dev/null +++ b/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md @@ -0,0 +1,779 @@ +# Soundlog Product Spec v0.3 Detailed + +> **Canonical domain notice:** 리캡, 로그, 여행 세션, GPS 경로, 로그 지도에 관한 최신 정의와 구현 규칙은 [리캡·로그 도메인 기준](RECAP_LOG_DOMAIN_MODEL.md)을 최우선으로 적용한다. 이 문서에 남은 `Moment -> Log -> Recap 결과물` 표현은 과거 모델이며 신규 구현 근거로 사용하지 않는다. + +## 1. 제품 한 줄 정의 + +Soundlog는 사용자가 현재 장소에 어울리는 음악을 발견하고, 그 음악을 여행 순간에 붙여 장소 기반 로그와 공유 가능한 리캡으로 남기는 앱이다. + +핵심 문장: + +> 지금 이 장소의 음악을 찾고, 그 순간을 여행 사운드 로그로 남긴다. + +MVP의 중심은 스트리밍이 아니라 `추천 -> 선택 -> 리캡 생성 -> 여행 로그 누적 -> 장소 기반 회고/발견`이다. 앱은 음원을 직접 재생하지 않고, 곡 메타데이터와 외부 음악 링크를 제공한다. 사용자는 Soundlog 안에서 음악을 고르고 기록하며, 실제 감상은 Spotify, YouTube Music, YouTube 같은 외부 앱에서 이어간다. + +## 2. 왜 이 방향인가 + +초기 아이디어였던 스포티파이 조작형 앱은 매력은 있지만 MVP 리스크가 크다. + +| 문제 | 설명 | Soundlog의 선택 | +| --- | --- | --- | +| Spotify Premium 의존 | 재생 제어는 프리미엄, 활성 기기, 계정 상태에 크게 의존한다. | 재생 제어를 핵심 가치에서 제외한다. | +| 심사/정책 리스크 | 인앱 스트리밍, 재생 제어, 음원 저작권 처리가 커진다. | 곡 메타데이터와 외부 링크만 다룬다. | +| 사용 대상 축소 | Spotify를 쓰지 않는 사용자는 가치가 줄어든다. | 누구나 추천, 저장, 리캡을 쓸 수 있게 한다. | +| 제품 정체성 약화 | 음악 플레이어 보조앱처럼 보일 수 있다. | 여행을 음악으로 기록하는 서비스로 정의한다. | + +따라서 Soundlog의 MVP 성공 기준은 “앱 안에서 음악을 틀 수 있는가”가 아니라 “내 여행을 음악과 장소가 붙은 콘텐츠로 남기고 다시 보고 싶어지는가”이다. + +## 3. 핵심 용어 + +### 3.1 Recap + +Recap은 가장 작은 제품 기록 단위다. 카메라 촬영, 갤러리 선택, 추천사진 선택 또는 사진 없이 기록하기를 거쳐 저장 버튼을 한 번 누르면 Recap 하나가 생성된다. + +- 예: DDP 앞에서 찍은 사진 1장 + 사용자가 입력한 장소명 + 그때 고른 곡 + 촬영 시간 + 내부 GPS 위치. +- 사진, 위치, 음악은 일부 없어도 저장할 수 있다. +- 장소명은 사용자가 입력하며 GPS 좌표나 주소로 자동 대체하지 않는다. +- GPS 위치가 있어야 공개 지도 핀으로 전환할 수 있다. +- 앨범, LP, 필름, 지도 표현 템플릿은 촬영 후 저장 확인 화면에서 선택한다. +- 여행모드 ON이면 현재 여행 세션의 `sessionId`를 가진다. +- 여행모드 OFF이면 `sessionId`가 없는 독립 Recap으로 저장된다. + +필드: + +| 필드 | 필수 | 설명 | +| --- | --- | --- | +| id | 필수 | Recap 식별자 | +| userId | 필수 | 작성자 | +| sessionId | 선택 | 여행모드에서 생성했을 때의 여행 세션 | +| photoUrl | 선택 | 촬영 또는 업로드 이미지 | +| lat/lng | 선택 | 내부 저장 GPS 위치. 좌표 문자열은 UI에 노출하지 않음 | +| placeName | 선택 | 사용자가 입력한 장소명 | +| recordedAt | 필수 | 저장 시각 | +| track | 선택 | 선택한 곡 | +| mood | 선택 | 잔잔한, 신나는 등 | +| travelState | 선택 | 바다, 드라이브 등 | +| caption | 선택 | 레거시 기록 호환용. 신규 작성 화면에서는 입력받지 않음 | +| templateId | 필수 | 앨범, LP, 필름, 지도 | +| visibility | 필수 | private 또는 public | + +현재 코드의 `MomentLog`, `RecapShareMoment`, `/v1/recap-captures`는 이 제품 Recap을 가리키는 레거시 기술명이다. 사용자 화면에는 Moment라는 별도 개념을 노출하지 않는다. + +### 3.2 Log + +Log는 같은 여행모드 세션에서 생성한 Recap의 시간순 집합이다. + +- 여행모드 ON: 새 Recap이 현재 `sessionId`의 Log에 누적된다. +- 여행모드 OFF: 독립 Recap만 생성되며 Log는 생성되지 않는다. +- 여행 세션에 Recap이 1개만 있어도 Log다. +- 여행 세션에 Recap이 0개면 Log 탭에 표시하지 않는다. +- 장소와 날짜가 같아도 `sessionId`가 다르면 서로 다른 Log다. +- Log 상세는 Recap과 GPS 경로를 감상하는 읽기 전용 화면이다. +- Log 지도에는 해당 Log의 Recap 핀과 해당 세션 경로만 표시한다. + +현재 서버의 `Recap` 모델과 `/v1/recaps`는 여행 Log 집계/공유 결과 역할도 하는 레거시 기술명이다. 제품 용어와 기술 이름의 전체 매핑은 [리캡·로그 도메인 기준](RECAP_LOG_DOMAIN_MODEL.md)을 따른다. + +### 3.3 Travel Mode + +Travel Mode는 사용자가 지금 하나의 여행 Log를 쌓고 있다는 상태다. + +- 시작하면 `TravelSession`이 생성되고 GPS 경로점 수집을 시작한다. +- 여행 중 만든 Recap은 현재 세션에 연결한다. +- 종료하면 같은 세션의 Recap과 GPS 경로를 하나의 Log로 확정한다. +- Travel Mode는 현재 위치 공개와 동일하지 않다. 공개는 별도 동의가 필요하다. +- 현재 MVP는 앱 foreground에서 GPS 경로를 기록한다. + +### 3.4 Public Recap Marker + +Public Recap Marker는 공개 Recap이 지도에 남긴 핀이다. + +- `public` Recap만 다른 사람에게 보인다. +- 내 Recap은 `private`여도 내 지도에서는 볼 수 있다. +- MVP에서는 사용자 현재 위치 기준 300m 이내 공개 핀만 반환한다. +- 지도에는 위치 기반 공개 흔적이 남지만, 정확한 개인 이동 경로 전체를 노출하지 않는다. + +## 4. 정보 구조 + +MVP 탭은 4개다. + +| 탭 | 화면명 | 역할 | 기본 질문 | +| --- | --- | --- | --- | +| 1 | 지도 / 여행모드 | 여행모드 진입, 주변 공개 리캡 탐색, 내 리캡 지도 확인 | 지금 이 장소에 어떤 사운드 로그가 남아 있나? | +| 2 | 음악 추천 / 일상모드 | 현재 장소 기반 사운드트랙 추천 | 지금 여기서 뭘 들으면 좋을까? | +| 3 | 로그 | 남의 공개 로그 탐색, 내 로그 공개 범위 관리 | 다른 사람과 내가 남긴 사운드로그는 어떻게 보이나? | +| 4 | 마이페이지 | 계정, 권한, 취향, 공개 설정 관리 | 내 데이터와 취향을 어떻게 관리할까? | + +카메라는 독립 콘텐츠 탭이 아니라 하단 중앙의 Recap 작성 액션이다. 진입점은 하단 중앙 카메라 버튼과 여행모드의 `기록 남기기`처럼 기록 맥락이 있는 CTA다. + +## 5. 사용자 핵심 루프 + +### 5.1 여행 중 루프 + +1. 앱을 열면 지도 탭이 열린다. +2. 사용자는 `여행모드 시작` CTA를 누른다. +3. 위치 권한이 없으면 이 시점에 권한을 요청한다. +4. 여행 상태와 무드를 고른다. +5. 일상모드 또는 추천 패널에서 장소 기반 음악을 고른다. +6. 외부 음악 앱으로 이동해 감상한다. +7. 좋은 순간이 생기면 `기록 남기기`로 사진, 촬영 시간과 음악을 Recap으로 저장한다. +8. 여행이 끝나면 여행모드를 종료한다. +9. 같은 여행 세션의 Recap과 GPS 경로가 하나의 Log로 확정된다. + +### 5.2 일상 추천 루프 + +1. 음악 추천 탭에 진입한다. +2. 현재 위치 또는 수동 장소를 기준으로 추천을 요청한다. +3. 사용자는 장소와 무드를 기준으로 추천 방향을 조정한다. +4. 곡 목록을 보고 좋아요, 저장, 외부 링크 열기를 수행한다. +5. 마음에 드는 곡은 나중에 Recap에 붙일 수 있다. + +### 5.3 로그 탐색 루프 + +1. 로그 탭에서 `다른사람 보기` 또는 `모든 사람 보기`를 선택한다. +2. 격자형 카드에서 특정 Log를 선택해 상세로 진입한다. +3. `모든 사람 보기`에서는 내 로그의 공개/비공개 상태를 확인한다. +4. 내 로그를 `전체공개`로 바꾸면 다른사람 보기와 지도 공개 핀에 노출된다. +5. 내 로그를 `비공개`로 바꾸면 모든 사람 보기에서만 볼 수 있다. + +## 6. 화면별 상세 명세 + +### 6.1 온보딩 / 로그인 + +목표: + +- 게스트 사용을 막고 계정 기반 저장 구조를 명확히 한다. +- Soundlog가 음악 플레이어가 아니라 여행 사운드 로그 앱이라는 점을 설명한다. + +필수 UI: + +- 브랜드명 `Soundlog` +- 짧은 가치 문구 +- 예시 Recap 비주얼 +- 이메일 입력 +- 비밀번호 입력 +- 로그인과 계정 만들기 전환 +- 약관/개인정보 링크 + +정책: + +- 로그인 전에는 지도, 추천, 리캡, 마이페이지 진입을 막는다. +- 로그인 실패 시 원인을 안내하고 같은 화면에서 재시도할 수 있게 한다. +- 현재 MVP는 Soundlog 자체 이메일/비밀번호 계정만 제공한다. Apple/Kakao 로그인은 공급자 키, 서버 검증, 스토어 정책을 함께 확정한 뒤 별도 단계에서 추가한다. +- 위치 권한은 온보딩 첫 화면에서 바로 요청하지 않는다. + +완료 조건: + +- 로그인 성공 +- 필수 약관 동의 +- 온보딩 완료 플래그 저장 + +### 6.2 지도 / 여행모드 탭 + +역할: + +- 앱의 첫 화면이다. +- 여행모드 CTA와 지도 탐색이 중심이다. +- 주변의 공개 Recap과 내 Recap을 지도에서 확인한다. + +상단 상태: + +- 현재 장소명 또는 현재 위치 조회 상태 +- 여행모드 ON/OFF +- 현재 여행 Log 이름 +- 현재 선택 곡이 있으면 미니 상태로 표시 + +지도 필터: + +| 필터 | 보이는 것 | CTA | +| --- | --- | --- | +| 장소 보기 | 현재 위치, 주변 장소, 여행모드 진입 카드 | 여행모드 시작 또는 기록 남기기 | +| 전체 리캡 | 300m 이내 공개 Recap 핀 | CTA 숨김 | +| 내 리캡 | 내 전체 private/public Recap의 방문 좌표 클러스터 | CTA 숨김 | + +CTA 정책: + +- 장소 보기 상태에서만 하단 CTA를 보여준다. +- 여행모드 OFF: `여행모드 시작` +- 여행모드 ON: `기록 남기기` +- 위치 권한 없음: `위치 켜고 시작` + +핀 정책: + +- 핀은 장소 단위가 아니라 Recap 단위다. +- 전체 리캡과 내 리캡은 현재 지도 축척에서 화면상 약 56pt 안에 겹치는 좌표를 `N개` 원형 핀으로 묶는다. 클러스터 반경은 0.5단계 줌 버킷으로 갱신해 작은 지도 이동에는 안정적으로 유지하고, 확대하면 작은 묶음 또는 개별 핀으로 분리한다. +- 내 리캡 진입 시 특정 핀으로 확대하지 않고 모든 방문 지역을 도시 단위 이상의 축척으로 조망한다. +- 클러스터를 누르면 묶인 Recap 목록을 열고 각 Recap의 읽기 전용 상세로 이동할 수 있다. +- 전체 리캡 핀은 공개 Recap만 노출한다. +- 내 리캡 핀은 private도 노출한다. + +공개 Recap 진입 정책: + +- 사용자가 공개 핀을 누르면 미리보기 카드가 열린다. +- 미리보기에는 제목, 장소, 대표 곡, 작성자 별칭, 거리만 보여준다. +- 상세 진입 시 공개 Recap 상세를 연다. +- 작성자의 정확한 이동 경로나 비공개 Recap은 보여주지 않는다. + +빈 상태: + +- 전체 리캡 없음: `근처에 공개된 사운드 로그가 아직 없어요.` +- 내 리캡 없음: `아직 지도에 남긴 내 리캡이 없어요.` +- 위치 권한 없음: `현재 위치를 켜면 주변 리캡을 볼 수 있어요.` + +에러 상태: + +- 지도 로딩 실패: 기본 좌표와 재시도 버튼 제공 +- 위치 조회 실패: 수동 장소 선택 유도 +- Recap API 실패: 지도는 유지하고 핀 영역만 실패 표시 + +### 6.3 음악 추천 / 일상모드 탭 + +역할: + +- 여행모드와 무관하게 현재 장소 기반 음악을 추천한다. +- 사용자가 곡을 고르고 외부 음악 앱으로 이동하게 한다. + +입력값: + +| 입력 | 예시 | 필수 여부 | +| --- | --- | --- | +| 위치 | lat/lng | 권장 | +| 장소 | 성수동 카페거리 | 위치 없을 때 대체 | +| 여행 상태 | 바다, 드라이브, 산책, 카페, 야경 | 필수 기본값 | +| 무드 | 잔잔한, 신나는, 시원한, 설레는, 감성적인 | 필수 기본값 | +| 취향 | 선호 장르, 저장 곡 | 선택 | +| 시간대 | 오후, 밤 | 자동 | + +추천 결과: + +- 추천 제목 +- 추천 이유 +- 곡 목록 +- 곡별 아티스트, 앨범 이미지, 외부 링크 +- 장소 또는 관광지 맥락 +- 무드 태그 + +액션: + +- 외부 앱에서 열기 +- 좋아요 +- 저장 +- 지금 선택 곡으로 지정 +- 이 곡으로 Recap 만들기 +- 무드 변경 + +외부 링크 정책: + +- Spotify 링크가 있으면 우선 제공한다. +- YouTube Music 또는 YouTube 검색 링크를 fallback으로 제공한다. +- 외부 링크가 실패해도 Soundlog의 곡 선택 상태는 유지한다. + +### 6.4 카메라 / Recap 작성 도구 + +역할: + +- 탭이 아니라 생성 도구다. +- 사진 촬영 또는 사진 없이 Recap을 만든다. +- 촬영 후 저장 확인 화면에서 장소명, 음악, 무드, 공개 범위, 표현 템플릿을 확정한다. + +진입점: + +- 지도 탭의 `기록 남기기` +- 음악 추천 상세의 `이 곡으로 기록` + +작성 필드: + +| 필드 | 기본값 | +| --- | --- | +| 사진 | 촬영 이미지 또는 없음 | +| 위치 | 현재 GPS 위치를 내부 저장 | +| 장소 | 빈 값에서 시작하며 사용자가 직접 입력 | +| 시간 | 촬영 시각으로 고정, 사용자가 수정할 수 없음 | +| 음악 | 현재 선택 곡 또는 없음 | +| 무드 | 마지막 선택 무드 | +| 표현 템플릿 | 필름 기본값, 사용자가 앨범/LP/필름/지도 중 선택 | +| 공개 범위 | 나만보기 기본값 | + +저장 정책: + +- 여행모드 ON이면 현재 `sessionId`를 가진 Recap으로 저장하고 활성 Log에 포함한다. +- 여행모드 OFF이면 `sessionId`가 없는 독립 Recap으로 저장하며 Log를 만들지 않는다. +- 업로드 실패 시 로컬 임시 저장 후 재시도할 수 있어야 한다. +- 사진이 없어도 음악/장소/무드 기반 Recap을 허용한다. + +### 6.5 로그 탭 + +역할: + +- 여행모드에서 생성된 여행 로그만 보는 곳이다. +- 공개된 다른 사람의 여행 로그와 내 여행 로그를 보는 곳이다. +- 인스타그램 돋보기처럼 격자형으로 로그를 탐색한다. +- 내 로그의 공개/비공개를 관리한다. +- 여행모드 밖에서 생성한 독립 Recap은 이 탭에 표시하지 않는다. + +섹션: + +| 섹션 | 설명 | +| --- | --- | +| 다른사람 보기 | visibility가 public인 다른 사용자의 Log | +| 모든 사람 보기 | 내 계정에 저장된 private/public Log와 공개 Log | + +로그 판별 기준은 `sessionId` 존재 여부다. 같은 여행 세션에 Recap이 하나만 있어도 Log이며, `sessionId`가 없는 독립 Recap은 Log가 아니다. + +로그 격자 카드: + +- Recap 개수 +- 대표 사진 +- 장소 +- 대표 곡 +- 공개 상태 배지. 내 로그에만 표시 +- 클릭 시 상세 화면 진입 + +로그 상세: + +- 같은 `sessionId`의 Recap을 촬영 시각 순서로 보여준다. +- GPS 지도에는 현재 Log 구성 Recap만 번호 핀으로 표시한다. +- 이동 경로선은 현재 Log의 여행 세션에서 수집한 `routePoints`만 사용한다. +- 주변 공개 Recap, 다른 Log, 관광지 추천 핀은 섞지 않는다. +- 저장된 템플릿 결과는 읽기 전용이며 Log 상세에서 다시 편집하지 않는다. + +내 로그 공개 설정: + +- `전체공개`: 다른사람 보기와 지도 공개 영역에 표시 +- `비공개`: 모든 사람 보기의 내 로그로만 표시 +- 서버에 저장되지 않은 로컬 로그는 상세 진입만 가능하고 공개 범위 변경은 비활성화 + +빈 상태: + +- 다른사람 로그 없음: `아직 다른 사람이 공개한 로그가 없어요.` +- 모든 사람 로그 없음: `아직 볼 수 있는 여행 로그가 없어요. 여행모드를 시작하고 리캡을 남기면 여기에 쌓여요.` + +### 6.6 Recap 표현 설정 + +목표: + +- 카메라 촬영 후 저장 확인 흐름에서 사용자가 Recap의 표현을 정한다. +- 단순 자동 생성이 아니라 “내가 만든 한 장의 기록”처럼 느끼게 한다. +- Log 상세에서는 이 설정을 읽기 전용으로 보여주며 다시 편집하지 않는다. + +템플릿: + +| 템플릿 | 용도 | 주요 요소 | +| --- | --- | --- | +| 앨범 커버 | 대표 순간 강조 | 사진, 제목, 대표 곡, 장소 | +| 필름 | 시간 흐름 강조 | 여러 Recap 컷, 촬영 시간, 장소 | +| LP | 음악 정체성 강조 | LP 그래픽, 트랙리스트, 아티스트 | +| 지도 엽서 | 장소성 강조 | 지도, 핀, 경로, 대표 곡 | + +저장 전 설정 가능 요소: + +- 음악 스티커 +- 음악 스티커 위치 +- 음악 스티커 여러 개 추가 +- 템플릿 변경 +- 공개 범위 + +저장 전 제스처 정책: + +- 시간 또는 음악 스티커를 드래그 중일 때는 화면 스크롤을 막는다. +- 드래그 종료 후 스크롤을 다시 허용한다. +- 요소는 캔버스 밖으로 완전히 나가지 않게 제한한다. +- 작은 화면에서도 저장/공유 버튼이 가려지지 않아야 한다. + +저장 정책: + +- 저장 전 이탈하면 임시 저장을 제안한다. +- 네트워크 실패 시 로컬 초안을 보관한다. +- 공개 전에는 위치 공개 여부를 다시 확인한다. + +### 6.7 리캡 상세 / 공유 + +역할: + +- 완성된 독립 Recap 또는 여행 Log를 감상하고 저장/공유/공개 설정을 바꾼다. +- 저장된 표현 결과를 읽기 전용으로 렌더링한다. + +액션: + +- 이미지로 저장 +- OS 공유 시트 열기 +- 공개 범위 변경 +- 지도에서 보기 +- 삭제하기 + +공개 범위: + +| 상태 | 설명 | +| --- | --- | +| 나만보기 | 내 계정에서만 확인 가능 | +| 전체공개 | 주변 사용자 지도에 공개 핀으로 노출 | + +공개 전 확인 문구: + +`전체공개하면 이 리캡의 장소와 대표 음악이 지도에 표시돼요. 정확한 이동 경로와 비공개 기록은 공개되지 않아요.` + +### 6.8 마이페이지 + +역할: + +- 계정과 취향, 권한, 공개 상태를 관리한다. + +필수 메뉴: + +- 프로필 +- 음악 취향 수정 +- 여행 취향 수정 +- 위치 권한 상태 +- 카메라 권한 상태 +- 내 로그 공개 설정 관리 +- 차단/신고 관리 +- 약관/개인정보 +- 로그아웃 + +P1 이후: + +- Spotify 연결 상태 +- 저장 곡 목록 +- 내 공개 활동 통계 + +## 7. 커뮤니티 기능 상세 + +커뮤니티는 MVP 핵심은 아니지만 제품 확장 방향으로 남긴다. 단, 안전과 공개 동의를 최우선으로 둔다. + +### 7.1 같이 만드는 리캡 + +목표: + +- 같이 여행 간 사람들이 각자의 사진, 곡, 문구를 하나의 Log에 모은다. + +흐름: + +1. 사용자가 여행방을 만든다. +2. 초대 링크 또는 코드로 동행자를 초대한다. +3. 각자 Recap을 남긴다. +4. 방장은 공동 Log에 포함할 Recap 후보를 선택한다. +5. 최종 Recap 공개 범위를 정한다. + +정책: + +- 참여자는 초대 기반이다. +- 방장은 공개 범위를 최종 결정한다. +- 참여자는 자신의 Recap을 삭제할 수 있다. + +### 7.2 Live Sound Map + +목표: + +- 여행모드 ON 사용자가 현재 선택한 음악을 지도 위에 표시한다. + +표시 데이터: + +- 닉네임 또는 익명명 +- 대략 위치 +- 현재 선택 곡 +- 여행 상태 +- 무드 + +정책: + +- 기본값은 비공개다. +- 사용자가 `지도에 내 사운드 표시`를 켜야 노출된다. +- 정확 좌표 대신 반경 보정 좌표를 사용할 수 있다. +- 일정 시간 활동이 없으면 자동 비노출한다. + +### 7.3 Nearby Sound Match + +목표: + +- 낯선 사람과 프로필이 아니라 음악 취향과 여행 맥락으로 먼저 연결된다. + +MVP 이후 흐름: + +1. 사용자가 여행모드에서 공개 상태를 켠다. +2. 주변의 익명 사운드 카드를 본다. +3. 무드/곡/여행 상태가 맞는 사람에게 `같이 들어요` 또는 `동행 제안`을 보낸다. +4. 상대가 수락하면 제한된 대화 또는 만남 제안이 열린다. + +안전 정책: + +- 상호 수락 전 자유 채팅 금지 +- 반복 요청 제한 +- 신고/차단 필수 +- 정확 위치 비공개 +- 미성년자 정책 별도 검토 필요 + +## 8. 서버/API 계약 + +### 8.1 추천 + +`POST /recommend` 또는 서버 프록시 `POST /v1/recommendations` + +요청: + +```json +{ + "x": 126.9963, + "y": 37.5104, + "state": "산책", + "mood": "잔잔한" +} +``` + +허용값: + +- state: `바다`, `드라이브`, `산책`, `카페`, `야경` +- mood: `잔잔한`, `신나는`, `시원한`, `설레는`, `감성적인` + +응답: + +- playlist id +- recommendation title +- reason +- tracks +- external links +- place context + +### 8.2 Recap/Log 집계 생성 + +`POST /v1/recaps` + +현재 API 이름은 레거시다. `sessionId`가 있으면 제품의 여행 Log 집계 결과이며, 없으면 독립 Recap의 공유 결과다. + +필드: + +- title +- templateId +- visibility +- momentLogIds +- sessionId +- caption +- selectedTrackIds + +### 8.3 Recap 공개 범위 변경 + +`PATCH /v1/recaps/:recapId/visibility` + +요청: + +```json +{ + "visibility": "public" +} +``` + +정책: + +- 작성자만 변경 가능 +- public으로 변경하려면 대표 위치가 있어야 한다 +- 대표 위치가 없으면 400 에러 + +### 8.4 지도 마커 + +`GET /v1/recap-markers` + +쿼리: + +- lat +- lng +- radiusMeters +- scope: `public` 또는 `mine` + +정책: + +- public: 300m 이내 공개 Recap만 반환 +- mine: 내 Recap은 private/public 모두 반환 +- radiusMeters는 서버 최대값으로 제한한다 + +### 8.5 Recap 원본 저장 + +`POST /v1/moments` + +실제 서버의 `/v1/recap-captures` 또는 `/v1/moment-logs`와 같은 `MomentLog` 명칭은 제품 Recap 원본을 저장하는 레거시 기술명이다. + +필드: + +- photoUrl +- lat/lng +- placeName +- recordedAt +- trackId +- mood +- state +- caption +- travelSessionId + +정책: + +- travelSessionId가 있으면 해당 여행 Log의 Recap으로 연결 +- 없으면 독립 Recap으로 저장하며 Log는 생성하지 않음 + +## 9. 데이터 모델 요약 + +| 모델 | 핵심 필드 | +| --- | --- | +| User | id, provider, displayName, profileImage, tasteProfile | +| TravelSession | id, userId, title, startedAt, endedAt, status, routePoints | +| Recap 원본(legacy MomentLog) | id, userId, sessionId, photoUrl, lat, lng, placeName, recordedAt, trackId, mood, state, caption, templateId, visibility | +| Track | id, title, artist, albumImageUrl, spotifyUrl, youtubeUrl | +| Log 집계(legacy server Recap) | id, userId, sessionId, title, visibility, recapCount, routePoints, createdAt | +| LogRecap 관계(legacy RecapMoment) | logId, recapId, order | +| PublicMarker | recapId, lat, lng, placeName, distanceMeters | +| Report | id, reporterId, targetType, targetId, reason | + +## 10. 권한과 개인정보 + +위치: + +- 지도/추천 진입 시 맥락 기반으로 요청한다. +- 거부해도 수동 장소 기반 추천은 가능해야 한다. +- 공개 Recap은 대표 위치만 공개한다. + +카메라: + +- Recap 작성 시 요청한다. +- 거부해도 사진 없는 Recap을 만들 수 있다. + +사진 저장: + +- Recap 이미지 저장 시 요청한다. + +음악 계정: + +- MVP에서는 필수가 아니다. +- Spotify 연결은 P1 이후 선택 기능이다. + +공개: + +- 모든 기록은 기본 private다. +- public 전환은 사용자의 명시적 액션이 있어야 한다. + +## 11. 상태와 예외 + +| 상황 | 처리 | +| --- | --- | +| 위치 권한 거부 | 수동 장소 선택, 권한 설정 이동 제공 | +| 지도 로딩 실패 | 기본 지도 영역 유지, 핀만 실패 상태 | +| 추천 API 실패 | 이전 추천 또는 샘플 추천 fallback | +| 외부 링크 실패 | YouTube 검색 링크 fallback | +| 사진 업로드 실패 | 로컬 임시 저장 후 재시도 | +| Recap 저장 실패 | 편집 초안 유지 | +| 공개 Recap 위치 없음 | 공개 전환 차단 | +| 로그인 만료 | 재로그인 유도, 작성 중 데이터 임시 보존 | + +## 12. 이벤트 설계 + +| 이벤트 | 발생 시점 | 주요 속성 | +| --- | --- | --- | +| onboarding_completed | 온보딩 완료 | provider | +| travel_mode_started | 여행모드 시작 | lat, lng, state, mood | +| travel_mode_ended | 여행모드 종료 | duration, recapCount | +| recommendation_requested | 추천 요청 | lat, lng, state, mood | +| track_selected | 곡 선택 | trackId, source | +| external_music_opened | 외부 링크 열기 | trackId, platform | +| recap_saved | 단일 Recap 저장 | hasPhoto, hasTrack, hasLocation, templateId | +| travel_log_created | 여행 Log 확정 | sessionId, recapCount, routePointCount | +| recap_visibility_changed | 공개 범위 변경 | from, to | +| recap_marker_opened | 지도 핀 열기 | recapId, distance | +| recap_shared | 공유 실행 | method | + +## 13. 우선순위 + +### P0: 앱의 정체성이 깨지지 않기 위한 필수 + +- 로그인 강제 +- 4탭 구조: 여행모드, 일상모드, 리캡, 마이페이지 +- 카메라 탭 제거, 생성 도구화 +- 여행모드 시작/종료 +- 장소 기반 추천 호출 +- 곡 선택과 외부 링크 +- Recap 저장 +- 여행 Log 생성 +- Recap 공개 범위 +- 공개 Recap 지도 핀 +- API 문서 최신화 + +### P1: 사용자가 실제로 계속 쓰게 만드는 기능 + +- 촬영 후 Recap 표현 설정 고도화 +- 음악 스티커 자유 배치 +- 여러 템플릿 +- 공개 Recap 신고/숨김 +- 수동 장소 선택 +- 로컬 임시 저장/재시도 +- 추천 실패 fallback + +### P2: 커뮤니티 확장 + +- 같이 만드는 리캡 +- Live Sound Map +- 주변 취향 발견 +- 동행 매칭 +- 차단/신고/안전 정책 + +### P3: 고도화 + +- Spotify 선택 연동 +- 개인화 추천 랭킹 +- 공개 Recap 인기순/저장순 +- 여행 경로 기반 자동 Recap +- 영상형 Recap + +## 14. MVP 완료 기준 + +사용자 관점: + +- 로그인 후 앱에 진입할 수 있다. +- 지도 탭에서 여행모드를 시작할 수 있다. +- 현재 위치와 상태/무드 기반 추천을 받을 수 있다. +- 추천 곡을 외부 음악 앱에서 열 수 있다. +- 여행 중 Recap을 남길 수 있다. +- 로그 탭에서 다른사람 공개 로그와 모든 로그를 격자로 볼 수 있다. +- 내 로그를 비공개/전체공개로 설정할 수 있다. +- 전체공개 로그는 지도에서 핀으로 보인다. + +기술 관점: + +- 프론트는 서버 API를 통해 추천, Recap, 지도 마커를 호출한다. +- 서버는 mock DB가 아닌 실제 DB 기준으로 주요 데이터를 저장한다. +- Swagger/OpenAPI에 실제 사용 API가 반영되어 있다. +- iOS 시뮬레이터에서 로그인 이후 주요 플로우가 동작한다. +- 위치 권한 거부, 추천 실패, 업로드 실패 상태가 앱을 깨뜨리지 않는다. + +## 15. 대표 테스트 시나리오 + +### 시나리오 A: 첫 사용자 + +1. 앱 설치 +2. Soundlog 이메일 계정 로그인 또는 가입 +3. 온보딩 완료 +4. 지도 탭 진입 +5. 위치 권한 허용 +6. 여행모드 시작 +7. 추천 탭에서 `산책/잔잔한` 추천 확인 +8. 곡 선택 후 외부 링크 열기 +9. 지도 탭에서 기록 남기기 +10. 로그 탭에서 모든 사람 보기 확인 +11. 필요하면 비공개 상태 유지 + +### 시나리오 B: 내 로그 공개로 전환하기 + +1. 로그 탭 진입 +2. `모든 사람 보기` 선택 +3. 기존 Log 카드의 공개 상태 토글 선택 +4. 전체공개 선택 +5. 지도 탭 전체 리캡 필터에서 핀 확인 + +### 시나리오 C: 위치 권한 거부 + +1. 지도 탭 진입 +2. 위치 권한 거부 +3. 수동 장소 선택 또는 샘플 추천 표시 +4. 추천 탭은 제한 모드로 사용 가능 +5. 공개 Recap 지도 탐색은 권한 안내 표시 + +## 16. 아직 결정이 필요한 질문 + +1. 공개 Recap의 기본 반경을 300m로 고정할지, 지역 밀도에 따라 조절할지. +2. 여행모드 중 위치 추적을 foreground만 할지, background까지 지원할지. +3. 사진 없는 Recap을 UI에서 얼마나 적극적으로 허용할지. +4. 공개 Recap의 댓글/반응을 P1에 넣을지 P2로 미룰지. +5. 낯선 사람 매칭을 진짜 제품 축으로 가져갈지, 졸업/공모전 이후 확장으로 둘지. diff --git a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md index 787cac5..f8ee5b5 100644 --- a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md +++ b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md @@ -1,5 +1,7 @@ # Soundlog 여행 사운드트랙 로그 기획 명세 +> **Canonical domain notice:** 이 문서는 피벗 배경과 초기 와이어프레임을 보존한다. 리캡과 로그의 현재 제품 의미는 [리캡·로그 도메인 기준](RECAP_LOG_DOMAIN_MODEL.md)을 따른다. 현재 기준에서 Recap은 카메라 저장 1회이며, Log는 같은 여행모드 세션에서 생성된 Recap의 집합이다. + ## 1. 문서 목적 이 문서는 Soundlog의 MVP 방향을 **직접 스트리밍/플레이어 조작 앱**이 아니라 **여행 사운드트랙 로그 앱**으로 정리하기 위한 제품 기획서이다. diff --git a/package-lock.json b/package-lock.json index c21a450..ad3f9cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,10 +29,10 @@ "expo-sharing": "~56.0.21", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", - "ol": "^10.9.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", + "react-native-maps": "1.27.2", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", @@ -1871,12 +1871,6 @@ "node": ">= 8" } }, - "node_modules/@petamoriken/float16": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", - "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", - "license": "MIT" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3089,6 +3083,12 @@ "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hammerjs": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", @@ -3129,12 +3129,6 @@ "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/rbush": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", - "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", @@ -3213,16 +3207,6 @@ "node": ">=10.0.0" } }, - "node_modules/@zarrita/storage": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz", - "integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==", - "license": "MIT", - "dependencies": { - "reference-spec-reader": "^0.2.0", - "unzipit": "2.0.0" - } - }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4405,12 +4389,6 @@ "license": "MIT", "peer": true }, - "node_modules/earcut": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", - "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", - "license": "ISC" - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5425,12 +5403,6 @@ "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", "license": "MIT" }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5568,25 +5540,6 @@ "node": ">=6.9.0" } }, - "node_modules/geotiff": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", - "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", - "license": "MIT", - "dependencies": { - "@petamoriken/float16": "^3.9.3", - "lerc": "^3.0.0", - "pako": "^2.0.4", - "parse-headers": "^2.0.2", - "quick-lru": "^6.1.1", - "web-worker": "^1.5.0", - "xml-utils": "^1.10.2", - "zstddec": "^0.2.0" - }, - "engines": { - "node": ">=10.19" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -6145,12 +6098,6 @@ "lan-network": "dist/lan-network-cli.js" } }, - "node_modules/lerc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", - "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", - "license": "Apache-2.0" - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7153,15 +7100,6 @@ "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", "license": "MIT" }, - "node_modules/numcodecs": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", - "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", - "license": "MIT", - "dependencies": { - "fflate": "^0.8.0" - } - }, "node_modules/ob1": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", @@ -7192,24 +7130,6 @@ "node": ">= 6" } }, - "node_modules/ol": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz", - "integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==", - "license": "BSD-2-Clause", - "dependencies": { - "@types/rbush": "4.0.0", - "earcut": "^3.0.0", - "geotiff": "^3.0.5 || ^3.1.0-beta.0", - "pbf": "4.0.1", - "rbush": "^4.0.0", - "zarrita": "^0.7.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/openlayers" - } - }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -7407,28 +7327,6 @@ "node": ">=6" } }, - "node_modules/pako": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", - "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "(MIT AND Zlib)" - }, - "node_modules/parse-headers": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", - "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", - "license": "MIT" - }, "node_modules/parse-png": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", @@ -7515,18 +7413,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pbf": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", - "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", - "license": "BSD-3-Clause", - "dependencies": { - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7935,12 +7821,6 @@ "node": ">= 6" } }, - "node_modules/protocol-buffers-schema": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", - "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7999,24 +7879,6 @@ ], "license": "MIT" }, - "node_modules/quick-lru": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", - "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/quickselect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", - "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", - "license": "ISC" - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8026,15 +7888,6 @@ "node": ">= 0.6" } }, - "node_modules/rbush": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", - "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", - "license": "MIT", - "dependencies": { - "quickselect": "^3.0.0" - } - }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -8463,6 +8316,28 @@ "react-native": "*" } }, + "node_modules/react-native-maps": { + "version": "1.27.2", + "resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.27.2.tgz", + "integrity": "sha512-VKr+xZ2RZGHHJlY6KhlafvGSmK0dq/tUu5uhfJ7K9rwN5pUdubdugzMKGDU/16lXmQSg7xbClKhRctj3Pm5F5g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.13" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "react": ">= 18.3.1", + "react-native": ">= 0.76.0", + "react-native-web": ">= 0.11" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/react-native-reanimated": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz", @@ -8697,12 +8572,6 @@ "node": ">=8" } }, - "node_modules/reference-spec-reader": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", - "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", - "license": "MIT" - }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -8808,15 +8677,6 @@ "node": ">=8" } }, - "node_modules/resolve-protobuf-schema": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", - "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", - "license": "MIT", - "dependencies": { - "protocol-buffers-schema": "^3.3.1" - } - }, "node_modules/resolve-workspace-root": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", @@ -9675,15 +9535,6 @@ "node": ">= 0.8" } }, - "node_modules/unzipit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz", - "integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -9875,12 +9726,6 @@ "defaults": "^1.0.3" } }, - "node_modules/web-worker": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", - "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -9975,12 +9820,6 @@ "node": ">=10.0.0" } }, - "node_modules/xml-utils": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", - "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", - "license": "CC0-1.0" - }, "node_modules/xml2js": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", @@ -10069,16 +9908,6 @@ "node": ">=12" } }, - "node_modules/zarrita": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz", - "integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==", - "license": "MIT", - "dependencies": { - "@zarrita/storage": "^0.2.0", - "numcodecs": "^0.3.2" - } - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -10088,12 +9917,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zstddec": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", - "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", - "license": "MIT AND BSD-3-Clause" - }, "node_modules/zustand": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", diff --git a/package.json b/package.json index 3b58980..88ea2d0 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "expo-sharing": "~56.0.21", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", - "ol": "^10.9.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", + "react-native-maps": "1.27.2", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", @@ -63,8 +63,10 @@ "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: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 doctor", + "check": "npm run typecheck && npm run check:recap-clustering && npm run doctor", "prepare": "sh scripts/install-git-hooks.sh" }, "private": true, diff --git a/scripts/check-api-origin.js b/scripts/check-api-origin.js index 2337705..dab7c13 100644 --- a/scripts/check-api-origin.js +++ b/scripts/check-api-origin.js @@ -146,7 +146,7 @@ async function verifyNearbyPlaces() { async function verifyMusicMetadata() { try { const payload = await fetchAuthenticatedJson( - '/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', + '/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday', ); const serialized = JSON.stringify(payload?.data ?? {}); diff --git a/scripts/check-deployed-web.js b/scripts/check-deployed-web.js index cea7b2e..fc83ca2 100644 --- a/scripts/check-deployed-web.js +++ b/scripts/check-deployed-web.js @@ -183,7 +183,7 @@ async function verifyApiProxy() { }, { auth: true, - path: '/api/soundlog/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', + path: '/api/soundlog/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday', }, { auth: true, @@ -259,7 +259,7 @@ async function verifyServerContract() { try { const payload = await fetchAuthenticatedJson( - '/api/soundlog/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', + '/api/soundlog/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday', ); const serialized = JSON.stringify(payload?.data ?? {}); diff --git a/scripts/check-recap-map-clustering.js b/scripts/check-recap-map-clustering.js new file mode 100644 index 0000000..27750d1 --- /dev/null +++ b/scripts/check-recap-map-clustering.js @@ -0,0 +1,156 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +const sourcePath = path.join(process.cwd(), 'src/utils/recapMapClustering.ts'); +const source = fs.readFileSync(sourcePath, 'utf8'); +const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: sourcePath, +}); +const moduleRef = { exports: {} }; + +vm.runInNewContext(transpiled.outputText, { + exports: moduleRef.exports, + module: moduleRef, +}); + +const { clusterRecapMarkers, RECAP_MAP_PIN_DIAMETER_PX } = moduleRef.exports; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function marker(id, lat, lng) { + return { + artistName: 'Soundlog', + createdAt: '2026-07-13T00:00:00.000Z', + id, + location: { lat, lng }, + ownerAlias: '나', + placeName: id, + recapId: id, + templateId: 'album', + title: id, + trackTitle: id, + visibility: 'private', + }; +} + +const viewport = { + height: 780, + region: { + latitude: 37.5665, + latitudeDelta: 0.16, + longitude: 126.978, + longitudeDelta: 0.16, + }, + width: 390, +}; +const longitudePerPixel = viewport.region.longitudeDelta / viewport.width; +const overlapMarkers = [ + marker('a', 37.5665, 126.978), + marker( + 'b', + 37.5665, + 126.978 + longitudePerPixel * (RECAP_MAP_PIN_DIAMETER_PX - 1), + ), + marker( + 'c', + 37.5665, + 126.978 + longitudePerPixel * (RECAP_MAP_PIN_DIAMETER_PX + 1), + ), +]; +const grouped = clusterRecapMarkers( + [overlapMarkers[0], overlapMarkers[1], marker('far', 37.5665, 127.08)], + viewport, +); + +assert(grouped.length === 2, '화면에서 겹친 핀만 같은 클러스터여야 합니다.'); +assert( + grouped.some((cluster) => cluster.markers.length === 2), + '핀 지름보다 중심 간격이 작은 두 핀이 숫자 클러스터가 되어야 합니다.', +); + +assert( + clusterRecapMarkers([overlapMarkers[0], overlapMarkers[2]], viewport) + .length === 2, + '핀 지름보다 중심 간격이 큰 두 핀은 가까워도 묶이지 않아야 합니다.', +); + +const sameCoordinates = [ + marker('same-a', 37.5665, 126.978), + marker('same-b', 37.5665, 126.978), +]; +assert( + clusterRecapMarkers(sameCoordinates, viewport).length === 1, + '같은 좌표의 핀은 반드시 하나의 클러스터로 묶여야 합니다.', +); + +const zoomSensitiveMarkers = [ + marker('zoom-a', 37.5665, 126.978), + marker('zoom-b', 37.5665, 126.979), +]; +assert( + clusterRecapMarkers(zoomSensitiveMarkers, viewport).length === 1, + '축소 화면에서 실제로 겹치는 핀은 하나의 클러스터여야 합니다.', +); +assert( + clusterRecapMarkers(zoomSensitiveMarkers, { + ...viewport, + region: { + ...viewport.region, + latitudeDelta: 0.002, + longitudeDelta: 0.002, + }, + }).length === 2, + '확대해 핀이 떨어지면 같은 좌표 간격도 개별 핀으로 분리되어야 합니다.', +); + +const transitiveMarkers = [ + marker('chain-a', 37.5665, 126.978), + marker('chain-b', 37.5665, 126.978 + longitudePerPixel * 40), + marker('chain-c', 37.5665, 126.978 + longitudePerPixel * 80), +]; +assert( + clusterRecapMarkers(transitiveMarkers, viewport).length === 1, + '서로 이어져 겹치는 핀들은 하나의 클러스터로 묶여야 합니다.', +); + +assert( + clusterRecapMarkers(sameCoordinates, { + ...viewport, + width: 0, + }).length === 2, + '지도 크기를 모를 때는 핀을 임의로 묶지 않아야 합니다.', +); + +const antimeridianMarkers = [ + marker('east', 0, 179.999), + marker('west', 0, -179.999), +]; +const antimeridianClusters = clusterRecapMarkers(antimeridianMarkers, { + ...viewport, + region: { + latitude: 0, + latitudeDelta: 0.1, + longitude: 180, + longitudeDelta: 0.1, + }, +}); +assert( + antimeridianClusters.length === 1, + '날짜 변경선 양쪽의 화면상 인접 핀도 정상적으로 묶여야 합니다.', +); +assert( + Math.abs(Math.abs(antimeridianClusters[0].location.lng) - 180) < 0.001, + '날짜 변경선 클러스터 중심이 반대편 경도로 이동하지 않아야 합니다.', +); + +console.log('Recap map screen-overlap clustering check passed.'); diff --git a/scripts/check-server-api-contract.js b/scripts/check-server-api-contract.js new file mode 100644 index 0000000..5eaec60 --- /dev/null +++ b/scripts/check-server-api-contract.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const projectRoot = path.resolve(__dirname, '..'); +const serverRoot = path.resolve( + process.env.SOUNDLOG_SERVER_ROOT || path.join(projectRoot, '..', 'SoundLogServer'), +); +const openApiPath = path.join(serverRoot, 'openapi', 'soundlog-api.yaml'); +const apiRoot = path.join(projectRoot, 'src', 'api'); + +if (!fs.existsSync(openApiPath)) { + throw new Error( + `SoundLogServer OpenAPI를 찾을 수 없습니다: ${openApiPath}\n` + + 'SOUNDLOG_SERVER_ROOT로 서버 저장소 경로를 지정하세요.', + ); +} + +function normalizePath(value) { + return value.replace(/\{[^}]+\}/g, '{param}').replace(/\/+$/, '') || '/'; +} + +function readOpenApiOperations() { + const operations = new Set(); + let currentPath; + + fs.readFileSync(openApiPath, 'utf8') + .split(/\r?\n/) + .forEach((line) => { + const pathMatch = line.match(/^ (\/[^:]+):\s*$/); + + if (pathMatch) { + currentPath = pathMatch[1]; + return; + } + + const methodMatch = line.match(/^ (delete|get|patch|post|put):\s*$/); + + if (currentPath && methodMatch) { + operations.add( + `${methodMatch[1].toUpperCase()} ${normalizePath(currentPath)}`, + ); + } + }); + + return operations; +} + +function getPropertyName(node) { + if (!node) { + return undefined; + } + + if (ts.isIdentifier(node) || ts.isStringLiteral(node)) { + return node.text; + } + + return undefined; +} + +function getStringProperty(objectLiteral, propertyName) { + if (!objectLiteral || !ts.isObjectLiteralExpression(objectLiteral)) { + return undefined; + } + + const property = objectLiteral.properties.find( + (candidate) => + ts.isPropertyAssignment(candidate) && + getPropertyName(candidate.name) === propertyName, + ); + + if ( + property && + ts.isPropertyAssignment(property) && + (ts.isStringLiteral(property.initializer) || + ts.isNoSubstitutionTemplateLiteral(property.initializer)) + ) { + return property.initializer.text; + } + + return undefined; +} + +function readStringConstants(sourceFile) { + const constants = new Map(); + + sourceFile.statements.forEach((statement) => { + if (!ts.isVariableStatement(statement)) { + return; + } + + statement.declarationList.declarations.forEach((declaration) => { + if ( + ts.isIdentifier(declaration.name) && + declaration.initializer && + (ts.isStringLiteral(declaration.initializer) || + ts.isNoSubstitutionTemplateLiteral(declaration.initializer)) + ) { + constants.set(declaration.name.text, declaration.initializer.text); + } + }); + }); + + return constants; +} + +function expressionToPath(expression, constants) { + if ( + ts.isStringLiteral(expression) || + ts.isNoSubstitutionTemplateLiteral(expression) + ) { + return expression.text; + } + + if (ts.isIdentifier(expression)) { + return constants.get(expression.text) ?? `{${expression.text}}`; + } + + if (ts.isCallExpression(expression)) { + const firstArgument = expression.arguments[0]; + + if ( + ts.isIdentifier(expression.expression) && + expression.expression.text === 'encodeURIComponent' && + firstArgument + ) { + if (ts.isIdentifier(firstArgument)) { + return `{${firstArgument.text}}`; + } + + return '{param}'; + } + } + + if (ts.isTemplateExpression(expression)) { + return expression.templateSpans.reduce( + (value, span) => + value + expressionToPath(span.expression, constants) + span.literal.text, + expression.head.text, + ); + } + + return undefined; +} + +function readFrontendOperations() { + const operations = []; + const unresolved = []; + const apiFiles = fs + .readdirSync(apiRoot) + .filter((fileName) => fileName.endsWith('Api.ts')) + .sort(); + + apiFiles.forEach((fileName) => { + const filePath = path.join(apiRoot, fileName); + const sourceText = fs.readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile( + filePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const constants = readStringConstants(sourceFile); + + function visit(node) { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + ['requestApi', 'uploadApiFile'].includes(node.expression.text) + ) { + const pathValue = node.arguments[0] + ? expressionToPath(node.arguments[0], constants) + : undefined; + + if (!pathValue || !pathValue.startsWith('/')) { + const position = sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile), + ); + unresolved.push(`${fileName}:${position.line + 1}`); + } else { + const isUpload = node.expression.text === 'uploadApiFile'; + const options = node.arguments[isUpload ? 2 : 1]; + const method = + getStringProperty(options, isUpload ? 'httpMethod' : 'method') ?? + (isUpload ? 'POST' : 'GET'); + const position = sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile), + ); + + operations.push({ + key: `${method.toUpperCase()} ${normalizePath(pathValue)}`, + source: `${fileName}:${position.line + 1}`, + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + }); + + if (unresolved.length > 0) { + throw new Error( + `정적으로 해석하지 못한 API 호출이 있습니다:\n${unresolved + .map((value) => `- ${value}`) + .join('\n')}`, + ); + } + + return operations; +} + +const serverOperations = readOpenApiOperations(); +const frontendOperations = readFrontendOperations(); +const missingOperations = frontendOperations.filter( + (operation) => !serverOperations.has(operation.key), +); + +if (missingOperations.length > 0) { + throw new Error( + `서버 OpenAPI에 없는 프론트 API 호출이 있습니다:\n${missingOperations + .map((operation) => `- ${operation.key} (${operation.source})`) + .join('\n')}`, + ); +} + +console.log( + `Frontend/server API contract check passed (${new Set( + frontendOperations.map((operation) => operation.key), + ).size} frontend operations, ${serverOperations.size} server operations).`, +); diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index 756fd4e..fd65fdb 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -139,14 +139,20 @@ function verifyRuntimeSourceDoesNotImportMocks() { }); } -function verifyHomeScreenUsesServerQueries() { - const homeRoutePath = path.join(projectRoot, 'app/(tabs)/index.tsx'); - const text = fs.readFileSync(homeRoutePath, 'utf8'); +function verifyMusicScreenUsesServerQueries() { + const musicRoutePath = path.join(projectRoot, 'app/(tabs)/music.tsx'); + const text = fs.readFileSync(musicRoutePath, 'utf8'); [ - ['useFeaturedPlaylistsQuery', 'Home screen must request featured playlists through the API query.'], - ['useMoodRecommendationsQuery', 'Home screen must request mood recommendations through the API query.'], - ['useNearbyPlacesQuery', 'Home screen must request nearby places through the API query.'], + [ + 'useFeaturedPlaylistsQuery', + 'Music screen must request featured playlists through the API query.', + ], + [ + 'useMoodRecommendationsQuery', + 'Music screen must request mood recommendations through the API query.', + ], + ['useNearbyPlacesQuery', 'Music screen must request nearby places through the API query.'], ].forEach(([marker, message]) => { if (!text.includes(marker)) { addError(message); @@ -155,7 +161,7 @@ function verifyHomeScreenUsesServerQueries() { ['@/mock-server', '@/mocks', 'playlistCurationById'].forEach((marker) => { if (text.includes(marker)) { - addError(`Home screen must not use mock data marker: ${marker}`); + addError(`Music screen must not use mock data marker: ${marker}`); } }); } @@ -243,7 +249,7 @@ try { verifyApiSourceFiles(); verifyHonestMusicActionSourceFiles(); verifyRuntimeSourceDoesNotImportMocks(); - verifyHomeScreenUsesServerQueries(); + verifyMusicScreenUsesServerQueries(); runExport(); if (errors.length === 0) { diff --git a/scripts/check-store-release.js b/scripts/check-store-release.js index 124801a..f6f9adb 100644 --- a/scripts/check-store-release.js +++ b/scripts/check-store-release.js @@ -187,9 +187,25 @@ function assertNativeIosPlist() { }); if (plist.includes('Expo Dev Launcher')) { - addWarning( - 'iOS native Info.plist contains Expo Dev Launcher local-network copy. Confirm the release strip phase removes it from the archived plist.', + const projectPath = path.join( + projectRoot, + 'ios/Soundlog.xcodeproj/project.pbxproj', ); + const project = fs.existsSync(projectPath) + ? fs.readFileSync(projectPath, 'utf8') + : ''; + const hasReleaseStripPhase = [ + '[Expo Dev Launcher] Strip Local Network Keys for Release', + 'if [ \\\"$CONFIGURATION\\\" != \\\"Debug\\\" ]', + 'Delete :NSLocalNetworkUsageDescription', + 'Delete :NSBonjourServices', + ].every((expectedText) => project.includes(expectedText)); + + if (!hasReleaseStripPhase) { + addError( + 'iOS Info.plist contains Expo Dev Launcher local-network keys without a verified non-Debug strip phase.', + ); + } } if (fs.existsSync(entitlementsPath)) { diff --git a/src/api/authApi.ts b/src/api/authApi.ts index a9f84d7..706d986 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -9,6 +9,12 @@ import { } from '@/types/auth'; export const authApi = { + deleteAccount: async () => { + return requestApi<{ deleted: boolean }>('/v1/me', { + method: 'DELETE', + retryOnUnauthorized: false, + }); + }, getMe: async () => { return requestApi('/v1/me'); }, diff --git a/src/api/authQueries.ts b/src/api/authQueries.ts index 377bbfe..9ab58a3 100644 --- a/src/api/authQueries.ts +++ b/src/api/authQueries.ts @@ -13,6 +13,12 @@ export function useLoginMutation() { }); } +export function useDeleteAccountMutation() { + return useMutation({ + mutationFn: () => authApi.deleteAccount(), + }); +} + export function useRegisterMutation() { return useMutation({ mutationFn: (request: RegisterRequest) => authApi.register(request), diff --git a/src/api/client.ts b/src/api/client.ts index 98176cc..f05cab5 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,6 @@ import { useAuthStore } from '@/store/authStore'; import { AuthSession } from '@/types/auth'; +import { clearAccountSession } from '@/utils/accountSession'; type QueryValue = boolean | number | string | Array | null | undefined; @@ -18,7 +19,17 @@ type ApiErrorResponse = { }; }; +type ApiFileUploadOptions = { + fieldName?: string; + httpMethod?: 'POST' | 'PUT'; + idempotencyKey?: string; + mimeType: string; + parameters?: Record; +}; + let refreshSessionPromise: Promise | undefined; +const JSON_REQUEST_TIMEOUT_MS = 15_000; +const MULTIPART_REQUEST_TIMEOUT_MS = 30_000; export class ApiError extends Error { code?: string; @@ -129,7 +140,7 @@ async function refreshSession(baseUrl: string) { }); if (!response.ok) { - useAuthStore.getState().logoutLocal(); + clearAccountSession(); return undefined; } @@ -167,6 +178,11 @@ async function sendRequest( ...requestOptions } = options; const headers = new Headers(requestOptions.headers); + const abortController = requestOptions.signal ? undefined : new AbortController(); + const requestTimeout = setTimeout( + () => abortController?.abort(), + isFormDataBody(body) ? MULTIPART_REQUEST_TIMEOUT_MS : JSON_REQUEST_TIMEOUT_MS, + ); if (options.auth !== false && accessToken) { headers.set('Authorization', `Bearer ${accessToken}`); @@ -180,11 +196,25 @@ async function sendRequest( headers.set('Content-Type', 'application/json'); } - const response = await fetch(`${baseUrl}${path}${createQueryString(query)}`, { - ...requestOptions, - body: toRequestBody(body), - headers, - }); + let response: Response; + + try { + response = await fetch(`${baseUrl}${path}${createQueryString(query)}`, { + ...requestOptions, + body: toRequestBody(body), + headers, + signal: requestOptions.signal ?? abortController?.signal, + }); + } catch (error) { + if (abortController?.signal.aborted) { + throw new ApiError('서버 응답이 늦어 요청을 중단했어요. 다시 시도해주세요.', 408, 'TIMEOUT'); + } + + throw error; + } finally { + clearTimeout(requestTimeout); + } + const payload = await parseJsonResponse(response); if (!response.ok) { @@ -219,3 +249,86 @@ export async function requestApi(path: string, options: ApiRequestOptions = { throw error; } } + +async function sendFileUpload( + path: string, + fileUri: string, + options: ApiFileUploadOptions, + accessToken?: string, +) { + const baseUrl = getApiBaseUrl(); + + if (!baseUrl) { + throw new ApiError('서버 API URL이 설정되지 않았습니다.', 0, 'API_BASE_URL_MISSING'); + } + + const { File, UploadType } = await import('expo-file-system'); + const headers: Record = {}; + const abortController = new AbortController(); + const requestTimeout = setTimeout( + () => abortController.abort(), + MULTIPART_REQUEST_TIMEOUT_MS, + ); + + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + + if (options.idempotencyKey) { + headers['Idempotency-Key'] = options.idempotencyKey; + } + + try { + const result = await new File(fileUri).upload(`${baseUrl}${path}`, { + fieldName: options.fieldName ?? 'photo', + headers, + httpMethod: options.httpMethod ?? 'POST', + mimeType: options.mimeType, + parameters: options.parameters, + sessionType: 'foreground', + signal: abortController.signal, + uploadType: UploadType.MULTIPART, + }); + const payload = result.body ? (JSON.parse(result.body) as unknown) : undefined; + + if (result.status < 200 || result.status >= 300) { + const errorPayload = payload as ApiErrorResponse | undefined; + const message = + errorPayload?.error?.message ?? `API 요청에 실패했습니다. (${result.status})`; + + throw new ApiError(message, result.status, errorPayload?.error?.code); + } + + return unwrapData(payload); + } catch (error) { + if (abortController.signal.aborted) { + throw new ApiError('서버 응답이 늦어 요청을 중단했어요. 다시 시도해주세요.', 408, 'TIMEOUT'); + } + + throw error; + } finally { + clearTimeout(requestTimeout); + } +} + +export async function uploadApiFile( + path: string, + fileUri: string, + options: ApiFileUploadOptions, +) { + const accessToken = useAuthStore.getState().accessToken; + + try { + return await sendFileUpload(path, fileUri, options, accessToken); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + const nextAccessToken = await refreshSessionOnce(getApiBaseUrl() ?? ''); + + if (nextAccessToken) { + return sendFileUpload(path, fileUri, options, nextAccessToken); + } + } + + throw error; + } +} diff --git a/src/api/homeApi.ts b/src/api/homeApi.ts index c96811a..a9c20a7 100644 --- a/src/api/homeApi.ts +++ b/src/api/homeApi.ts @@ -27,7 +27,6 @@ export type MoodRecommendationParams = { recommendationMode?: MusicRecommendationMode; preferredGenres?: string[]; preferredMoods?: string[]; - topFilter?: string; travelStyles?: string[]; }; @@ -75,7 +74,6 @@ export const homeApi = { preferredGenres: params?.preferredGenres, preferredMoods: params?.preferredMoods, recommendationMode: params?.recommendationMode ?? 'everyday', - topFilter: params?.topFilter ?? '전체', travelStyles: params?.travelStyles, }, }); diff --git a/src/api/momentLogApi.ts b/src/api/momentLogApi.ts index 250c130..7a608b1 100644 --- a/src/api/momentLogApi.ts +++ b/src/api/momentLogApi.ts @@ -2,10 +2,21 @@ import { createIdempotencyKey, requestApi, shouldAttemptAuthenticatedApi, + uploadApiFile, } from '@/api/client'; -import { GeoPoint, MomentLog, MoodTag, Track, TravelMode } from '@/types/domain'; +import { + GeoPoint, + MomentLog, + MoodTag, + RecapTemplateId, + RecapVisibility, + Track, + TravelMode, +} from '@/types/domain'; import { sanitizeTrack } from '@/utils/trackSanitizer'; +const RECAP_CAPTURE_API_PATH = '/v1/recap-captures'; + export type CreateMomentLogInput = { createdAt: string; idempotencyKey?: string; @@ -16,7 +27,9 @@ export type CreateMomentLogInput = { placeCategory?: string; placeId?: string; placeName?: string; + recapVisibility?: RecapVisibility; sessionId?: string; + templateId?: RecapTemplateId; track?: Track; travelMode?: TravelMode; }; @@ -35,59 +48,67 @@ export type UpdateMomentLogInput = { placeCategory?: string | null; placeId?: string | null; placeName?: string | null; + recapVisibility?: RecapVisibility; sessionId?: string | null; + templateId?: RecapTemplateId; track?: Track; travelMode?: TravelMode | null; }; -function appendIfDefined(formData: FormData, key: string, value?: number | string) { - if (value === undefined || value === null || value === '') { - return; - } - - formData.append(key, String(value)); -} - function getPhotoName(photoUri: string) { return photoUri.split('/').pop() || `moment-${Date.now()}.jpg`; } -function toFormData(input: CreateMomentLogInput) { - const formData = new FormData(); +function getPhotoContentType(photoUri: string) { + const extension = getPhotoName(photoUri).split('.').pop()?.toLowerCase(); - if (input.photoUri) { - formData.append('photo', { - name: getPhotoName(input.photoUri), - type: 'image/jpeg', - uri: input.photoUri, - } as unknown as Blob); + if (extension === 'png') { + return 'image/png'; } - formData.append('createdAt', input.createdAt); - formData.append('moodTags', input.moodTags.join(',')); - - appendIfDefined(formData, 'artistName', input.track?.artist); - appendIfDefined(formData, 'lat', input.location?.lat); - appendIfDefined(formData, 'lng', input.location?.lng); - appendIfDefined(formData, 'note', input.note); - appendIfDefined(formData, 'placeCategory', input.placeCategory); - appendIfDefined(formData, 'placeId', input.placeId); - appendIfDefined(formData, 'placeName', input.placeName); - appendIfDefined(formData, 'sessionId', input.sessionId); - appendIfDefined(formData, 'trackId', input.track?.id); - appendIfDefined(formData, 'trackTitle', input.track?.title); - appendIfDefined(formData, 'travelMode', input.travelMode); - return formData; + if (extension === 'webp') { + return 'image/webp'; + } + + return 'image/jpeg'; } -function toPhotoFormData(photoUri: string) { +function toCreateParameters(input: CreateMomentLogInput) { + const parameters: Record = { + createdAt: input.createdAt, + moodTags: input.moodTags.join(','), + }; + const optionalParameters = { + artistName: input.track?.artist, + lat: input.location?.lat, + lng: input.location?.lng, + note: input.note, + placeCategory: input.placeCategory, + placeId: input.placeId, + placeName: input.placeName, + sessionId: input.sessionId, + templateId: input.templateId, + trackId: input.track?.id, + trackTitle: input.track?.title, + travelMode: input.travelMode, + visibility: input.recapVisibility, + }; + + Object.entries(optionalParameters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + parameters[key] = String(value); + } + }); + + return parameters; +} + +function toFormData(input: CreateMomentLogInput) { const formData = new FormData(); - formData.append('photo', { - name: getPhotoName(photoUri), - type: 'image/jpeg', - uri: photoUri, - } as unknown as Blob); + Object.entries(toCreateParameters(input)).forEach(([key, value]) => { + formData.append(key, value); + }); return formData; } @@ -128,6 +149,10 @@ function toUpdateBody(input: UpdateMomentLogInput) { body.sessionId = input.sessionId; } + if ('templateId' in input) { + body.templateId = input.templateId; + } + if ('track' in input) { body.artistName = input.track?.artist; body.trackId = input.track?.id; @@ -138,6 +163,10 @@ function toUpdateBody(input: UpdateMomentLogInput) { body.travelMode = input.travelMode; } + if ('recapVisibility' in input) { + body.visibility = input.recapVisibility; + } + return body; } @@ -149,23 +178,32 @@ function sanitizeMomentLog(log: MomentLog) { } export const momentLogApi = { - createMomentLog: (input: CreateMomentLogInput) => { + createMomentLog: async (input: CreateMomentLogInput) => { if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } - return requestApi('/v1/moment-logs', { - body: toFormData(input), - idempotencyKey: input.idempotencyKey ?? createIdempotencyKey('moment-log'), - method: 'POST', - }).then(sanitizeMomentLog); + const idempotencyKey = input.idempotencyKey ?? createIdempotencyKey('recap-capture'); + const request = input.photoUri + ? uploadApiFile(RECAP_CAPTURE_API_PATH, input.photoUri, { + idempotencyKey, + mimeType: getPhotoContentType(input.photoUri), + parameters: toCreateParameters(input), + }) + : requestApi(RECAP_CAPTURE_API_PATH, { + body: toFormData(input), + idempotencyKey, + method: 'POST', + }); + + return request.then(sanitizeMomentLog); }, getMomentLogs: async (params: MomentLogListParams = {}) => { if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve([]); } - const logs = await requestApi('/v1/moment-logs', { + const logs = await requestApi(RECAP_CAPTURE_API_PATH, { query: params, }); @@ -176,7 +214,7 @@ export const momentLogApi = { return Promise.resolve(undefined); } - return requestApi<{ accepted: boolean }>(`/v1/moment-logs/${momentLogId}`, { + return requestApi<{ accepted: boolean }>(`${RECAP_CAPTURE_API_PATH}/${momentLogId}`, { method: 'DELETE', }).then((response) => response.accepted); }, @@ -185,27 +223,31 @@ export const momentLogApi = { return Promise.resolve(undefined); } - return requestApi(`/v1/moment-logs/${momentLogId}`, { + return requestApi(`${RECAP_CAPTURE_API_PATH}/${momentLogId}`, { body: toUpdateBody(input), method: 'PATCH', }).then(sanitizeMomentLog); }, - updateMomentLogPhoto: (momentLogId: string, photoUri: string) => { + updateMomentLogPhoto: async (momentLogId: string, photoUri: string) => { if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } - return requestApi(`/v1/moment-logs/${momentLogId}/photo`, { - body: toPhotoFormData(photoUri), - method: 'PUT', - }).then(sanitizeMomentLog); + return uploadApiFile( + `${RECAP_CAPTURE_API_PATH}/${momentLogId}/photo`, + photoUri, + { + httpMethod: 'PUT', + mimeType: getPhotoContentType(photoUri), + }, + ).then(sanitizeMomentLog); }, deleteMomentLogPhoto: (momentLogId: string) => { if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } - return requestApi(`/v1/moment-logs/${momentLogId}/photo`, { + return requestApi(`${RECAP_CAPTURE_API_PATH}/${momentLogId}/photo`, { method: 'DELETE', }).then(sanitizeMomentLog); }, diff --git a/src/api/playlistQueries.ts b/src/api/playlistQueries.ts index 45e47e8..a8f1baa 100644 --- a/src/api/playlistQueries.ts +++ b/src/api/playlistQueries.ts @@ -1,15 +1,40 @@ import { useQuery } from '@tanstack/react-query'; -import { playlistApi } from '@/api/playlistApi'; +import { + playlistApi, + type ContextualPlaylistInput, +} from '@/api/playlistApi'; + +type PlaylistQueryOptions = { + enabled?: boolean; +}; export const playlistQueryKeys = { detail: (id?: string) => ['playlist', 'detail', id ?? 'fallback'] as const, + recommended: (input: ContextualPlaylistInput) => + ['playlist', 'recommended', input] as const, }; -export function usePlaylistCurationQuery(id?: string) { +export function usePlaylistCurationQuery( + id?: string, + options: PlaylistQueryOptions = {}, +) { return useQuery({ + enabled: options.enabled ?? true, queryFn: () => playlistApi.getPlaylist(id), queryKey: playlistQueryKeys.detail(id), staleTime: 5 * 60 * 1000, }); } + +export function useRecommendedPlaylistQuery( + input: ContextualPlaylistInput, + options: PlaylistQueryOptions = {}, +) { + return useQuery({ + enabled: options.enabled ?? Boolean(input.location), + queryFn: () => playlistApi.getRecommendedPlaylist(input), + queryKey: playlistQueryKeys.recommended(input), + staleTime: 2 * 60 * 1000, + }); +} diff --git a/src/api/recapApi.ts b/src/api/recapApi.ts index 078626e..926b731 100644 --- a/src/api/recapApi.ts +++ b/src/api/recapApi.ts @@ -3,20 +3,81 @@ import { requestApi, shouldAttemptAuthenticatedApi, } from '@/api/client'; -import type { RecapItem, RecapShare, RecapTemplateId } from '@/types/domain'; +import type { + RecapItem, + RecapMapMarker, + RecapMapScope, + RecapShare, + RecapTemplateId, + RecapVisibility, + RoutePoint, +} from '@/types/domain'; import { sanitizeRecapItem } from '@/utils/trackSanitizer'; type CreateRecapInput = { momentLogIds?: string[]; representativeTrackId?: string; + routePoints?: RoutePoint[]; sessionId?: string; templateId: RecapTemplateId | 'video'; title?: string; + visibility?: RecapVisibility; }; type RecapShareEventType = 'os_share' | 'save_image'; +type RecapMarkerQuery = { + lat?: number; + lng?: number; + radiusMeters?: number; + scope?: RecapMapScope; +}; + +export type RecapListScope = 'all' | 'mine' | 'others'; + +type RecapListQuery = { + limit?: number; + scope?: RecapListScope; +}; + export const recapApi = { + getRecapMarkers: async (query: RecapMarkerQuery) => { + if (!shouldAttemptAuthenticatedApi()) { + return Promise.resolve([]); + } + + return requestApi('/v1/recap-markers', { query }); + }, + updateRecapVisibility: async (recapId: string, visibility: RecapVisibility) => { + if (!shouldAttemptAuthenticatedApi()) { + return Promise.resolve(undefined); + } + + const recap = await requestApi( + `/v1/recaps/${encodeURIComponent(recapId)}/visibility`, + { + body: { visibility }, + method: 'PATCH', + }, + ); + + return sanitizeRecapItem(recap); + }, + updateRecapThumbnail: async (recapId: string, momentId: string) => { + if (!shouldAttemptAuthenticatedApi()) { + return Promise.resolve(undefined); + } + + const recap = await requestApi( + `/v1/recaps/${encodeURIComponent(recapId)}/thumbnail`, + { + body: { momentId }, + method: 'PATCH', + }, + ); + + return sanitizeRecapItem(recap); + }, createShareEvent: async (recapId: string, type: RecapShareEventType) => { if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve({ accepted: false }); @@ -34,26 +95,30 @@ export const recapApi = { }, ); }, - createRecap: async (input: CreateRecapInput) => { + createRecap: async (input: CreateRecapInput, idempotencyKey?: string) => { if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } const recap = await requestApi('/v1/recaps', { body: input, - idempotencyKey: createIdempotencyKey(`recap-${input.sessionId ?? 'session'}`), + idempotencyKey: + idempotencyKey ?? createIdempotencyKey(`recap-${input.sessionId ?? 'session'}`), method: 'POST', }); return sanitizeRecapItem(recap); }, - getRecapList: async () => { + getRecapList: async (query: RecapListQuery = {}) => { if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve([]); } const recaps = await requestApi('/v1/recaps', { - query: { limit: 20 }, + query: { + limit: query.limit ?? 20, + scope: query.scope ?? 'mine', + }, }); return recaps.map(sanitizeRecapItem); diff --git a/src/api/recapQueries.ts b/src/api/recapQueries.ts index 818103b..841c950 100644 --- a/src/api/recapQueries.ts +++ b/src/api/recapQueries.ts @@ -1,9 +1,10 @@ import { useQuery } from '@tanstack/react-query'; -import { recapApi } from '@/api/recapApi'; +import { recapApi, type RecapListScope } from '@/api/recapApi'; export const recapQueryKeys = { - list: ['recaps', 'list'] as const, + lists: ['recaps', 'list'] as const, + list: (scope: RecapListScope = 'mine') => ['recaps', 'list', scope] as const, share: (id?: string) => ['recaps', 'share', id ?? 'fallback'] as const, }; @@ -11,10 +12,10 @@ type RecapShareQueryOptions = { enabled?: boolean; }; -export function useRecapListQuery() { +export function useRecapListQuery(scope: RecapListScope = 'mine') { return useQuery({ - queryFn: recapApi.getRecapList, - queryKey: recapQueryKeys.list, + queryFn: () => recapApi.getRecapList({ scope }), + queryKey: recapQueryKeys.list(scope), staleTime: 5 * 60 * 1000, }); } diff --git a/src/api/tourApi.ts b/src/api/tourApi.ts index 8ffa02d..cc6c683 100644 --- a/src/api/tourApi.ts +++ b/src/api/tourApi.ts @@ -6,9 +6,36 @@ type NearbyPlacesParams = { radiusMeters?: number; }; +type SearchPlacesParams = { + limit?: number; + query: string; +}; + +type ReverseGeocodeParams = { + location: GeoPoint; +}; + const DEFAULT_RADIUS_METERS = 2000; export const tourApi = { + async reverseGeocodeLocation( + params: ReverseGeocodeParams, + ): Promise { + return requestApi('/v1/tour/reverse-geocode', { + query: { + lat: params.location.lat, + lng: params.location.lng, + }, + }); + }, + async searchPlaces(params: SearchPlacesParams): Promise { + return requestApi('/v1/tour/places', { + query: { + limit: params.limit ?? 10, + query: params.query.trim(), + }, + }); + }, async getNearbyPlaces(params: NearbyPlacesParams): Promise { return requestApi('/v1/tour/nearby-places', { query: { diff --git a/src/api/tourQueries.ts b/src/api/tourQueries.ts index 154807d..e47941a 100644 --- a/src/api/tourQueries.ts +++ b/src/api/tourQueries.ts @@ -3,6 +3,23 @@ import { useQuery } from '@tanstack/react-query'; import { tourApi } from '@/api/tourApi'; import { GeoPoint } from '@/types/domain'; +type PlaceSearchQueryParams = { + enabled?: boolean; + query: string; +}; + +export function usePlaceSearchQuery({ enabled = true, query }: PlaceSearchQueryParams) { + const normalizedQuery = query.trim(); + + return useQuery({ + enabled: enabled && normalizedQuery.length > 0, + queryFn: () => tourApi.searchPlaces({ query: normalizedQuery }), + queryKey: ['tour', 'place-search', normalizedQuery], + retry: 1, + staleTime: 1000 * 60 * 5, + }); +} + type NearbyPlacesQueryParams = { enabled: boolean; location?: GeoPoint; @@ -32,3 +49,30 @@ export function useNearbyPlacesQuery({ staleTime: 1000 * 60 * 5, }); } + +type ReverseGeocodedPlaceQueryParams = { + enabled: boolean; + location?: GeoPoint; +}; + +export function useReverseGeocodedPlaceQuery({ + enabled, + location, +}: ReverseGeocodedPlaceQueryParams) { + return useQuery({ + enabled: enabled && Boolean(location), + gcTime: 1000 * 60 * 60 * 24, + queryFn: () => + tourApi.reverseGeocodeLocation({ location: location as GeoPoint }), + queryKey: [ + 'tour', + 'reverse-geocode', + { + lat: location?.lat, + lng: location?.lng, + }, + ], + retry: 1, + staleTime: 1000 * 60 * 60 * 24, + }); +} diff --git a/src/api/travelSessionApi.ts b/src/api/travelSessionApi.ts index a0a5904..4b03ce8 100644 --- a/src/api/travelSessionApi.ts +++ b/src/api/travelSessionApi.ts @@ -1,9 +1,10 @@ import { requestApi, shouldAttemptAuthenticatedApi } from '@/api/client'; -import type { GeoPoint, TravelMode } from '@/types/domain'; +import type { GeoPoint, RoutePoint, TravelMode } from '@/types/domain'; export type TravelSessionDto = { endedAt?: string; id: string; + routePoints?: RoutePoint[]; startedAt?: string; status: 'active' | 'ended'; travelMode?: TravelMode; @@ -12,6 +13,7 @@ export type TravelSessionDto = { export const travelSessionApi = { createTravelSession: async (input: { location?: GeoPoint; + routePoints?: RoutePoint[]; startedAt?: string; travelMode?: TravelMode; }) => { @@ -30,6 +32,7 @@ export const travelSessionApi = { input: { endedAt?: string; location?: GeoPoint; + routePoints?: RoutePoint[]; } = {}, ) => { if (!shouldAttemptAuthenticatedApi()) { @@ -40,9 +43,31 @@ export const travelSessionApi = { body: { endedAt: input.endedAt, location: input.location, + routePoints: input.routePoints, status: 'ended', }, method: 'PATCH', }); }, + + syncTravelSessionRoute: async ( + sessionId: string, + input: { + location?: GeoPoint; + routePoints: RoutePoint[]; + }, + ) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + return requestApi(`/v1/travel-sessions/${encodeURIComponent(sessionId)}`, { + body: { + location: input.location, + routePoints: input.routePoints, + status: 'active', + }, + method: 'PATCH', + }); + }, }; diff --git a/src/components/Chip.tsx b/src/components/Chip.tsx index eae0afe..9c5d2ac 100644 --- a/src/components/Chip.tsx +++ b/src/components/Chip.tsx @@ -14,6 +14,8 @@ export function Chip({ label, onPress, selected = false, size = 'default' }: Chi return ( void; }; -export function IconButton({ label, name, onPress }: IconButtonProps) { +export function IconButton({ + disabled = false, + label, + name, + onPress, +}: IconButtonProps) { return ( ({ + opacity: disabled ? 0.42 : pressed ? 0.64 : 1, + })} > diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 444be0e..51b3778 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -1,45 +1,41 @@ -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'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +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"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { libraryApi } from '@/api/libraryApi'; -import { syncRecommendationEvent } from '@/api/recommendationEventApi'; -import { AppText } from '@/components/AppText'; -import { TrackActionMenu } from '@/components/playlist/TrackActionMenu'; -import { getMiniPlayerBottom } from '@/constants/layout'; -import { useLibraryStore } from '@/store/libraryStore'; -import { usePlayerStore } from '@/store/playerStore'; -import { useRecommendationEventStore } from '@/store/recommendationEventStore'; +import { libraryApi } from "@/api/libraryApi"; +import { syncRecommendationEvent } from "@/api/recommendationEventApi"; +import { AppText } from "@/components/AppText"; +import { TrackActionMenu } from "@/components/playlist/TrackActionMenu"; +import { getMiniPlayerBottom } from "@/constants/layout"; +import { useLibraryStore } from "@/store/libraryStore"; +import { usePlayerStore } from "@/store/playerStore"; +import { useRecommendationEventStore } from "@/store/recommendationEventStore"; import { getExternalMusicLinks, openExternalMusicLink, type ExternalMusicLink, -} from '@/utils/externalMusicLinks'; -import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; -import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals'; +} from "@/utils/externalMusicLinks"; +import { createRecommendationEventContext } from "@/utils/recommendationEventContext"; +import { getTrackKeyColor, hexToRgba } from "@/utils/trackVisuals"; const webGlassPlayerStyle = { - backdropFilter: 'blur(30px) saturate(170%)', - WebkitBackdropFilter: 'blur(30px) saturate(170%)', + backdropFilter: "blur(30px) saturate(170%)", + WebkitBackdropFilter: "blur(30px) saturate(170%)", }; export function MiniPlayer() { const insets = useSafeAreaInsets(); const router = useRouter(); - const { - currentTrack, - playNext, - playPrevious, - playlist, - playlistId, - queue, - } = usePlayerStore(); + const { currentTrack, playNext, playPrevious, playlist, playlistId, queue } = + usePlayerStore(); const { isLiked, isSaved, setLikeState, setSaveState } = useLibraryStore(); - const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); + const addRecommendationEvent = useRecommendationEventStore( + (state) => state.addEvent, + ); const [actionMessage, setActionMessage] = useState(); const [isActionMenuVisible, setIsActionMenuVisible] = useState(false); const [isFullPlayerVisible, setIsFullPlayerVisible] = useState(false); @@ -62,20 +58,20 @@ export function MiniPlayer() { setLikeState(currentTrack, !liked, playlistId, playlist); void libraryApi .updateTrackState(currentTrack.id, { - action: liked ? 'unlike' : 'like', + action: liked ? "unlike" : "like", context, playlistId, }) .catch(() => { setLikeState(currentTrack, liked, playlistId, playlist); - setActionMessage('서버 저장에 실패해서 좋아요 상태를 되돌렸어요.'); + setActionMessage("서버 저장에 실패해서 좋아요 상태를 되돌렸어요."); }); syncRecommendationEvent( addRecommendationEvent({ context, playlistId, trackId: currentTrack.id, - type: liked ? 'track_unlike' : 'track_like', + type: liked ? "track_unlike" : "track_like", }), ); }; @@ -86,20 +82,20 @@ export function MiniPlayer() { setSaveState(currentTrack, !saved, playlistId, playlist); void libraryApi .updateTrackState(currentTrack.id, { - action: saved ? 'unsave' : 'save', + action: saved ? "unsave" : "save", context, playlistId, }) .catch(() => { setSaveState(currentTrack, saved, playlistId, playlist); - setActionMessage('서버 저장에 실패해서 저장 상태를 되돌렸어요.'); + setActionMessage("서버 저장에 실패해서 저장 상태를 되돌렸어요."); }); syncRecommendationEvent( addRecommendationEvent({ context, playlistId, trackId: currentTrack.id, - type: saved ? 'track_unsave' : 'track_save', + type: saved ? "track_unsave" : "track_save", }), ); }; @@ -119,11 +115,10 @@ export function MiniPlayer() { }; const handleCaptureMoment = () => { setIsFullPlayerVisible(false); - router.push('/camera'); - }; - const handleOpenLiveSoundMap = () => { - setIsFullPlayerVisible(false); - router.push('/travel'); + router.push({ + params: { returnTo: "music" }, + pathname: "/camera", + } as never); }; const handleOpenExternalLink = async (link: ExternalMusicLink) => { const context = createRecommendationEventContext(); @@ -134,25 +129,29 @@ export function MiniPlayer() { context, playlistId, trackId: currentTrack.id, - type: 'track_external_open', + type: "track_external_open", value: link.id, }), ); try { await openExternalMusicLink(link); - setActionMessage(`${link.label} 링크를 열었어요. 돌아오면 이 곡을 기록할 수 있어요.`); + setActionMessage( + `${link.label} 링크를 열었어요. 돌아오면 이 곡을 기록할 수 있어요.`, + ); } catch { syncRecommendationEvent( addRecommendationEvent({ context, playlistId, trackId: currentTrack.id, - type: 'external_music_open_failed', + type: "external_music_open_failed", value: link.id, }), ); - setActionMessage('외부 음악 링크를 열지 못했어요. 웹 검색을 다시 시도해보세요.'); + setActionMessage( + "외부 음악 링크를 열지 못했어요. 웹 검색을 다시 시도해보세요.", + ); } }; const renderCover = (sizeClassName: string, radiusClassName: string) => ( @@ -160,13 +159,20 @@ export function MiniPlayer() { className={`overflow-hidden border ${radiusClassName} ${sizeClassName}`} style={{ backgroundColor: hexToRgba(keyColor, 0.24), - borderColor: 'rgba(255,255,255,0.24)', + borderColor: "rgba(255,255,255,0.24)", }} > {currentTrack.albumImageUrl ? ( - + ) : ( - + )} ); @@ -174,16 +180,16 @@ export function MiniPlayer() { {currentTrack.albumImageUrl ? ( - + ) : ( - + )} @@ -226,25 +239,29 @@ export function MiniPlayer() { setIsFullPlayerVisible(true)} > - {renderCover('h-[68px] w-[68px]', 'rounded-[22px]')} + {renderCover("h-[68px] w-[68px]", "rounded-[22px]")} - + {currentTrack.title} - + {currentTrack.artist} - + SoundLog 음악으로 선택됨 @@ -315,16 +341,16 @@ export function MiniPlayer() { - + {currentTrack.title} - + {currentTrack.artist} @@ -380,14 +412,20 @@ export function MiniPlayer() { 선택한 곡 - + {currentTrack.title} - + {currentTrack.artist} - SoundLog 안에서 현재 여행 음악으로 선택됐어요 + SoundLog에서 현재 선택한 음악이에요 @@ -405,11 +443,18 @@ export function MiniPlayer() { > - + {link.label} - + ))} @@ -428,7 +473,7 @@ export function MiniPlayer() { - - - - Live Sound Map 열기 - - - 음원을 재생하지 않고 곡 정보와 여행 순간을 SoundLog 안에 기록하거나, 여행 모드에서 지도에 공개해요. + Soundlog는 음원을 직접 재생하지 않아요. 위 외부 음악 앱에서 + 감상하고, 이 곡과 장소는 리캡으로 남길 수 있어요. - + - + diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx new file mode 100644 index 0000000..b12b6d9 --- /dev/null +++ b/src/components/PageHeader.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react'; +import { View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +type PageHeaderProps = { + leftContent?: ReactNode; + rightContent?: ReactNode; + title: string; +}; + +export function PageHeader({ leftContent, rightContent, title }: PageHeaderProps) { + return ( + + {leftContent ? {leftContent} : null} + + {title} + + {rightContent ? {rightContent} : null} + + ); +} diff --git a/src/components/SectionTitle.tsx b/src/components/SectionTitle.tsx new file mode 100644 index 0000000..73385ac --- /dev/null +++ b/src/components/SectionTitle.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import { View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +type SectionTitleProps = { + rightContent?: ReactNode; + title: string; +}; + +export function SectionTitle({ rightContent, title }: SectionTitleProps) { + return ( + + + {title} + + {rightContent ? {rightContent} : null} + + ); +} diff --git a/src/components/SettingsRow.tsx b/src/components/SettingsRow.tsx new file mode 100644 index 0000000..f059854 --- /dev/null +++ b/src/components/SettingsRow.tsx @@ -0,0 +1,103 @@ +import { Feather } from '@expo/vector-icons'; +import type { ComponentProps, ReactNode } from 'react'; +import { Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +type FeatherIconName = ComponentProps['name']; + +export type SettingsRowProps = { + accessibilityLabel?: string; + description?: string; + disabled?: boolean; + icon: FeatherIconName; + label: string; + onPress?: () => void; + rightContent?: ReactNode; + rightText?: string; + tone?: 'danger' | 'default'; +}; + +export function SettingsRow({ + accessibilityLabel, + description, + disabled = false, + icon, + label, + onPress, + rightContent, + rightText, + tone = 'default', +}: SettingsRowProps) { + const content = ( + <> + + + + + + {label} + + {description ? ( + + {description} + + ) : null} + + {rightContent ? ( + {rightContent} + ) : null} + {rightText ? ( + + {rightText} + + ) : null} + {onPress ? ( + + ) : null} + + ); + + if (!onPress) { + return ( + {content} + ); + } + + return ( + ({ + opacity: disabled ? 0.45 : pressed ? 0.62 : 1, + })} + > + {content} + + ); +} diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index 89b51a0..92c0708 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -12,6 +12,8 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { authApi } from '@/api/authApi'; +import { getApiBaseUrl } from '@/api/client'; import { queryClient } from '@/providers/queryClient'; import { AppText } from '@/components/AppText'; import { playlistCurationById } from '@/mocks/playlistMocks'; @@ -56,7 +58,7 @@ const placePresets: Array<{ category: '야경', contentType: '문화시설', id: 'dev-seoul-night', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', location: { lat: 37.5512, lng: 126.9882 }, overview: '도시 야경과 감성 음악 추천을 테스트하는 개발용 장소입니다.', source: 'seed', @@ -65,7 +67,6 @@ const placePresets: Array<{ }, ]; -const topFilterOptions = ['전체', '근처', '지역 트렌드', '내 취향', '저장 많은']; const moodFilterOptions = ['전체', '잔잔한', '신나는', '시원한', '설레는', '감성적인']; const travelModeOptions: Array<{ label: string; value: TravelMode }> = [ { label: '산책', value: 'walk' }, @@ -169,6 +170,8 @@ function DevTestManagerContent() { const insets = useSafeAreaInsets(); const { height, width } = useWindowDimensions(); const [isOpen, setIsOpen] = useState(false); + const [isServerAuthPending, setIsServerAuthPending] = useState(false); + const [serverAuthMessage, setServerAuthMessage] = useState(); const maxX = Math.max(width - BUTTON_SIZE - 12, 12); const maxY = Math.max(height - BUTTON_SIZE - Math.max(insets.bottom, 12) - 12, 80); const defaultPosition = useMemo( @@ -207,8 +210,7 @@ function DevTestManagerContent() { [maxX, maxY, pan], ); - const { selectedMoodFilter, selectedTopFilter, setSelectedMoodFilter, setSelectedTopFilter } = - useHomeFilterStore(); + const { selectedMoodFilter, setSelectedMoodFilter } = useHomeFilterStore(); const { currentLocation, currentPlace, locationStatus, selectedMode, session } = useTravelSessionStore(); const clearLocation = useTravelSessionStore((state) => state.clearLocation); @@ -219,7 +221,7 @@ function DevTestManagerContent() { const setMode = useTravelSessionStore((state) => state.setMode); const setPlace = useTravelSessionStore((state) => state.setPlace); const startSession = useTravelSessionStore((state) => state.startSession); - const { completeOnboarding, resetOnboarding } = useUserProfileStore(); + const { completeOnboarding, resetOnboarding, updateProfile } = useUserProfileStore(); const { finishLogin, logoutLocal, status: authStatus, user: authUser } = useAuthStore(); const { logs, addLog, removeLog } = useMomentLogStore(); const { likedTracks, savedTracks, removeLikedTrack, removeSavedTrack, toggleLike, toggleSave } = @@ -249,6 +251,45 @@ function DevTestManagerContent() { setIsOpen(false); router.replace('/' as never); }; + const applyServerTestLogin = async () => { + if (isServerAuthPending) { + return; + } + + setIsServerAuthPending(true); + setServerAuthMessage('서버 로그인 중...'); + + try { + const credentials = { + email: 'local-demo@soundlog.test', + password: 'soundlog123', + }; + let session: AuthSession; + + try { + session = await authApi.register({ + ...credentials, + displayName: '로컬데모', + }); + } catch { + session = await authApi.login(credentials); + } + + finishLogin(session); + + if (session.profile?.completedOnboarding) { + updateProfile(session.profile); + } + + setServerAuthMessage('서버 JWT 로그인 완료'); + setIsOpen(false); + router.replace('/' as never); + } catch (error) { + setServerAuthMessage(error instanceof Error ? error.message : '서버 로그인 실패'); + } finally { + setIsServerAuthPending(false); + } + }; const applyLogout = () => { logoutLocal(); setIsOpen(false); @@ -262,13 +303,11 @@ function DevTestManagerContent() { preferredMoods: ['시원한', '잔잔한'], travelStyles: ['산책', '바다 보기'], }); - setSelectedTopFilter('전체'); setSelectedMoodFilter('시원한'); router.replace('/' as never); }; const resetProfile = () => { resetOnboarding(); - setSelectedTopFilter('전체'); setSelectedMoodFilter('전체'); setIsOpen(false); router.replace('/onboarding' as never); @@ -321,7 +360,7 @@ function DevTestManagerContent() { id: `dev-moment-seoul-${baseTime}`, location: placePresets[1].location, moodTags: ['emotional'], - photoUri: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + photoUri: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', placeCategory: '야경', placeId: placePresets[1].place.id, placeName: '남산서울타워', @@ -411,12 +450,12 @@ function DevTestManagerContent() { subtitle={`세션 ${session.status} · 위치 ${locationStatus} · 로그 ${logs.length}개 · 이벤트 ${events.length}개`} title="현재 상태" > - + @@ -434,11 +473,15 @@ function DevTestManagerContent() { navigate('/recap')} /> navigate('/recap-share/log-1')} /> navigate('/library')} /> - navigate('/my')} /> navigate('/camera')} /> + {authProviderOptions.map((provider) => ( ))} + {serverAuthMessage ? : null} @@ -456,14 +500,6 @@ function DevTestManagerContent() { - {topFilterOptions.map((filter) => ( - setSelectedTopFilter(filter)} - /> - ))} {moodFilterOptions.map((filter) => ( - + ); } diff --git a/src/components/home/CurrentSoundtrackCard.tsx b/src/components/home/CurrentSoundtrackCard.tsx index be8824c..268f405 100644 --- a/src/components/home/CurrentSoundtrackCard.tsx +++ b/src/components/home/CurrentSoundtrackCard.tsx @@ -1,8 +1,10 @@ -import { Feather } from '@expo/vector-icons'; -import { Pressable, View } from 'react-native'; +import { View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; import type { FeaturedPlaylist, PlaceContext } from '@/types/domain'; +import { getPlaceDisplayTitle } from '@/utils/placeLabel'; type CurrentSoundtrackCardProps = { cachedAt?: string; @@ -10,44 +12,72 @@ type CurrentSoundtrackCardProps = { isCached?: boolean; isError?: boolean; isLoading?: boolean; + isOpeningPlaylist?: boolean; moodLabel: string; - onCaptureMoment: () => void; + needsLocation?: boolean; onOpenPlaylist: (playlist: FeaturedPlaylist) => void; onRetry?: () => void; playlist?: FeaturedPlaylist; - travelLabel: string; + placeLabel: string; + recommendationSource?: string; }; +function getRecommendationSourceLabel(source?: string) { + if (source === 'ml-recommendation') { + return '맞춤 추천'; + } + + if (source === 'seed-fallback') { + return '기본 추천'; + } + + return undefined; +} + export function CurrentSoundtrackCard({ cachedAt, currentPlace, isCached = false, isError = false, isLoading = false, + isOpeningPlaylist = false, moodLabel, - onCaptureMoment, + needsLocation = false, onOpenPlaylist, onRetry, playlist, - travelLabel, + placeLabel, + recommendationSource, }: CurrentSoundtrackCardProps) { - const placeTitle = currentPlace?.title ?? '지금 위치 주변'; - const placeCaption = currentPlace?.address ?? currentPlace?.category ?? '장소를 확인하며 추천을 준비 중'; - const playlistTitle = playlist?.regionName ?? `${travelLabel} 사운드트랙`; + const placeTitle = getPlaceDisplayTitle(currentPlace); + const placeCaption = + currentPlace?.address ?? + currentPlace?.category ?? + '장소를 확인하며 추천을 준비 중이에요.'; + const playlistTitle = + recommendationSource === 'seed-fallback' && currentPlace + ? `${placeTitle} 사운드트랙` + : playlist?.regionName ?? `${placeLabel} 사운드트랙`; const playlistDescription = playlist?.description ?? - '현재 장소와 무드를 기준으로 오늘 들을 곡 목록을 준비하고 있어요.'; + (needsLocation + ? '위치를 확인하면 지금 상황에 맞춘 곡 목록을 준비할게요.' + : '현재 장소 맥락으로 오늘 들을 곡 목록을 준비하고 있어요.'); const trackMeta = playlist ? `${playlist.trackCount}곡 · ${playlist.durationText}` : isLoading - ? '추천 준비 중' - : '샘플 추천 사용 가능'; - const cacheCaption = cachedAt - ? `최근 추천 · ${new Date(cachedAt).toLocaleTimeString('ko-KR', { + ? '준비 중' + : needsLocation + ? '위치 필요' + : '다시 시도'; + const sourceLabel = getRecommendationSourceLabel(recommendationSource); + const cacheLabel = cachedAt + ? `최근 ${new Date(cachedAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', })}` : '최근 추천'; + const sectionStatus = sourceLabel ?? (isCached ? cacheLabel : undefined); const handleOpenPlaylist = () => { if (playlist) { @@ -59,107 +89,38 @@ export function CurrentSoundtrackCard({ }; return ( - - - - - - - - 상태 - - {travelLabel} - - - 무드 - {moodLabel} - - {isCached ? ( - - - {cacheCaption} - - - ) : null} - - - 오늘의 사운드트랙 + + + {sectionStatus} - - {placeTitle} - - - - - - - - - - - - - - - {playlistTitle} - - - {playlistDescription} - - - - - - - - {trackMeta} - - - {isError ? '추천을 다시 시도할 수 있어요' : placeCaption} - - - - - - 곡 보기 - - - - - - - - - 기록 - - - - 다시 추천 - - - + ) : undefined + } + title="오늘의 사운드트랙" + /> + + + ); } diff --git a/src/components/home/FeaturedPlaylistCard.tsx b/src/components/home/FeaturedPlaylistCard.tsx index 2ebeffe..614762f 100644 --- a/src/components/home/FeaturedPlaylistCard.tsx +++ b/src/components/home/FeaturedPlaylistCard.tsx @@ -1,4 +1,3 @@ -import { router } from 'expo-router'; import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; @@ -6,29 +5,26 @@ import { Chip } from '@/components/Chip'; import { FeaturedPlaylist } from '@/types/domain'; type FeaturedPlaylistCardProps = { - onPress?: (playlist: FeaturedPlaylist) => void; + onPress: (playlist: FeaturedPlaylist) => void; playlist: FeaturedPlaylist; }; export function FeaturedPlaylistCard({ onPress, playlist }: FeaturedPlaylistCardProps) { return ( { - if (onPress) { - onPress(playlist); - return; - } - - router.push(`/playlist/${playlist.id}`); - }} + accessibilityLabel={`${playlist.regionName} 플레이리스트 열기`} + accessibilityRole="button" + className="mr-4 h-[260px] w-[180px] justify-end overflow-hidden rounded-[8px] border border-white/10 bg-soundlog-card p-4" + onPress={() => onPress(playlist)} > - {playlist.regionName} + + {playlist.regionName} + {playlist.description} ); diff --git a/src/components/home/FeaturedPlaylistSection.tsx b/src/components/home/FeaturedPlaylistSection.tsx index 9b04625..f45fb16 100644 --- a/src/components/home/FeaturedPlaylistSection.tsx +++ b/src/components/home/FeaturedPlaylistSection.tsx @@ -1,8 +1,16 @@ -import { Pressable, ScrollView, View } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + View, +} from 'react-native'; import { AppText } from '@/components/AppText'; import { CarouselProgress } from '@/components/home/CarouselProgress'; import { FeaturedPlaylistCard } from '@/components/home/FeaturedPlaylistCard'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; import { FeaturedPlaylist } from '@/types/domain'; type FeaturedPlaylistSectionProps = { @@ -11,7 +19,7 @@ type FeaturedPlaylistSectionProps = { isCached?: boolean; isError?: boolean; isLoading?: boolean; - onSelectPlaylist?: (playlist: FeaturedPlaylist) => void; + onSelectPlaylist: (playlist: FeaturedPlaylist) => void; onRetry?: () => void; }; @@ -21,7 +29,7 @@ function FeaturedPlaylistSkeleton() { {[0, 1].map((item) => ( ))} @@ -37,42 +45,77 @@ export function FeaturedPlaylistSection({ onSelectPlaylist, onRetry, }: FeaturedPlaylistSectionProps) { + const scrollRef = useRef(null); + const [scrollProgress, setScrollProgress] = useState(0); + const [scrollViewportWidth, setScrollViewportWidth] = useState(0); + const [scrollContentWidth, setScrollContentWidth] = useState(0); const cacheLabel = cachedAt ? `최근 추천 · ${new Date(cachedAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', })}` : '최근 추천'; + const contentVisibleRatio = + scrollContentWidth > 0 && scrollViewportWidth > 0 + ? Math.min(scrollViewportWidth / scrollContentWidth, 1) + : 1; + + useEffect(() => { + setScrollProgress(0); + scrollRef.current?.scrollTo({ animated: false, x: 0, y: 0 }); + }, [data.length]); + + const handlePlaylistScroll = (event: NativeSyntheticEvent) => { + const { + contentOffset, + contentSize, + layoutMeasurement, + } = event.nativeEvent; + const maxScrollableX = Math.max(contentSize.width - layoutMeasurement.width, 0); + + setScrollViewportWidth(layoutMeasurement.width); + setScrollContentWidth(contentSize.width); + setScrollProgress(maxScrollableX > 0 ? contentOffset.x / maxScrollableX : 0); + }; return ( - - Music Playlist - {isCached ? ( - - + {cacheLabel} - - ) : null} - + ) : undefined + } + title="장소별 플레이리스트" + /> {isLoading ? ( ) : isError ? ( - - 추천을 불러오지 못했어요 - 눌러서 다시 시도해보세요. - + ) : data.length === 0 ? ( - - 추천을 준비 중이에요 - - 위치와 무드를 바탕으로 플레이리스트를 보여드릴게요. - - + ) : ( - + setScrollContentWidth(width)} + onLayout={(event) => setScrollViewportWidth(event.nativeEvent.layout.width)} + onScroll={handlePlaylistScroll} + ref={scrollRef} + scrollEventThrottle={16} + showsHorizontalScrollIndicator={false} + > {data.map((playlist) => ( )} - + {data.length > 0 ? ( + + ) : null} ); } diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx index 5ac5e22..22aa005 100644 --- a/src/components/home/HomeHeader.tsx +++ b/src/components/home/HomeHeader.tsx @@ -1,113 +1,15 @@ -import { Pressable, ScrollView, View } from 'react-native'; - -import { AppText } from '@/components/AppText'; -import { BrandLogo } from '@/components/BrandLogo'; -import { Chip } from '@/components/Chip'; -import { colors } from '@/constants/colors'; +import { PageHeader } from '@/components/PageHeader'; import type { MusicRecommendationMode } from '@/types/domain'; -const topFilters = ['전체', '근처', '지역 트렌드', '내 취향', '저장 많은']; - -export function isHomeTopFilter(filter: string) { - return topFilters.includes(filter); -} - type HomeHeaderProps = { - recommendationMode: MusicRecommendationMode; - onSelectRecommendationMode: (mode: MusicRecommendationMode) => void; -}; - -type HomeTopFilterBarProps = { - selectedTopFilter: string; - onSelectTopFilter: (filter: string) => void; + recommendationMode?: MusicRecommendationMode; + onSelectRecommendationMode?: (mode: MusicRecommendationMode) => void; }; -const musicModeOptions: Array<{ - accent: string; - label: string; - value: MusicRecommendationMode; -}> = [ - { - accent: colors.accent.blue, - label: '일상 모드', - value: 'everyday', - }, - { - accent: colors.accent.lime, - label: '여행 모드', - value: 'travel', - }, -]; - export function HomeNavigationBar() { - return ( - - - - Soundlog - - - ); -} - -export function HomeHeader({ - onSelectRecommendationMode, - recommendationMode, -}: HomeHeaderProps) { - return ( - - - - {musicModeOptions.map((mode) => { - const selected = recommendationMode === mode.value; - - return ( - onSelectRecommendationMode(mode.value)} - style={{ - backgroundColor: selected ? mode.accent : 'transparent', - transform: [{ scale: selected ? 1 : 0.98 }], - }} - > - - {mode.label} - - - ); - })} - - - - ); + return ; } -export function HomeTopFilterBar({ - onSelectTopFilter, - selectedTopFilter, -}: HomeTopFilterBarProps) { - return ( - - - - {topFilters.map((filter) => ( - onSelectTopFilter(filter)} - selected={selectedTopFilter === filter} - /> - ))} - - - - ); +export function HomeHeader(_props: HomeHeaderProps) { + return ; } diff --git a/src/components/home/HomeSoundtrackBottomSheet.tsx b/src/components/home/HomeSoundtrackBottomSheet.tsx new file mode 100644 index 0000000..34ef2af --- /dev/null +++ b/src/components/home/HomeSoundtrackBottomSheet.tsx @@ -0,0 +1,363 @@ +import { Feather } from '@expo/vector-icons'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Easing, + Modal, + PanResponder, + Pressable, + ScrollView, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AppText } from '@/components/AppText'; +import { TrackList } from '@/components/playlist/TrackList'; +import type { PlaylistCuration, Track } from '@/types/domain'; + +type HomeSoundtrackBottomSheetProps = { + actionMessage?: string; + currentTrackId?: string; + eyebrowLabel?: string; + isError?: boolean; + isLoading?: boolean; + likedTrackIds: Set; + onClose: () => void; + onSelectTrack: (track: Track) => void; + onToggleLike: (track: Track) => void; + onToggleSave: (track: Track) => void; + onRetry?: () => void; + playlist?: PlaylistCuration; + savedTrackIds: Set; + visible: boolean; +}; + +const CLOSE_DRAG_DISTANCE = 86; +const CLOSE_DRAG_VELOCITY = 0.75; +const CLOSE_ANIMATION_DURATION = 180; +const OPEN_BACKDROP_OPACITY = 1; +const EXPANDED_OFFSET = 0; +const RESTING_OFFSET_MAX = 136; +const RESTING_OFFSET_MIN = 82; + +function getRestingOffset(height: number) { + return Math.min(RESTING_OFFSET_MAX, Math.max(RESTING_OFFSET_MIN, height * 0.12)); +} + +function getResistedOffset(offset: number, restingOffset: number) { + if (offset < EXPANDED_OFFSET) { + return offset * 0.28; + } + + if (offset > restingOffset) { + return restingOffset + (offset - restingOffset) * 0.42; + } + + return offset; +} + +export function HomeSoundtrackBottomSheet({ + actionMessage, + currentTrackId, + eyebrowLabel = '오늘의 사운드트랙', + isError = false, + isLoading = false, + likedTrackIds, + onClose, + onSelectTrack, + onToggleLike, + onToggleSave, + onRetry, + playlist, + savedTrackIds, + visible, +}: HomeSoundtrackBottomSheetProps) { + const insets = useSafeAreaInsets(); + const { height } = useWindowDimensions(); + const sheetTopGap = Math.max(insets.top + 8, 22); + const sheetHeight = Math.max(360, height - sheetTopGap); + const restingOffset = getRestingOffset(height); + const sheetHiddenOffset = sheetHeight + insets.bottom + 48; + const backdropOpacity = useRef(new Animated.Value(0)).current; + const translateY = useRef(new Animated.Value(sheetHiddenOffset)).current; + const currentSnapOffset = useRef(restingOffset); + const dragStartOffset = useRef(restingOffset); + const [isMounted, setIsMounted] = useState(visible); + const listBottomPadding = insets.bottom + 40; + + useEffect(() => { + if (visible) { + setIsMounted(true); + } + }, [visible]); + + useEffect(() => { + if (!isMounted) { + return; + } + + if (visible) { + backdropOpacity.stopAnimation(); + translateY.stopAnimation(); + backdropOpacity.setValue(0); + translateY.setValue(sheetHiddenOffset); + currentSnapOffset.current = restingOffset; + dragStartOffset.current = restingOffset; + const animationFrame = requestAnimationFrame(() => { + Animated.parallel([ + Animated.timing(backdropOpacity, { + duration: 140, + easing: Easing.out(Easing.quad), + toValue: OPEN_BACKDROP_OPACITY, + useNativeDriver: true, + }), + Animated.spring(translateY, { + damping: 24, + mass: 0.8, + stiffness: 230, + toValue: restingOffset, + useNativeDriver: true, + }), + ]).start(); + }); + + return () => { + cancelAnimationFrame(animationFrame); + }; + } + + Animated.parallel([ + Animated.timing(backdropOpacity, { + duration: CLOSE_ANIMATION_DURATION, + easing: Easing.in(Easing.quad), + toValue: 0, + useNativeDriver: true, + }), + Animated.timing(translateY, { + duration: CLOSE_ANIMATION_DURATION, + easing: Easing.out(Easing.cubic), + toValue: sheetHiddenOffset, + useNativeDriver: true, + }), + ]).start(({ finished }) => { + if (finished) { + setIsMounted(false); + } + }); + }, [backdropOpacity, isMounted, 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 panResponder = useMemo( + () => + PanResponder.create({ + onPanResponderGrant: () => { + translateY.stopAnimation(); + dragStartOffset.current = currentSnapOffset.current; + }, + onMoveShouldSetPanResponder: (_, 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)); + }, + onPanResponderRelease: (_, gesture) => { + const nextOffset = dragStartOffset.current + gesture.dy; + const shouldClose = + nextOffset > restingOffset + CLOSE_DRAG_DISTANCE || + (gesture.vy > CLOSE_DRAG_VELOCITY && nextOffset > restingOffset - 12); + + if (shouldClose) { + onClose(); + return; + } + + if (gesture.vy < -0.34 || nextOffset < restingOffset * 0.54) { + snapTo(EXPANDED_OFFSET); + return; + } + + snapTo(restingOffset); + }, + onPanResponderTerminate: () => { + snapTo(currentSnapOffset.current); + }, + }), + [onClose, restingOffset, snapTo, translateY], + ); + + if (!isMounted && !visible) { + return null; + } + + return ( + + + + + + + + + + + + + {eyebrowLabel} + + + {playlist?.regionName ?? '추천 곡'} + + {playlist ? ( + + {playlist.reason} + + ) : null} + + + + + + + {playlist ? ( + + + + {playlist.trackCount}곡 + + + + + {playlist.durationText} + + + + ) : null} + + + {isLoading ? ( + + + + 추천 곡을 불러오고 있어요 + + + ) : playlist ? ( + + + + ) : isError ? ( + + + 추천 곡을 열지 못했어요 + + + + 다시 시도 + + + + ) : ( + + + 아직 추천 곡이 없어요 + + + )} + + {actionMessage ? ( + + + {actionMessage} + + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + dragHandle: { + alignSelf: 'center', + backgroundColor: 'rgba(255,255,255,0.72)', + borderRadius: 999, + height: 5, + marginBottom: 18, + width: 54, + }, + dragRegion: { + paddingBottom: 10, + paddingHorizontal: 28, + paddingTop: 18, + }, + sheet: { + backgroundColor: '#08101D', + borderColor: 'rgba(255,255,255,0.14)', + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + borderWidth: 1, + overflow: 'hidden', + }, + trackScroller: { + flex: 1, + }, +}); diff --git a/src/components/home/LocationContextCard.tsx b/src/components/home/LocationContextCard.tsx index d2620ea..23a77da 100644 --- a/src/components/home/LocationContextCard.tsx +++ b/src/components/home/LocationContextCard.tsx @@ -1,11 +1,12 @@ import { Feather } from '@expo/vector-icons'; import { Pressable, View } from 'react-native'; -import { AppText } from '@/components/AppText'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; import { HomeLocationStatus } from '@/store/travelSessionStore'; import { GeoPoint, PlaceContext } from '@/types/domain'; import { formatRecapRecordedAt } from '@/utils/dateFormat'; -import { formatPlaceLabel } from '@/utils/placeLabel'; +import { getPlaceDisplayTitle } from '@/utils/placeLabel'; type LocationContextCardProps = { enabled: boolean; @@ -15,6 +16,7 @@ type LocationContextCardProps = { onDismiss?: () => void; onEnable: () => void; onRefresh: () => void; + onSelectPlace?: () => void; place?: PlaceContext; placeCount?: number; placeInfoMessage?: string; @@ -29,9 +31,9 @@ const statusCopy: Record 0 ? `주변 장소 ${placeCount}곳 반영` + : place && !location + ? '직접 선택한 장소로 추천 중' : location ? updatedAt ? `${formatRecapRecordedAt(updatedAt)} 갱신` - : '현재 위치 기반 추천 중' + : '지역 기반 추천 중' : copy.description; return ( - - - - - - - - - 현재 위치 - - - {placeTitle} - - - {placeMeta ? `${placeMeta} · ${statusText}` : statusText} - - {placeInfoMessage ? ( - - {placeInfoMessage} - - ) : null} - - - {onDismiss ? ( + + - ) : null} - - - + - {buttonLabel} - + rightText={isLoading ? '확인 중' : buttonLabel} + /> + {place?.attribution ? ( + + ) : null} + {onSelectPlace ? ( + + ) : null} ); } diff --git a/src/components/home/ManualPlacePickerModal.tsx b/src/components/home/ManualPlacePickerModal.tsx new file mode 100644 index 0000000..3277b94 --- /dev/null +++ b/src/components/home/ManualPlacePickerModal.tsx @@ -0,0 +1,199 @@ +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/home/MoodRecommendationCard.tsx b/src/components/home/MoodRecommendationCard.tsx index 3c8d247..71017c4 100644 --- a/src/components/home/MoodRecommendationCard.tsx +++ b/src/components/home/MoodRecommendationCard.tsx @@ -1,4 +1,5 @@ -import { Pressable } from 'react-native'; +import { Image } from 'expo-image'; +import { Pressable, StyleSheet, View } from 'react-native'; import { AppText } from '@/components/AppText'; import { MoodRecommendation } from '@/types/domain'; @@ -9,20 +10,44 @@ type MoodRecommendationCardProps = { }; export function MoodRecommendationCard({ item, onPress }: MoodRecommendationCardProps) { + const moodLabel = item.moods?.[0] ?? item.genres?.[0]; + const actionLabel = item.playlistId ? '추천 플레이리스트 열기' : '추천 음악 선택'; + return ( onPress(item)} style={{ backgroundColor: item.color }} > - - {item.title} - - {item.subtitle ? ( - - {item.subtitle} - + {item.imageUrl ? ( + ) : null} + + + {moodLabel ? ( + + + {moodLabel} + + + ) : null} + + + {item.title} + + {item.subtitle ? ( + + {item.subtitle} + + ) : null} + ); } diff --git a/src/components/home/MoodRecommendationSection.tsx b/src/components/home/MoodRecommendationSection.tsx index a56dbef..b0acaa8 100644 --- a/src/components/home/MoodRecommendationSection.tsx +++ b/src/components/home/MoodRecommendationSection.tsx @@ -3,6 +3,8 @@ import { ScrollView, View } from 'react-native'; import { AppText } from '@/components/AppText'; import { Chip } from '@/components/Chip'; import { MoodRecommendationCard } from '@/components/home/MoodRecommendationCard'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; import { MoodRecommendation } from '@/types/domain'; const moodFilters = [ @@ -26,6 +28,7 @@ type MoodRecommendationSectionProps = { isLoading?: boolean; onSelectMoodFilter: (filter: string) => void; onSelectRecommendation: (item: MoodRecommendation) => void; + onRetry?: () => void; selectedMoodFilter: string; }; @@ -35,7 +38,7 @@ function MoodRecommendationSkeleton() { {[0, 1, 2].map((item) => ( ))} @@ -50,6 +53,7 @@ export function MoodRecommendationSection({ isLoading = false, onSelectMoodFilter, onSelectRecommendation, + onRetry, selectedMoodFilter, }: MoodRecommendationSectionProps) { const cacheLabel = cachedAt @@ -61,18 +65,16 @@ export function MoodRecommendationSection({ return ( - - - 나의 무드에 맞는 음악 추천 - - {isCached ? ( - - + {cacheLabel} - - ) : null} - + ) : undefined + } + title="무드 추천" + /> @@ -90,17 +92,17 @@ export function MoodRecommendationSection({ {isLoading ? ( ) : isError ? ( - - - 무드 추천을 불러오지 못했어요 - - + ) : data.length === 0 ? ( - - - 아직 추천할 음악이 없어요 - - + ) : ( diff --git a/src/components/home/MusicLogSection.tsx b/src/components/home/MusicLogSection.tsx index 36a806e..de24f4c 100644 --- a/src/components/home/MusicLogSection.tsx +++ b/src/components/home/MusicLogSection.tsx @@ -100,20 +100,20 @@ export function MusicLogSection({ return ( - Music Log + 오늘의 사운드 {isLoading ? ( ) : isError ? ( - Music Log를 불러오지 못했어요 + 오늘의 사운드를 불러오지 못했어요 ) : data.length === 0 ? ( - 오늘의 여행 순간을 저장해보세요. + 오늘의 리캡을 저장해보세요. ) : ( diff --git a/src/components/home/TravelModeHomeView.tsx b/src/components/home/TravelModeHomeView.tsx new file mode 100644 index 0000000..472002d --- /dev/null +++ b/src/components/home/TravelModeHomeView.tsx @@ -0,0 +1,776 @@ +import { Feather } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Easing, + Platform, + Pressable, + StyleSheet, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AppText } from '@/components/AppText'; +import { BrandLogo } from '@/components/BrandLogo'; +import { HomeHeader } from '@/components/home/HomeHeader'; +import { getTabBarHeight } from '@/constants/layout'; +import type { HomeLocationStatus } from '@/store/travelSessionStore'; +import type { + FeaturedPlaylist, + GeoPoint, + MusicRecommendationMode, + PlaceContext, +} from '@/types/domain'; + +type TravelSessionStatus = 'active' | 'ended' | 'idle'; + +type TravelModeHomeViewProps = { + actionMessage?: string; + currentLocation?: GeoPoint; + currentPlace?: PlaceContext; + isOpeningPlaylist?: boolean; + isRecommendationError?: boolean; + isRecommendationLoading?: boolean; + locationStatus: HomeLocationStatus; + locationUpdatedAt?: string; + moodLabel: string; + needsLocation?: boolean; + onCaptureMoment: () => void; + onEditTravelState: () => void; + onEndTravel: () => void; + onOpenPlaylist: () => void; + onRefreshLocation: () => void; + onRetryRecommendation: () => void; + onSelectRecommendationMode: (mode: MusicRecommendationMode) => void; + onStartTravel: () => void; + playlist?: FeaturedPlaylist; + recommendationSource?: string; + recommendationMode: MusicRecommendationMode; + sessionStatus: TravelSessionStatus; + startedAt?: string; + travelLabel: string; +}; + +type NativeMapsModule = typeof import('react-native-maps'); + +const fallbackCenter: GeoPoint = { + lat: 37.5512, + lng: 126.9882, +}; + +let nativeMapsModule: NativeMapsModule | undefined; + +function getNativeMaps() { + if (Platform.OS === 'web') { + return undefined; + } + + nativeMapsModule ??= require('react-native-maps') as NativeMapsModule; + return nativeMapsModule; +} + +function getMapCenter(currentLocation?: GeoPoint, currentPlace?: PlaceContext) { + return currentLocation ?? currentPlace?.location ?? fallbackCenter; +} + +function getLocationTitle({ + currentLocation, + currentPlace, + locationStatus, +}: { + currentLocation?: GeoPoint; + currentPlace?: PlaceContext; + locationStatus: HomeLocationStatus; +}) { + if (locationStatus === 'loading') { + return '내 위치 확인 중'; + } + + if (currentPlace?.title) { + return currentPlace.title; + } + + if (currentLocation) { + return '내 위치 주변'; + } + + if (locationStatus === 'denied') { + return '위치 권한이 꺼져 있어요'; + } + + return '여행 시작 위치'; +} + +function getLocationCaption({ + currentLocation, + currentPlace, + locationStatus, + locationUpdatedAt, +}: { + currentLocation?: GeoPoint; + currentPlace?: PlaceContext; + locationStatus: HomeLocationStatus; + locationUpdatedAt?: string; +}) { + if (locationStatus === 'loading') { + return '현재 위치에 맞춰 지도를 준비하고 있어요.'; + } + + if (locationStatus === 'denied') { + return '위치를 허용하면 지도에 내 위치를 표시해요.'; + } + + if (currentPlace?.address) { + return currentPlace.address; + } + + if (currentPlace?.category) { + return currentPlace.category; + } + + if (currentLocation && locationUpdatedAt) { + return `최근 위치 · ${new Date(locationUpdatedAt).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + })}`; + } + + return '위치를 새로고침하면 지금 장소를 기준으로 시작해요.'; +} + +function getSessionElapsedLabel(startedAt?: string) { + if (!startedAt) { + return '방금 시작'; + } + + const startedTime = new Date(startedAt).getTime(); + + if (Number.isNaN(startedTime)) { + return '여행 기록 중'; + } + + const elapsedMinutes = Math.max(0, Math.floor((Date.now() - startedTime) / 60000)); + + if (elapsedMinutes < 1) { + return '방금 시작'; + } + + if (elapsedMinutes < 60) { + return `${elapsedMinutes}분째 기록 중`; + } + + const hours = Math.floor(elapsedMinutes / 60); + const minutes = elapsedMinutes % 60; + + return minutes ? `${hours}시간 ${minutes}분째 기록 중` : `${hours}시간째 기록 중`; +} + +function getRecommendationSourceLabel(source?: string) { + if (source === 'ml-recommendation') { + return 'ML 추천'; + } + + if (source === 'seed-fallback') { + return '기본 추천'; + } + + return '현재 장소 추천'; +} + +function TravelMapBackground({ + currentLocation, + currentPlace, + onInteractionEnd, + onInteractionStart, +}: { + currentLocation?: GeoPoint; + currentPlace?: PlaceContext; + onInteractionEnd: () => void; + onInteractionStart: () => void; +}) { + const NativeMaps = getNativeMaps(); + const center = useMemo( + () => getMapCenter(currentLocation, currentPlace), + [currentLocation, currentPlace], + ); + const region = useMemo( + () => ({ + latitude: center.lat, + latitudeDelta: 0.012, + longitude: center.lng, + longitudeDelta: 0.012, + }), + [center], + ); + const markerLocation = currentLocation ?? currentPlace?.location; + + if (!NativeMaps) { + return ; + } + + const MapView = NativeMaps.default; + const Marker = NativeMaps.Marker; + + return ( + + {markerLocation ? ( + + + + + + ) : null} + + ); +} + +function FallbackMapBackground({ hasLocation }: { hasLocation: boolean }) { + return ( + + + + + + + + {hasLocation ? ( + + + + ) : null} + + ); +} + +function TravelStartPanel({ + isLocationLoading, + locationCaption, + locationTitle, + onRefreshLocation, + onStartTravel, +}: { + isLocationLoading: boolean; + locationCaption: string; + locationTitle: string; + onRefreshLocation: () => void; + onStartTravel: () => void; +}) { + return ( + + + + + {isLocationLoading ? ( + + ) : ( + + )} + + + + 현재 위치 + + + {locationTitle} + + + {locationCaption} + + + + + + + + 여행 시작 + + + + + + 내 위치 새로고침 + + + + ); +} + +function ActiveRecommendationPanel({ + isOpeningPlaylist, + isRecommendationError, + isRecommendationLoading, + moodLabel, + needsLocation, + onCaptureMoment, + onEditTravelState, + onEndTravel, + onOpenPlaylist, + onRefreshLocation, + onRetryRecommendation, + playlist, + recommendationSource, + sessionElapsedLabel, + travelLabel, +}: { + isOpeningPlaylist: boolean; + isRecommendationError: boolean; + isRecommendationLoading: boolean; + moodLabel: string; + needsLocation: boolean; + onCaptureMoment: () => void; + onEditTravelState: () => void; + onEndTravel: () => void; + onOpenPlaylist: () => void; + onRefreshLocation: () => void; + onRetryRecommendation: () => void; + playlist?: FeaturedPlaylist; + recommendationSource?: string; + sessionElapsedLabel: string; + travelLabel: string; +}) { + const title = playlist?.regionName ?? `${travelLabel} 사운드트랙`; + const description = playlist?.description ?? + (needsLocation + ? '위치를 잡으면 지금 장소에 맞는 음악 추천이 지도 위에 떠요.' + : '현재 장소와 분위기에 맞는 음악을 준비하고 있어요.'); + const meta = playlist + ? `${playlist.trackCount}곡 · ${playlist.durationText}` + : isRecommendationLoading + ? '추천 준비 중' + : needsLocation + ? '위치 필요' + : '추천 다시 시도'; + const playlistButtonDisabled = isRecommendationLoading || isOpeningPlaylist; + + return ( + + + + + + + 여행 기록 중 + + + + + {sessionElapsedLabel} + + + + + 지도 위 음악 추천 + + + {title} + + + + {isRecommendationLoading ? ( + + ) : ( + + )} + + + + + {description} + + + + + {travelLabel} + + + {moodLabel} + + + + {getRecommendationSourceLabel(recommendationSource)} + + + + + + + {meta} + + {isRecommendationError ? '추천을 다시 시도할 수 있어요.' : '시작한 여행에 이 음악 맥락을 붙여요.'} + + + + + + {isOpeningPlaylist ? '여는 중' : needsLocation && !playlist ? '위치 잡기' : '곡 보기'} + + + + + + + + 기록 + + + + 다시 추천 + + + + + + + 상태 수정 + + + + 여행 종료 + + + + ); +} + +export function TravelModeHomeView({ + actionMessage, + currentLocation, + currentPlace, + isOpeningPlaylist = false, + isRecommendationError = false, + isRecommendationLoading = false, + locationStatus, + locationUpdatedAt, + moodLabel, + needsLocation = false, + onCaptureMoment, + onEditTravelState, + onEndTravel, + onOpenPlaylist, + onRefreshLocation, + onRetryRecommendation, + onSelectRecommendationMode, + onStartTravel, + playlist, + recommendationSource, + recommendationMode, + sessionStatus, + startedAt, + travelLabel, +}: TravelModeHomeViewProps) { + const insets = useSafeAreaInsets(); + const [isMapInteracting, setIsMapInteracting] = useState(false); + const interactionEndTimerRef = useRef | undefined>(undefined); + const recommendationPanelOffset = useRef(new Animated.Value(0)).current; + const locationTitle = getLocationTitle({ currentLocation, currentPlace, locationStatus }); + const locationCaption = getLocationCaption({ + currentLocation, + currentPlace, + locationStatus, + locationUpdatedAt, + }); + const sessionElapsedLabel = getSessionElapsedLabel(startedAt); + const isActive = sessionStatus === 'active'; + const handleMapInteractionStart = useCallback(() => { + if (interactionEndTimerRef.current) { + clearTimeout(interactionEndTimerRef.current); + } + + setIsMapInteracting(true); + }, []); + const handleMapInteractionEnd = useCallback(() => { + if (interactionEndTimerRef.current) { + clearTimeout(interactionEndTimerRef.current); + } + + interactionEndTimerRef.current = setTimeout(() => { + setIsMapInteracting(false); + }, 760); + }, []); + + useEffect(() => { + Animated.timing(recommendationPanelOffset, { + duration: isMapInteracting ? 220 : 320, + easing: Easing.out(Easing.cubic), + toValue: isMapInteracting ? 230 : 0, + useNativeDriver: true, + }).start(); + }, [isMapInteracting, recommendationPanelOffset]); + + useEffect( + () => () => { + if (interactionEndTimerRef.current) { + clearTimeout(interactionEndTimerRef.current); + } + }, + [], + ); + + return ( + + + + + + + + + + + Soundlog + + 여행 모드 + + + + + + + + + + + + + {locationTitle} + + + + + + {isActive ? ( + + + + ) : ( + + )} + + {actionMessage ? ( + + {actionMessage} + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + bottomFade: { + bottom: 0, + height: 360, + left: 0, + position: 'absolute', + right: 0, + }, + fallbackLocationMarker: { + left: '50%', + marginLeft: -18, + marginTop: -18, + position: 'absolute', + top: '48%', + }, + fallbackMap: { + backgroundColor: '#E9EEF5', + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + locationMarker: { + backgroundColor: '#1C8DFF', + borderColor: '#FFFFFF', + borderRadius: 999, + borderWidth: 4, + height: 28, + shadowColor: '#000000', + shadowOffset: { height: 5, width: 0 }, + shadowOpacity: 0.25, + shadowRadius: 12, + width: 28, + }, + locationMarkerHalo: { + alignItems: 'center', + backgroundColor: 'rgba(28,141,255,0.16)', + borderRadius: 999, + height: 54, + justifyContent: 'center', + width: 54, + }, + mapBlock: { + backgroundColor: 'rgba(255,255,255,0.58)', + borderColor: 'rgba(130,146,166,0.12)', + borderRadius: 26, + borderWidth: 1, + position: 'absolute', + }, + mapBlockOne: { + height: 180, + right: 36, + top: 190, + transform: [{ rotate: '12deg' }], + width: 170, + }, + mapBlockThree: { + bottom: 190, + height: 160, + left: 40, + transform: [{ rotate: '-18deg' }], + width: 180, + }, + mapBlockTwo: { + height: 130, + left: 28, + top: 270, + transform: [{ rotate: '-8deg' }], + width: 138, + }, + mapRoad: { + backgroundColor: 'rgba(255,255,255,0.92)', + borderColor: 'rgba(149,163,184,0.16)', + borderRadius: 999, + borderWidth: 1, + height: 24, + position: 'absolute', + width: '120%', + }, + mapRoadPrimary: { + left: -40, + top: '44%', + transform: [{ rotate: '-18deg' }], + }, + mapRoadSecondary: { + left: -32, + top: '58%', + transform: [{ rotate: '24deg' }], + }, + mapRoadTertiary: { + left: -42, + top: '30%', + transform: [{ rotate: '38deg' }], + }, + topFade: { + height: 240, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, +}); diff --git a/src/components/home/TravelSessionCard.tsx b/src/components/home/TravelSessionCard.tsx index f384424..4f6b878 100644 --- a/src/components/home/TravelSessionCard.tsx +++ b/src/components/home/TravelSessionCard.tsx @@ -37,13 +37,13 @@ const statusCopy: Record< > = { active: { cta: '여행 보기', - description: '순간 저장과 Music Log가 지금 여행에 함께 묶이고 있어요.', + description: '여행 중 저장한 리캡들이 지금 로그에 함께 묶이고 있어요.', icon: 'radio', title: '여행 기록 중', }, ended: { - cta: '리캡 보기', - description: '저장한 순간은 Recap에서 다시 확인할 수 있어요.', + cta: '로그 보기', + description: '저장한 리캡들은 여행 로그에서 다시 확인할 수 있어요.', icon: 'check-circle', title: '여행이 종료됐어요', }, diff --git a/src/components/legal/LegalDocumentScreen.tsx b/src/components/legal/LegalDocumentScreen.tsx index f3b0cb9..b90201e 100644 --- a/src/components/legal/LegalDocumentScreen.tsx +++ b/src/components/legal/LegalDocumentScreen.tsx @@ -1,9 +1,12 @@ -import { Feather } from '@expo/vector-icons'; import { router } from 'expo-router'; -import { Linking, Pressable, ScrollView, View } from 'react-native'; +import { Linking, ScrollView, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { IconButton } from '@/components/IconButton'; +import { PageHeader } from '@/components/PageHeader'; import { Screen } from '@/components/Screen'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; import { SOUNDLOG_SUPPORT_EMAIL } from '@/constants/legal'; type LegalSection = { @@ -32,39 +35,33 @@ export function LegalDocumentScreen({ return ( - router.back()} - > - - + router.back()} /> + } + title={title} + /> - - + + {updatedAt} - - {title} - - + {subtitle} - + {sections.map((section) => ( - - - {section.title} - + + {section.body} @@ -72,18 +69,16 @@ export function LegalDocumentScreen({ ))} - - - - 문의 메일 보내기 - - + + + + ); } - diff --git a/src/components/library/LibraryEmptyState.tsx b/src/components/library/LibraryEmptyState.tsx index a48b503..fe01860 100644 --- a/src/components/library/LibraryEmptyState.tsx +++ b/src/components/library/LibraryEmptyState.tsx @@ -11,12 +11,16 @@ type LibraryEmptyStateProps = { export function LibraryEmptyState({ description, icon, title }: LibraryEmptyStateProps) { return ( - - - + + + - {title} - {description} + + {title} + + + {description} + ); } diff --git a/src/components/library/LibraryScreen.tsx b/src/components/library/LibraryScreen.tsx index 78de8ee..d3c620a 100644 --- a/src/components/library/LibraryScreen.tsx +++ b/src/components/library/LibraryScreen.tsx @@ -3,14 +3,18 @@ import { Feather } from '@expo/vector-icons'; import { Image } from 'expo-image'; import { router } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; -import { Pressable, ScrollView, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native'; import { libraryApi } from '@/api/libraryApi'; import { syncRecommendationEvent } from '@/api/recommendationEventApi'; import { AppText } from '@/components/AppText'; +import { IconButton } from '@/components/IconButton'; import { LibraryEmptyState } from '@/components/library/LibraryEmptyState'; import { LibraryTrackRow } from '@/components/library/LibraryTrackRow'; +import { PageHeader } from '@/components/PageHeader'; import { Screen } from '@/components/Screen'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; import { LibraryTrackRecord, useLibraryStore } from '@/store/libraryStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; @@ -63,7 +67,12 @@ export function LibraryScreen() { } = useLibraryStore(); const setTrack = usePlayerStore((state) => state.setTrack); const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); - const { data: remoteLibraryRecords = [] } = useQuery({ + const { + data: remoteLibraryRecords = [], + isError: isRemoteLibraryError, + isLoading: isRemoteLibraryLoading, + refetch: refetchRemoteLibrary, + } = useQuery({ queryFn: () => libraryApi.getTracks('all'), queryKey: ['library', 'tracks', 'all'], staleTime: 5 * 60 * 1000, @@ -102,6 +111,15 @@ export function LibraryScreen() { ); }, [savedTracks]); const records = selectedTab === 'liked' ? likedTracks : savedTracks; + const hasLocalRecords = likedTracks.length > 0 || savedTracks.length > 0; + const selectedCount = + selectedTab === 'playlists' ? playlistRecords.length : records.length; + const selectedSectionTitle = + selectedTab === 'liked' + ? '좋아요한 음악' + : selectedTab === 'saved' + ? '저장한 음악' + : '저장한 플레이리스트'; useEffect(() => { remoteLibraryRecords.forEach((record) => { @@ -125,7 +143,7 @@ export function LibraryScreen() { const playRecord = (record: LibraryTrackRecord) => { setActionMessage(undefined); setTrack(record.track, record.playlistId, undefined, record.playlist); - setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); + setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 리캡에 담을 수 있어요.'); }; const removeRecord = (record: LibraryTrackRecord) => { const context = createRecommendationEventContext(); @@ -182,23 +200,33 @@ export function LibraryScreen() { return ( - - 보관함 - - 좋아요한 음악, 저장곡, 플레이리스트를 모아둬요. - - + router.replace('/my' as never)} + /> + } + title="보관함" + /> - + {tabs.map((tab) => { const isSelected = selectedTab === tab.id; return ( selectTab(tab.id)} style={{ backgroundColor: isSelected ? '#ffffff' : 'transparent' }} @@ -214,15 +242,48 @@ export function LibraryScreen() { })} + + + {selectedCount}개 + + } + title={selectedSectionTitle} + /> + + {actionMessage ? ( - - + + + {actionMessage} ) : null} - {selectedTab === 'playlists' ? ( + {isRemoteLibraryLoading && !hasLocalRecords ? ( + + + + 보관함을 동기화하고 있어요 + + + ) : isRemoteLibraryError ? ( + void refetchRemoteLibrary()} + rightText="다시 시도" + /> + ) : null} + + {isRemoteLibraryLoading && !hasLocalRecords ? null : selectedTab === 'playlists' ? ( playlistRecords.length === 0 ? ( ) : ( - + {playlistRecords.map((playlist) => { const representativeRecord = playlist.records[0]; const imageUrl = @@ -243,42 +304,33 @@ export function LibraryScreen() { return ( representativeRecord && playRecord(representativeRecord)} + className="min-h-[76px] flex-row items-center py-2" + onPress={() => router.push(`/playlist/${playlist.playlistId}` as never)} + style={({ pressed }) => ({ opacity: pressed ? 0.62 : 1 })} > {imageUrl ? ( ) : null} - - - - - 저장곡 {playlist.records.length} - - - {playlist.playlist?.durationText ? ( - - {playlist.playlist.durationText} - - ) : null} - - + + {title} - + {description} - {representativeRecord ? ( - - 대표곡 · {representativeRecord.track.title} / {representativeRecord.track.artist} - - ) : null} + + 저장곡 {playlist.records.length} + {playlist.playlist?.durationText + ? ` · ${playlist.playlist.durationText}` + : ''} + @@ -297,7 +349,7 @@ export function LibraryScreen() { title={selectedTab === 'liked' ? '좋아요한 음악이 없어요' : '저장한 음악이 없어요'} /> ) : ( - + {records.map((record) => ( )} - - - 마이 - - router.push({ - pathname: '/onboarding', - params: { mode: 'edit' }, - } as never) - } - > - - - - - 취향 수정 - - 장르 · 무드 · 여행 스타일 - - - - - router.push('/my' as never)} - > - - - - - 권한 설정 - - 위치 · 카메라 · 사진 - - - - - ); diff --git a/src/components/library/LibraryTrackRow.tsx b/src/components/library/LibraryTrackRow.tsx index 5855ea9..a393374 100644 --- a/src/components/library/LibraryTrackRow.tsx +++ b/src/components/library/LibraryTrackRow.tsx @@ -1,3 +1,4 @@ +import { Feather } from '@expo/vector-icons'; import { Image } from 'expo-image'; import { Pressable, View } from 'react-native'; @@ -19,15 +20,15 @@ export function LibraryTrackRow({ const { track } = record; return ( - + {track.albumImageUrl ? ( @@ -42,30 +43,27 @@ export function LibraryTrackRow({ {track.artist} - + {formatRecapRecordedAt(record.createdAt)} + - - - 열기 - - - 삭제 - - + + + ); } diff --git a/src/components/moment-capture/CameraCaptureView.tsx b/src/components/moment-capture/CameraCaptureView.tsx index 38c717a..731c2e2 100644 --- a/src/components/moment-capture/CameraCaptureView.tsx +++ b/src/components/moment-capture/CameraCaptureView.tsx @@ -1,36 +1,49 @@ -import { Feather } from '@expo/vector-icons'; -import { CameraView } from 'expo-camera'; -import { RefObject } from 'react'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { Feather } from "@expo/vector-icons"; +import { CameraView } from "expo-camera"; +import { RefObject } from "react"; +import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native"; -import { AppText } from '@/components/AppText'; -import { LocationPermissionBanner } from '@/components/moment-capture/LocationPermissionBanner'; -import { MomentSaveState } from '@/components/moment-capture/MomentSaveState'; +import { AppText } from "@/components/AppText"; +import { LocationPermissionBanner } from "@/components/moment-capture/LocationPermissionBanner"; +import { MomentSaveState } from "@/components/moment-capture/MomentSaveState"; -type LocationStatus = 'denied' | 'granted' | 'idle' | 'loading' | 'unavailable'; +type LocationStatus = "denied" | "granted" | "idle" | "loading" | "unavailable"; type CameraCaptureViewProps = { cameraRef: RefObject; errorMessage?: string; isCapturing: boolean; + isPickingPhoto?: boolean; locationStatus: LocationStatus; onCapture: () => void; onClose: () => void; + onPickGallery: () => void; onSkipPhoto: () => void; + onUseRecommendedPhoto: () => void; }; export function CameraCaptureView({ cameraRef, errorMessage, isCapturing, + isPickingPhoto = false, locationStatus, onCapture, onClose, + onPickGallery, onSkipPhoto, + onUseRecommendedPhoto, }: CameraCaptureViewProps) { + const controlsDisabled = isCapturing || isPickingPhoto; + return ( - + @@ -48,34 +61,76 @@ export function CameraCaptureView({ - 지금 듣는 음악과 장소를 함께 저장해요. + 지금 듣는 음악과 장소를 리캡으로 남겨요. - - {isCapturing ? ( - - ) : ( - - )} - + + + + {isCapturing || isPickingPhoto ? ( + + ) : ( + + )} + + + - 사진 없이 기록하기 + 사진 없이 리캡 만들기 ); } + +function CameraToolButton({ + disabled, + icon, + label, + onPress, +}: { + disabled: boolean; + icon: keyof typeof Feather.glyphMap; + label: string; + onPress: () => void; +}) { + return ( + + + + + {label} + + ); +} diff --git a/src/components/moment-capture/CameraPermissionState.tsx b/src/components/moment-capture/CameraPermissionState.tsx index 9cd7de7..c844ead 100644 --- a/src/components/moment-capture/CameraPermissionState.tsx +++ b/src/components/moment-capture/CameraPermissionState.tsx @@ -1,7 +1,9 @@ -import { Feather } from '@expo/vector-icons'; import { Linking, Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { PageHeader } from '@/components/PageHeader'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; type CameraPermissionStateProps = { canAskAgain?: boolean; @@ -19,32 +21,39 @@ export function CameraPermissionState({ const isDenied = status === 'denied'; return ( - - - + + + + + + - - 카메라 권한이 필요해요 - - - 여행 순간을 사진과 음악, 장소 정보로 함께 저장하기 위해 카메라 접근을 허용해주세요. + + + 권한을 허용하지 않아도 사진 없이 장소, 음악, 무드를 리캡으로 저장할 수 있어요. void Linking.openSettings() : onRequest} > - + {isDenied && !canAskAgain ? '설정 열기' : '카메라 권한 허용'} - 사진 없이 기록하기 + 사진 없이 기록하기 ); diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx index 003a51c..12b4e42 100644 --- a/src/components/moment-capture/MomentCaptureScreen.tsx +++ b/src/components/moment-capture/MomentCaptureScreen.tsx @@ -1,77 +1,138 @@ -import { CameraView, useCameraPermissions } from 'expo-camera'; -import { router } from 'expo-router'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { Platform, Pressable, View } from 'react-native'; - -import { momentLogApi } from '@/api/momentLogApi'; -import { syncRecommendationEvent } from '@/api/recommendationEventApi'; -import { AppText } from '@/components/AppText'; -import { CameraCaptureView } from '@/components/moment-capture/CameraCaptureView'; -import { CameraPermissionState } from '@/components/moment-capture/CameraPermissionState'; -import { MomentReviewPanel } from '@/components/moment-capture/MomentReviewPanel'; -import { Screen } from '@/components/Screen'; -import { useHomeFilterStore } from '@/store/homeFilterStore'; +import { CameraView, useCameraPermissions } from "expo-camera"; +import { router, useLocalSearchParams } from "expo-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Platform, Pressable, View } from "react-native"; + +import { syncRecommendationEvent } from "@/api/recommendationEventApi"; +import { AppText } from "@/components/AppText"; +import { PageHeader } from "@/components/PageHeader"; +import { CameraCaptureView } from "@/components/moment-capture/CameraCaptureView"; +import { CameraPermissionState } from "@/components/moment-capture/CameraPermissionState"; +import { + MomentReviewPanel, + type MomentReviewPanelHandle, +} from "@/components/moment-capture/MomentReviewPanel"; +import { Screen } from "@/components/Screen"; +import { SectionTitle } from "@/components/SectionTitle"; +import { SettingsRow } from "@/components/SettingsRow"; +import { useHomeFilterStore } from "@/store/homeFilterStore"; import { useMomentLogStore, type MomentLogCreateQueuePayload, -} from '@/store/momentLogStore'; -import { usePlayerStore } from '@/store/playerStore'; -import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import { useTravelSessionStore } from '@/store/travelSessionStore'; -import { GeoPoint, MomentLog } from '@/types/domain'; -import { getForegroundLocationWithTimeout } from '@/utils/location'; -import { getMoodTagsFromFilter } from '@/utils/moodTags'; -import { persistMomentPhoto } from '@/utils/momentFiles'; -import { formatPlaceLabel } from '@/utils/placeLabel'; -import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; - -type LocationStatus = 'denied' | 'granted' | 'idle' | 'loading' | 'unavailable'; +} from "@/store/momentLogStore"; +import { usePlayerStore } from "@/store/playerStore"; +import { useRecommendationEventStore } from "@/store/recommendationEventStore"; +import { useTravelSessionStore } from "@/store/travelSessionStore"; +import { + GeoPoint, + MomentLog, + RecapTemplateId, + RecapVisibility, +} from "@/types/domain"; +import { getForegroundLocationWithTimeout } from "@/utils/location"; +import { getMoodTagsFromFilter } from "@/utils/moodTags"; +import { persistMomentPhoto } from "@/utils/momentFiles"; +import { pickMomentPhotoFromLibrary } from "@/utils/momentPhotoPicker"; +import { createRecommendationEventContext } from "@/utils/recommendationEventContext"; +import { getDistanceMeters } from "@/utils/recapTravelSummary"; + +type LocationStatus = "denied" | "granted" | "idle" | "loading" | "unavailable"; +type MomentCaptureReturnTo = "map" | "music" | "recap"; + +const CAPTURE_PLACE_RADIUS_METERS = 3000; + +function resolveReturnPath(returnTo?: string | string[]) { + const value = Array.isArray(returnTo) ? returnTo[0] : returnTo; + + if (value === "recap") { + return "/recap"; + } + + if (value === "music") { + return "/music"; + } + + return "/"; +} export function MomentCaptureScreen() { + const { returnTo } = useLocalSearchParams<{ + returnTo?: MomentCaptureReturnTo; + }>(); const cameraRef = useRef(null); + const reviewPanelRef = useRef(null); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); + const [capturedAt, setCapturedAt] = useState(); const [capturedPhotoUri, setCapturedPhotoUri] = useState(); const [errorMessage, setErrorMessage] = useState(); const [isReviewing, setIsReviewing] = useState(false); - const [reviewMoodTags, setReviewMoodTags] = useState([]); - const [reviewNote, setReviewNote] = useState(''); - const [reviewPlaceName, setReviewPlaceName] = useState(''); + const [reviewMoodTags, setReviewMoodTags] = useState( + [], + ); + const [reviewPlaceName, setReviewPlaceName] = useState(""); + const [reviewTemplate, setReviewTemplate] = useState("film"); + const [recapVisibility, setRecapVisibility] = + useState("private"); const [shouldSaveMusic, setShouldSaveMusic] = useState(true); const [isCapturing, setIsCapturing] = useState(false); + const [isPickingPhoto, setIsPickingPhoto] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [locationStatus, setLocationStatus] = useState('idle'); + const [locationStatus, setLocationStatus] = useState("idle"); const addLog = useMomentLogStore((state) => state.addLog); const queueCreate = useMomentLogStore((state) => state.queueCreate); - const resolveLocalLog = useMomentLogStore((state) => state.resolveLocalLog); - const updateLog = useMomentLogStore((state) => state.updateLog); - const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); + const addRecommendationEvent = useRecommendationEventStore( + (state) => state.addEvent, + ); const { selectedMoodFilter } = useHomeFilterStore(); const { currentTrack, playlistId } = usePlayerStore(); - const { currentLocation, currentPlace, selectedMode, session, setLocation, startSession } = + const { currentLocation, currentPlace, selectedMode, session, setLocation } = useTravelSessionStore(); + const capturePlace = useMemo(() => { + if (!currentPlace) { + return undefined; + } - const moodTags = useMemo(() => getMoodTagsFromFilter(selectedMoodFilter), [selectedMoodFilter]); + if (!currentLocation) { + return currentPlace; + } + + if (!currentPlace.location) { + return undefined; + } + + return getDistanceMeters(currentLocation, currentPlace.location) <= + CAPTURE_PLACE_RADIUS_METERS + ? currentPlace + : undefined; + }, [currentLocation, currentPlace]); + + const moodTags = useMemo( + () => getMoodTagsFromFilter(selectedMoodFilter), + [selectedMoodFilter], + ); const prepareReview = (photoUri?: string) => { + setCapturedAt(new Date().toISOString()); setCapturedPhotoUri(photoUri); - setReviewPlaceName(currentPlace?.title ?? formatPlaceLabel(currentLocation)); + setReviewPlaceName(""); + setReviewTemplate("film"); setReviewMoodTags(moodTags); - setReviewNote(''); + setRecapVisibility("private"); setShouldSaveMusic(Boolean(currentTrack)); setIsReviewing(true); setErrorMessage(undefined); }; useEffect(() => { - if (Platform.OS === 'web' || session.status !== 'active') { + if (Platform.OS === "web") { return; } let isMounted = true; async function loadLocation() { - setLocationStatus('loading'); + setLocationStatus("loading"); try { const location = await getForegroundLocationWithTimeout(); @@ -82,15 +143,17 @@ export function MomentCaptureScreen() { if (location) { setLocation(location); - setLocationStatus('granted'); + setLocationStatus("granted"); } else { - const fallbackLocation = useTravelSessionStore.getState().currentLocation; - setLocationStatus(fallbackLocation ? 'granted' : 'unavailable'); + const fallbackLocation = + useTravelSessionStore.getState().currentLocation; + setLocationStatus(fallbackLocation ? "granted" : "unavailable"); } } catch { if (isMounted) { - const fallbackLocation = useTravelSessionStore.getState().currentLocation; - setLocationStatus(fallbackLocation ? 'granted' : 'unavailable'); + const fallbackLocation = + useTravelSessionStore.getState().currentLocation; + setLocationStatus(fallbackLocation ? "granted" : "unavailable"); } } } @@ -100,7 +163,7 @@ export function MomentCaptureScreen() { return () => { isMounted = false; }; - }, [session.status, setLocation]); + }, [setLocation]); const handleCapture = async () => { if (isCapturing) { @@ -117,16 +180,58 @@ export function MomentCaptureScreen() { }); if (!photo?.uri) { - throw new Error('capture_failed'); + throw new Error("capture_failed"); } prepareReview(photo.uri); } catch { - setErrorMessage('사진을 촬영하지 못했어요. 다시 시도해주세요.'); + setErrorMessage("사진을 촬영하지 못했어요. 다시 시도해주세요."); } finally { setIsCapturing(false); } }; + const handlePickGallery = async () => { + if (isPickingPhoto || isCapturing) { + return; + } + + setIsPickingPhoto(true); + setErrorMessage(undefined); + + try { + const result = await pickMomentPhotoFromLibrary(); + + if (result.status === "selected") { + prepareReview(result.uri); + return; + } + + if (result.status === "permission-denied") { + setErrorMessage( + "갤러리 사진을 사용하려면 사진 보관함 권한이 필요해요.", + ); + return; + } + + if (result.status === "unavailable") { + setErrorMessage("갤러리를 열지 못했어요. 다시 시도해주세요."); + } + } finally { + setIsPickingPhoto(false); + } + }; + const handleUseRecommendedPhoto = () => { + const recommendedPhotoUri = capturePlace?.imageUrl; + + if (!recommendedPhotoUri) { + setErrorMessage( + "현재 위치에서 추천할 관광지 사진이 없어요. 직접 촬영하거나 갤러리 사진을 선택해주세요.", + ); + return; + } + + prepareReview(recommendedPhotoUri); + }; const handleSave = async () => { if (!isReviewing || isSaving) { @@ -139,47 +244,62 @@ export function MomentCaptureScreen() { try { const id = `moment-${Date.now()}`; const locationSnapshot: GeoPoint | undefined = currentLocation; - const photoUri = capturedPhotoUri - ? await persistMomentPhoto(capturedPhotoUri, id) + const activeSessionId = + session.status === "active" ? session.id : undefined; + let photoSourceUri = capturedPhotoUri; + + if (capturedPhotoUri) { + const capturePromise = reviewPanelRef.current?.capturePhoto(); + const capturedCanvasUri = capturePromise + ? await capturePromise.catch(() => undefined) + : undefined; + + photoSourceUri = capturedCanvasUri ?? capturedPhotoUri; + } + + const photoUri = photoSourceUri + ? await persistMomentPhoto(photoSourceUri, id) : undefined; - const createdAt = new Date().toISOString(); - const placeName = reviewPlaceName.trim() || formatPlaceLabel(locationSnapshot); - const note = reviewNote.trim() || undefined; + const createdAt = capturedAt ?? new Date().toISOString(); + const placeName = reviewPlaceName.trim() || undefined; const trackSnapshot = shouldSaveMusic ? currentTrack : undefined; + const activeTravelMode = activeSessionId ? selectedMode : undefined; const recommendationContext = createRecommendationEventContext({ - placeCategory: currentPlace?.category, - placeId: currentPlace?.id, + placeCategory: capturePlace?.category, + placeId: capturePlace?.id, placeName, - travelMode: selectedMode, + travelMode: activeTravelMode, }); const localLog: MomentLog = { createdAt, id, location: locationSnapshot, moodTags: reviewMoodTags, - note, - placeCategory: currentPlace?.category, - placeId: currentPlace?.id, + placeCategory: capturePlace?.category, + placeId: capturePlace?.id, photoUri, placeName, - sessionId: session.id, - source: 'camera', - syncStatus: 'pending', + recapVisibility, + sessionId: activeSessionId, + source: "camera", + syncStatus: "pending", + templateId: reviewTemplate, track: trackSnapshot, - travelMode: selectedMode, + travelMode: activeTravelMode, }; const createPayload: MomentLogCreateQueuePayload = { createdAt, location: locationSnapshot, moodTags: reviewMoodTags, - note, photoUri, - placeCategory: currentPlace?.category, - placeId: currentPlace?.id, + placeCategory: capturePlace?.category, + placeId: capturePlace?.id, placeName, - sessionId: session.id, + recapVisibility, + sessionId: activeSessionId, + templateId: reviewTemplate, track: trackSnapshot, - travelMode: selectedMode, + travelMode: activeTravelMode, }; addLog(localLog); @@ -189,89 +309,35 @@ export function MomentCaptureScreen() { context: recommendationContext, playlistId, trackId: trackSnapshot?.id, - type: 'moment_log_saved', + type: "moment_log_saved", value: localLog.syncStatus, }), ); - void momentLogApi - .createMomentLog({ - ...createPayload, - idempotencyKey: id, - }) - .then((serverLog) => { - if (!serverLog) { - updateLog(id, { syncStatus: 'local' }); - return; - } - - resolveLocalLog(id, serverLog); - }) - .catch(() => { - updateLog(id, { syncStatus: 'failed' }); - syncRecommendationEvent( - addRecommendationEvent({ - context: recommendationContext, - playlistId, - trackId: trackSnapshot?.id, - type: 'moment_log_sync_failed', - value: 'network_error', - }), - ); - }); - - router.replace('/'); + router.replace(resolveReturnPath(returnTo) as never); } catch { - setErrorMessage('이 순간을 저장하지 못했어요. 다시 시도해주세요.'); + setErrorMessage("이 리캡을 저장하지 못했어요. 다시 시도해주세요."); } finally { setIsSaving(false); } }; - if (session.status !== 'active') { - return ( - - - - 여행을 먼저 시작해요 - - - 순간 저장은 현재 여행에 연결돼요. 여행을 시작하면 사진, 음악, 장소가 하나의 기록으로 묶입니다. - - startSession()} - > - - 여행 시작하고 촬영하기 - - - router.back()} - > - 돌아가기 - - - - ); - } - if (isReviewing) { return ( { + setCapturedAt(undefined); setCapturedPhotoUri(undefined); setIsReviewing(false); setErrorMessage(undefined); @@ -280,37 +346,51 @@ export function MomentCaptureScreen() { onToggleMusic={() => setShouldSaveMusic((value) => !value)} photoUri={capturedPhotoUri} placeName={reviewPlaceName} + selectedTemplate={reviewTemplate} track={currentTrack} - travelMode={selectedMode} + travelMode={session.status === "active" ? selectedMode : undefined} + visibility={recapVisibility} /> ); } - if (Platform.OS === 'web') { + if (Platform.OS === "web") { return ( - - - - 앱에서 사용할 수 있어요 - - - 카메라 촬영은 모바일 앱에서 사용할 수 있어요. 대신 사진 없이 음악과 장소만 먼저 기록할 수 있습니다. - + + + + + + + + + + prepareReview()} > - + 사진 없이 기록하기 router.back()} > - 돌아가기 + + 돌아가기 + @@ -335,10 +415,13 @@ export function MomentCaptureScreen() { cameraRef={cameraRef} errorMessage={errorMessage} isCapturing={isCapturing} + isPickingPhoto={isPickingPhoto} locationStatus={locationStatus} onCapture={handleCapture} onClose={() => router.back()} + onPickGallery={() => void handlePickGallery()} onSkipPhoto={() => prepareReview()} + onUseRecommendedPhoto={handleUseRecommendedPhoto} /> ); } diff --git a/src/components/moment-capture/MomentPhotoCanvas.tsx b/src/components/moment-capture/MomentPhotoCanvas.tsx new file mode 100644 index 0000000..51f387a --- /dev/null +++ b/src/components/moment-capture/MomentPhotoCanvas.tsx @@ -0,0 +1,1387 @@ +import { Feather } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import { + type ComponentProps, + type ComponentRef, + forwardRef, + type MutableRefObject, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { + Animated, + type LayoutChangeEvent, + PanResponder, + Platform, + Pressable, + StyleSheet, + View, +} from 'react-native'; +import ViewShot from 'react-native-view-shot'; +import type { MapStyleElement } from 'react-native-maps'; + +import { AppText } from '@/components/AppText'; +import type { GeoPoint, RecapTemplateId, Track } from '@/types/domain'; + +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 FeatherIconName = ComponentProps['name']; + +export type MomentPhotoCanvasHandle = { + capturePhoto: () => Promise; +}; + +type MomentPhotoCanvasProps = { + capturedAt?: string; + isSaving: boolean; + location?: GeoPoint; + onStickerDragChange?: (isDragging: boolean) => void; + photoUri: string; + placeName?: string; + selectedTemplate: RecapTemplateId; + track?: Track; +}; + +type CanvasSize = { + height: number; + width: number; +}; + +type StickerSize = { + height: number; + width: number; +}; + +type StickerPosition = { + x: number; + y: number; +}; + +const CANVAS_PADDING = 16; +export const MOMENT_PHOTO_CANVAS_ASPECT_RATIO = 4 / 5; +const recapMapDarkStyle: MapStyleElement[] = [ + { elementType: 'geometry', stylers: [{ color: '#10172A' }] }, + { elementType: 'labels.text.fill', stylers: [{ color: '#B8C0D8' }] }, + { elementType: 'labels.text.stroke', stylers: [{ color: '#0B1020' }] }, + { + elementType: 'geometry', + featureType: 'poi.park', + stylers: [{ color: '#163020' }], + }, + { + elementType: 'geometry', + featureType: 'road', + stylers: [{ color: '#273147' }], + }, + { + elementType: 'geometry', + featureType: 'road.highway', + stylers: [{ color: '#38435C' }], + }, + { + elementType: 'geometry', + featureType: 'water', + stylers: [{ color: '#0A2238' }], + }, +]; + +let nativeMapsModule: NativeMapsModule | undefined; + +function getNativeMaps() { + if (Platform.OS === 'web') { + return undefined; + } + + nativeMapsModule ??= require('react-native-maps') as NativeMapsModule; + return nativeMapsModule; +} + +const timestampStickerSizes: Record = { + card: { height: 90, width: 184 }, + stamp: { height: 78, width: 164 }, + type: { height: 82, width: 206 }, +}; + +const musicStickerSizes: Record = { + label: { height: 70, width: 206 }, + player: { height: 82, width: 228 }, + vinyl: { height: 88, width: 218 }, +}; + +const timestampStickerTemplates: Array<{ + label: string; + value: TimestampStickerTemplate; +}> = [ + { label: '카드', value: 'card' }, + { label: '스탬프', value: 'stamp' }, + { label: '타이포', value: 'type' }, +]; + +const musicStickerTemplates: Array<{ + label: string; + value: MusicStickerTemplate; +}> = [ + { label: '플레이어', value: 'player' }, + { label: '라벨', value: 'label' }, + { label: '바이닐', value: 'vinyl' }, +]; + +const stickerThemes: Array<{ + label: string; + value: StickerTheme; +}> = [ + { label: '글래스', value: 'glass' }, + { label: '라임', value: 'lime' }, + { label: '모노', value: 'mono' }, +]; + +const recapTemplatePresets: Record< + RecapTemplateId, + { + musicTemplate: MusicStickerTemplate; + musicTheme: StickerTheme; + timestampTemplate: TimestampStickerTemplate; + timestampTheme: StickerTheme; + } +> = { + album: { + musicTemplate: 'player', + musicTheme: 'glass', + timestampTemplate: 'card', + timestampTheme: 'glass', + }, + film: { + musicTemplate: 'label', + musicTheme: 'glass', + timestampTemplate: 'type', + timestampTheme: 'glass', + }, + lp: { + musicTemplate: 'vinyl', + musicTheme: 'mono', + timestampTemplate: 'stamp', + timestampTheme: 'mono', + }, + map: { + musicTemplate: 'label', + musicTheme: 'lime', + timestampTemplate: 'card', + timestampTheme: 'lime', + }, +}; + +export const MomentPhotoCanvas = forwardRef< + MomentPhotoCanvasHandle, + MomentPhotoCanvasProps +>(function MomentPhotoCanvas( + { + capturedAt, + isSaving, + location, + onStickerDragChange, + photoUri, + placeName, + selectedTemplate, + track, + }, + ref, +) { + const initialPreset = recapTemplatePresets[selectedTemplate]; + const photoCaptureRef = useRef>(null); + const timestampPan = useRef( + new Animated.ValueXY({ x: CANVAS_PADDING, y: CANVAS_PADDING }), + ).current; + const musicPan = useRef( + new Animated.ValueXY({ x: CANVAS_PADDING, y: CANVAS_PADDING }), + ).current; + const timestampPositionRef = useRef({ + x: CANVAS_PADDING, + y: CANVAS_PADDING, + }); + const musicPositionRef = useRef({ + x: CANVAS_PADDING, + y: CANVAS_PADDING, + }); + const didPlaceTimestampRef = useRef(false); + const didPlaceMusicRef = useRef(false); + const [canvasSize, setCanvasSize] = useState({ + height: 0, + width: 0, + }); + const [activeSticker, setActiveSticker] = useState(); + const [timestampTheme, setTimestampTheme] = useState( + initialPreset.timestampTheme, + ); + const [timestampTemplate, setTimestampTemplate] = + useState(initialPreset.timestampTemplate); + const [musicTheme, setMusicTheme] = useState( + initialPreset.musicTheme, + ); + const [musicTemplate, setMusicTemplate] = + useState(initialPreset.musicTemplate); + const [musicVisible, setMusicVisible] = useState(Boolean(track)); + const stickerDateTime = useMemo( + () => formatStickerDateTime(capturedAt), + [capturedAt], + ); + const timestampStickerSize = timestampStickerSizes[timestampTemplate]; + const musicStickerSize = musicStickerSizes[musicTemplate]; + const timestampThemeStyle = getStickerThemeStyle(timestampTheme); + const musicThemeStyle = getStickerThemeStyle(musicTheme); + const isDraggingMusic = activeSticker === 'music'; + const isMapTemplate = selectedTemplate === 'map'; + + const handleCanvasLayout = (event: LayoutChangeEvent) => { + const { height, width } = event.nativeEvent.layout; + + setCanvasSize((current) => + current.height === height && current.width === width + ? current + : { height, width }, + ); + }; + + const handleDragStart = useCallback( + (sticker: StickerKind) => { + setActiveSticker(sticker); + onStickerDragChange?.(true); + }, + [onStickerDragChange], + ); + + const handleDragEnd = useCallback(() => { + setActiveSticker(undefined); + onStickerDragChange?.(false); + }, [onStickerDragChange]); + + const musicPanResponder = useMemo( + () => + createStickerPanResponder({ + canvasSize, + onDragEnd: handleDragEnd, + onDragStart: () => handleDragStart('music'), + pan: musicPan, + positionRef: musicPositionRef, + size: musicStickerSize, + }), + [canvasSize, handleDragEnd, handleDragStart, musicPan, musicStickerSize], + ); + + useImperativeHandle( + ref, + () => ({ + capturePhoto: () => + photoCaptureRef.current?.capture?.() ?? Promise.resolve(undefined), + }), + [], + ); + + useEffect(() => { + didPlaceTimestampRef.current = false; + didPlaceMusicRef.current = false; + }, [photoUri]); + + useEffect(() => { + const preset = recapTemplatePresets[selectedTemplate]; + + setTimestampTemplate(preset.timestampTemplate); + setTimestampTheme(preset.timestampTheme); + setMusicTemplate(preset.musicTemplate); + setMusicTheme(preset.musicTheme); + didPlaceTimestampRef.current = false; + didPlaceMusicRef.current = false; + }, [photoUri, selectedTemplate]); + + useEffect(() => { + setMusicVisible(Boolean(track)); + didPlaceMusicRef.current = false; + }, [photoUri, track?.id, track]); + + useEffect(() => { + if (!canvasSize.height || !canvasSize.width) { + return; + } + + if (!didPlaceTimestampRef.current) { + const defaultPosition = getDefaultTimestampStickerPosition( + canvasSize, + timestampStickerSize, + ); + + timestampPositionRef.current = defaultPosition; + timestampPan.setValue(defaultPosition); + didPlaceTimestampRef.current = true; + } else { + const nextPosition = clampStickerPosition( + timestampPositionRef.current, + canvasSize, + timestampStickerSize, + ); + + timestampPositionRef.current = nextPosition; + timestampPan.setValue(nextPosition); + } + + if (!didPlaceMusicRef.current) { + const defaultPosition = getDefaultMusicStickerPosition( + canvasSize, + musicStickerSize, + ); + + musicPositionRef.current = defaultPosition; + musicPan.setValue(defaultPosition); + didPlaceMusicRef.current = true; + return; + } + + const nextMusicPosition = clampStickerPosition( + musicPositionRef.current, + canvasSize, + musicStickerSize, + ); + + musicPositionRef.current = nextMusicPosition; + musicPan.setValue(nextMusicPosition); + }, [canvasSize, musicPan, musicStickerSize, timestampPan, timestampStickerSize]); + + return ( + + + + {isMapTemplate ? ( + + ) : ( + <> + + + + + + )} + + + + + + {!isMapTemplate && musicVisible && track ? ( + + + + ) : null} + + + + + + + + + + + + + 촬영 시간 + + + 촬영 시각으로 고정되며 이동할 수 없어요. + + + + + {stickerDateTime.time} + + + + + + + + + + + {isMapTemplate ? ( + + + + + + + 지도 음악 핀 + + + {track + ? `${track.title} - ${track.artist}가 저장 위치 핀에 고정돼요.` + : '음악을 선택하면 저장 위치 핀에 곡 정보가 표시돼요.'} + + + + ) : ( + <> + setMusicVisible((value) => !value)} + title="현재 음악 스티커" + visible={musicVisible && Boolean(track)} + /> + + {track + ? `${track.title} - ${track.artist}` + : '선택한 음악이 있으면 사진 위에 함께 배치할 수 있어요.'} + + + + + + + )} + + + + ); +}); + +function MapTemplateBackground({ + location, + placeName, + track, +}: { + location?: GeoPoint; + placeName?: string; + track?: Track; +}) { + const NativeMaps = getNativeMaps(); + const placeLabel = placeName?.trim() || (location ? '저장 위치' : '위치 정보 없음'); + + if (!NativeMaps || !location) { + return ( + + ); + } + + const MapView = NativeMaps.default; + const Marker = NativeMaps.Marker; + + return ( + + + + + + + + + + ); +} + +function MapMusicPin({ track }: { track?: Track }) { + return ( + + + {track?.albumImageUrl ? ( + + ) : ( + + + + )} + + + {track?.title ?? '음악을 선택해 주세요'} + + + {track?.artist ?? 'Soundlog'} + + + + + + + + + + ); +} + +function MapPlaceLabel({ label }: { label: string }) { + return ( + + + + {label} + + + ); +} + +function FallbackRecapMap({ + placeLabel, + track, +}: { + placeLabel: string; + track?: Track; +}) { + return ( + + + + + + + + + + + + + ); +} + +function createStickerPanResponder({ + canvasSize, + onDragEnd, + onDragStart, + pan, + positionRef, + size, +}: { + canvasSize: CanvasSize; + onDragEnd: () => void; + onDragStart: () => void; + pan: Animated.ValueXY; + positionRef: MutableRefObject; + size: StickerSize; +}) { + const finishDrag = () => { + onDragEnd(); + pan.stopAnimation((value) => { + positionRef.current = clampStickerPosition(value, canvasSize, size); + pan.setValue(positionRef.current); + }); + }; + + return PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => + Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2, + onMoveShouldSetPanResponderCapture: (_, gestureState) => + Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2, + onPanResponderGrant: () => { + onDragStart(); + pan.stopAnimation((value) => { + const nextPosition = clampStickerPosition(value, canvasSize, size); + positionRef.current = nextPosition; + pan.setValue(nextPosition); + }); + }, + onPanResponderMove: (_, gestureState) => { + const nextPosition = clampStickerPosition( + { + x: positionRef.current.x + gestureState.dx, + y: positionRef.current.y + gestureState.dy, + }, + canvasSize, + size, + ); + + pan.setValue(nextPosition); + }, + onPanResponderRelease: finishDrag, + onPanResponderTerminationRequest: () => false, + onPanResponderTerminate: finishDrag, + onShouldBlockNativeResponder: () => true, + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + }); +} + +function TimestampStickerContent({ + date, + iconColor, + template, + textStyle, + time, +}: { + date: string; + iconColor: string; + template: TimestampStickerTemplate; + textStyle: ReturnType; + time: string; +}) { + if (template === 'stamp') { + return ( + + + {time || '--:--'} + + + + {date} + + + ); + } + + if (template === 'type') { + return ( + + + + {time || '--:--'} + + + {date} + + + + ); + } + + return ( + <> + + + 촬영 시간 + + + + + {time || '--:--'} + + + {date} + + + ); +} + +function MusicStickerContent({ + artist, + iconColor, + template, + textStyle, + title, +}: { + artist: string; + iconColor: string; + template: MusicStickerTemplate; + textStyle: ReturnType; + title: string; +}) { + if (template === 'label') { + return ( + + + + + + + Now playing + + + {title} + + + {artist} + + + + ); + } + + if (template === 'vinyl') { + return ( + + + + + + + + + Soundtrack + + + {title} + + + {artist} + + + + ); + } + + return ( + <> + + + Current music + + + + + + + + + + {title} + + + {artist} + + + + + ); +} + +function StickerControlHeader({ + disabled, + icon, + isSaving, + onToggle, + title, + visible, +}: { + disabled?: boolean; + icon: FeatherIconName; + isSaving: boolean; + onToggle: () => void; + title: string; + visible: boolean; +}) { + return ( + + + + + + {title} + + + + + + ); +} + +function StickerSegment({ + disabled, + onChange, + options, + value, +}: { + disabled?: boolean; + onChange: (value: T) => void; + options: Array<{ label: string; value: T }>; + value: T; +}) { + return ( + + {options.map((option) => { + const selected = option.value === value; + + return ( + onChange(option.value)} + style={{ + backgroundColor: selected ? '#fff' : 'transparent', + opacity: disabled ? 0.5 : 1, + }} + > + + {option.label} + + + ); + })} + + ); +} + +function clampStickerPosition( + position: StickerPosition, + canvasSize: CanvasSize, + size: StickerSize, +) { + if (!canvasSize.height || !canvasSize.width) { + return { x: CANVAS_PADDING, y: CANVAS_PADDING }; + } + + const maxX = Math.max( + CANVAS_PADDING, + canvasSize.width - size.width - CANVAS_PADDING, + ); + const maxY = Math.max( + CANVAS_PADDING, + canvasSize.height - size.height - CANVAS_PADDING, + ); + + return { + x: Math.min(Math.max(position.x, CANVAS_PADDING), maxX), + y: Math.min(Math.max(position.y, CANVAS_PADDING), maxY), + }; +} + +function getDefaultTimestampStickerPosition( + canvasSize: CanvasSize, + size: StickerSize, +) { + return clampStickerPosition( + { + x: CANVAS_PADDING, + y: canvasSize.height - size.height - 26, + }, + canvasSize, + size, + ); +} + +function getDefaultMusicStickerPosition(canvasSize: CanvasSize, size: StickerSize) { + return clampStickerPosition( + { + x: canvasSize.width - size.width - CANVAS_PADDING, + y: CANVAS_PADDING, + }, + canvasSize, + size, + ); +} + +function formatTwoDigits(value: number) { + return String(value).padStart(2, '0'); +} + +function formatStickerDateTime(value?: string) { + const date = value ? new Date(value) : new Date(); + + if (Number.isNaN(date.getTime())) { + return { + date: '--.--.--', + time: '--:--:--', + }; + } + + return { + date: `${formatTwoDigits(date.getFullYear() % 100)}.${formatTwoDigits(date.getMonth() + 1)}.${formatTwoDigits(date.getDate())}`, + time: `${formatTwoDigits(date.getHours())}:${formatTwoDigits(date.getMinutes())}:${formatTwoDigits(date.getSeconds())}`, + }; +} + +function getStickerThemeStyle(theme: StickerTheme) { + if (theme === 'lime') { + return { + container: { + backgroundColor: 'rgba(183,230,40,0.94)', + borderColor: 'rgba(255,255,255,0.42)', + }, + dateText: { color: 'rgba(5,9,22,0.72)' }, + divider: { backgroundColor: 'rgba(5,9,22,0.28)' }, + iconBubble: { backgroundColor: 'rgba(5,9,22,0.13)' }, + iconColor: 'rgba(5,9,22,0.78)', + metaText: { color: 'rgba(5,9,22,0.66)' }, + timeText: { color: '#050916' }, + }; + } + + if (theme === 'mono') { + return { + container: { + backgroundColor: 'rgba(255,255,255,0.92)', + borderColor: 'rgba(255,255,255,0.66)', + }, + dateText: { color: 'rgba(5,9,22,0.6)' }, + divider: { backgroundColor: 'rgba(5,9,22,0.22)' }, + iconBubble: { backgroundColor: 'rgba(5,9,22,0.08)' }, + iconColor: 'rgba(5,9,22,0.64)', + metaText: { color: 'rgba(5,9,22,0.5)' }, + timeText: { color: '#050916' }, + }; + } + + return { + container: { + backgroundColor: 'rgba(5,9,22,0.58)', + borderColor: 'rgba(255,255,255,0.28)', + }, + dateText: { color: 'rgba(255,255,255,0.66)' }, + divider: { backgroundColor: 'rgba(255,255,255,0.2)' }, + iconBubble: { backgroundColor: 'rgba(255,255,255,0.1)' }, + iconColor: 'rgba(255,255,255,0.72)', + metaText: { color: 'rgba(255,255,255,0.54)' }, + timeText: { color: '#fff' }, + }; +} + +function getTimestampTemplateStyle(template: TimestampStickerTemplate) { + if (template === 'stamp') { + return { + borderRadius: 999, + paddingHorizontal: 16, + paddingVertical: 10, + }; + } + + if (template === 'type') { + return { + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + }; + } + + return { + borderRadius: 18, + paddingHorizontal: 14, + paddingVertical: 10, + }; +} + +function getMusicTemplateStyle(template: MusicStickerTemplate) { + if (template === 'label') { + return { + borderRadius: 18, + paddingHorizontal: 12, + paddingVertical: 10, + }; + } + + if (template === 'vinyl') { + return { + borderRadius: 22, + paddingHorizontal: 12, + paddingVertical: 12, + }; + } + + return { + borderRadius: 18, + paddingHorizontal: 14, + paddingVertical: 10, + }; +} + +function getPhotoCanvasTemplateStyle(template: RecapTemplateId) { + if (template === 'lp') { + return { + borderColor: 'rgba(255,255,255,0.72)', + borderRadius: 16, + borderWidth: 2, + }; + } + + if (template === 'film') { + return { + borderColor: 'rgba(255,255,255,0.34)', + borderRadius: 4, + borderWidth: 3, + }; + } + + if (template === 'map') { + return { + borderColor: 'rgba(183,230,40,0.88)', + borderRadius: 8, + borderWidth: 2, + }; + } + + return { + borderColor: 'rgba(255,255,255,0.16)', + borderRadius: 8, + borderWidth: 1, + }; +} + +const styles = StyleSheet.create({ + fallbackBlock: { + backgroundColor: '#16263B', + borderColor: '#263852', + borderRadius: 5, + borderWidth: 1, + position: 'absolute', + }, + fallbackBlockOne: { + height: 92, + left: 24, + top: 32, + width: 118, + }, + fallbackBlockThree: { + bottom: 58, + height: 88, + right: 18, + width: 124, + }, + fallbackBlockTwo: { + height: 112, + right: 26, + top: 72, + width: 102, + }, + fallbackMap: { + backgroundColor: '#0D1725', + bottom: 0, + left: 0, + overflow: 'hidden', + position: 'absolute', + right: 0, + top: 0, + }, + fallbackMapPin: { + left: '50%', + marginLeft: -110, + marginTop: -54, + position: 'absolute', + top: '48%', + }, + fallbackRoad: { + backgroundColor: '#46516A', + borderColor: '#59647A', + borderWidth: 1, + height: 18, + position: 'absolute', + width: '150%', + }, + fallbackRoadOne: { + left: -70, + top: 202, + transform: [{ rotate: '-18deg' }], + }, + fallbackRoadThree: { + left: -92, + top: 334, + transform: [{ rotate: '12deg' }], + }, + fallbackRoadTwo: { + left: -76, + top: 105, + transform: [{ rotate: '42deg' }], + }, + mapEdgeShade: { + backgroundColor: 'rgba(5,9,22,0.12)', + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + mapMusicArtist: { + color: 'rgba(255,255,255,0.58)', + fontSize: 10, + fontWeight: '600', + marginTop: 2, + }, + mapMusicArtwork: { + borderRadius: 6, + height: 38, + width: 38, + }, + mapMusicArtworkFallback: { + alignItems: 'center', + borderRadius: 6, + height: 38, + justifyContent: 'center', + width: 38, + }, + mapMusicBubble: { + alignItems: 'center', + backgroundColor: 'rgba(5,9,22,0.96)', + borderColor: '#B7E628', + borderRadius: 8, + borderWidth: 2, + flexDirection: 'row', + padding: 7, + shadowColor: '#000000', + shadowOffset: { height: 8, width: 0 }, + shadowOpacity: 0.32, + shadowRadius: 14, + width: 220, + }, + mapMusicText: { + flex: 1, + marginLeft: 9, + minWidth: 0, + }, + mapMusicTitle: { + color: '#FFFFFF', + fontSize: 13, + fontWeight: '700', + }, + mapPinConnector: { + backgroundColor: '#B7E628', + height: 8, + width: 2, + }, + mapPinContainer: { + alignItems: 'center', + width: 220, + }, + mapPinHead: { + alignItems: 'center', + backgroundColor: '#B7E628', + borderColor: '#FFFFFF', + borderRadius: 999, + borderWidth: 2, + height: 32, + justifyContent: 'center', + shadowColor: '#000000', + shadowOffset: { height: 5, width: 0 }, + shadowOpacity: 0.3, + shadowRadius: 8, + width: 32, + }, + mapPinPoint: { + borderLeftColor: 'transparent', + borderLeftWidth: 5, + borderRightColor: 'transparent', + borderRightWidth: 5, + borderTopColor: '#B7E628', + borderTopWidth: 8, + height: 0, + marginTop: -2, + width: 0, + }, + mapPlaceLabel: { + alignItems: 'center', + backgroundColor: 'rgba(5,9,22,0.86)', + borderColor: 'rgba(255,255,255,0.16)', + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + left: 12, + maxWidth: 210, + paddingHorizontal: 10, + paddingVertical: 8, + position: 'absolute', + top: 12, + }, + mapPlaceLabelText: { + color: '#FFFFFF', + flexShrink: 1, + fontSize: 11, + fontWeight: '700', + marginLeft: 6, + }, + musicSticker: { + borderWidth: 1, + left: 0, + position: 'absolute', + shadowColor: '#000', + shadowOffset: { height: 8, width: 0 }, + shadowOpacity: 0.28, + shadowRadius: 18, + top: 0, + }, + photoCanvas: { + aspectRatio: MOMENT_PHOTO_CANVAS_ASPECT_RATIO, + backgroundColor: '#10131f', + borderRadius: 8, + overflow: 'hidden', + width: '100%', + }, + photoCanvasBottomCover: { + backgroundColor: 'rgba(5,9,22,0.96)', + bottom: 0, + height: '34%', + left: 0, + position: 'absolute', + right: 0, + }, + photoCanvasBottomVignette: { + bottom: 0, + height: '52%', + left: 0, + position: 'absolute', + right: 0, + }, + photoCanvasShade: { + backgroundColor: 'rgba(0,0,0,0.06)', + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + photoCaptureFrame: { + aspectRatio: MOMENT_PHOTO_CANVAS_ASPECT_RATIO, + borderRadius: 8, + overflow: 'hidden', + width: '100%', + }, + stickerDragging: { + shadowOpacity: 0.38, + shadowRadius: 22, + }, + timestampSticker: { + borderWidth: 1, + left: 0, + position: 'absolute', + shadowColor: '#000', + shadowOffset: { height: 8, width: 0 }, + shadowOpacity: 0.28, + shadowRadius: 18, + top: 0, + }, +}); diff --git a/src/components/moment-capture/MomentReviewPanel.tsx b/src/components/moment-capture/MomentReviewPanel.tsx index 55dacef..e361592 100644 --- a/src/components/moment-capture/MomentReviewPanel.tsx +++ b/src/components/moment-capture/MomentReviewPanel.tsx @@ -1,77 +1,116 @@ -import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; +import { Feather } from "@expo/vector-icons"; +import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, + Switch, TextInput, View, -} from 'react-native'; +} from "react-native"; -import { AppText } from '@/components/AppText'; -import { MomentSaveState } from '@/components/moment-capture/MomentSaveState'; -import { Screen } from '@/components/Screen'; -import { GeoPoint, MoodTag, Track, TravelMode } from '@/types/domain'; -import { formatPlaceLabel } from '@/utils/placeLabel'; +import { AppText } from "@/components/AppText"; +import { IconButton } from "@/components/IconButton"; +import { + MOMENT_PHOTO_CANVAS_ASPECT_RATIO, + MomentPhotoCanvas, + type MomentPhotoCanvasHandle, +} from "@/components/moment-capture/MomentPhotoCanvas"; +import { MomentSaveState } from "@/components/moment-capture/MomentSaveState"; +import { PageHeader } from "@/components/PageHeader"; +import { RecapTemplateSelector } from "@/components/recap-share/RecapTemplateSelector"; +import { Screen } from "@/components/Screen"; +import { SectionTitle } from "@/components/SectionTitle"; +import { SettingsRow } from "@/components/SettingsRow"; +import { + GeoPoint, + MoodTag, + RecapTemplateId, + RecapVisibility, + Track, + TravelMode, +} from "@/types/domain"; const travelModeLabels: Partial> = { - cafe: '카페 투어', - drive: '드라이브', - festival: '축제', - night: '야경 감상', - ocean: '바다 보기', - walk: '산책', + cafe: "카페 투어", + drive: "드라이브", + festival: "축제", + night: "야경 감상", + ocean: "바다 보기", + walk: "산책", }; const moodLabels: Record = { - active: '신나는', - calm: '잔잔한', - emotional: '감성적인', - fresh: '시원한', - local: '설레는', + active: "신나는", + calm: "잔잔한", + emotional: "감성적인", + fresh: "시원한", + local: "설레는", }; type MomentReviewPanelProps = { + capturedAt?: string; errorMessage?: string; includeMusic: boolean; isSaving: boolean; location?: GeoPoint; moodTags: MoodTag[]; - note: string; onChangeMoodTags: (moodTags: MoodTag[]) => void; - onChangeNote: (note: string) => void; onChangePlaceName: (placeName: string) => void; + onChangeTemplate: (template: RecapTemplateId) => void; + onChangeVisibility: (visibility: RecapVisibility) => void; onRetake: () => void; onSave: () => void; onToggleMusic: () => void; photoUri?: string; placeName: string; + selectedTemplate: RecapTemplateId; track?: Track; travelMode?: TravelMode; + visibility: RecapVisibility; +}; + +export type MomentReviewPanelHandle = { + capturePhoto: () => Promise; }; const editableMoodTags = Object.entries(moodLabels) as Array<[MoodTag, string]>; -export function MomentReviewPanel({ - errorMessage, - includeMusic, - isSaving, - location, - moodTags, - note, - onChangeMoodTags, - onChangeNote, - onChangePlaceName, - onRetake, - onSave, - onToggleMusic, - photoUri, - placeName, - track, - travelMode, -}: MomentReviewPanelProps) { - const moodLabel = moodTags.map((tag) => moodLabels[tag]).join(', ') || '무드 없음'; +export const MomentReviewPanel = forwardRef< + MomentReviewPanelHandle, + MomentReviewPanelProps +>(function MomentReviewPanel( + { + capturedAt, + errorMessage, + includeMusic, + isSaving, + location, + moodTags, + onChangeMoodTags, + onChangePlaceName, + onChangeTemplate, + onChangeVisibility, + onRetake, + onSave, + onToggleMusic, + photoUri, + placeName, + selectedTemplate, + track, + travelMode, + visibility, + }, + ref, +) { + const photoCanvasRef = useRef(null); + const [isCanvasStickerDragging, setIsCanvasStickerDragging] = useState(false); + const moodLabel = moodTags.map((tag) => moodLabels[tag]).join(", ") || "선택 안 함"; const canToggleMusic = Boolean(track); + const travelModeLabel = travelMode + ? (travelModeLabels[travelMode] ?? "미설정") + : "여행모드 아님"; + const toggleMoodTag = (tag: MoodTag) => { if (moodTags.includes(tag)) { onChangeMoodTags(moodTags.filter((item) => item !== tag)); @@ -81,156 +120,224 @@ export function MomentReviewPanel({ onChangeMoodTags([...moodTags, tag]); }; + useImperativeHandle( + ref, + () => ({ + capturePhoto: () => + photoCanvasRef.current?.capturePhoto() ?? Promise.resolve(undefined), + }), + [], + ); + return ( - - - - - 저장 확인 - - + + } + title="리캡 만들기" + /> + + 사진과 장소, 음악을 확인하고 저장하세요. + {photoUri ? ( - ) : ( - - + + - 사진 없이 음악만 남겨요 + 사진 없이 리캡을 만들어요 - - 장소, 곡, 무드, 메모만으로도 여행 사운드트랙 로그를 만들 수 있어요. + + 장소, 음악과 무드만으로도 저장할 수 있어요. )} - - - - - 장소 + + + + + + + + + + + + + - - - - - - 음악 - - {includeMusic && track ? `${track.title} - ${track.artist}` : '음악 없음'} - - - {canToggleMusic ? ( - - - - ) : null} - - - - - - - 무드 - {moodLabel} - - - {editableMoodTags.map(([tag, label]) => { - const selected = moodTags.includes(tag); + + 공개 범위 + + {( + [ + { label: "나만 보기", value: "private" }, + { label: "전체 공개", value: "public" }, + ] as const + ).map((option) => { + const selected = visibility === option.value; + const disabled = option.value === "public" && !location; return ( toggleMoodTag(tag)} - style={{ - backgroundColor: selected ? '#B7E628' : 'rgba(255,255,255,0.08)', - borderColor: selected ? '#B7E628' : 'rgba(255,255,255,0.1)', - }} + accessibilityState={{ disabled, selected }} + className={`min-h-11 flex-1 items-center justify-center rounded-full ${ + selected ? "bg-soundlog-lime" : "bg-transparent" + }`} + disabled={disabled || isSaving} + key={option.value} + onPress={() => onChangeVisibility(option.value)} + style={{ opacity: disabled ? 0.38 : 1 }} > - {label} + {option.label} ); })} + + {location + ? "전체 공개하면 현재 위치 300m 안의 사용자 지도에 표시돼요." + : "위치가 없는 리캡은 나만 볼 수 있어요."} + - - - - 메모 - - + + } + /> + + + {moodLabel} + } + title="무드" + /> + + {editableMoodTags.map(([tag, label]) => { + const selected = moodTags.includes(tag); + + return ( + toggleMoodTag(tag)} + > + + {label} + + + ); + })} + + + - + ) : ( - 이 순간 저장하기 + + 이 리캡 저장하기 + )} - - {photoUri ? '다시 촬영하기' : '사진 촬영하기'} + + {photoUri ? "다시 촬영하기" : "사진 촬영하기"} ); -} - -function InfoRow({ - icon, - label, - value, -}: { - icon: keyof typeof Feather.glyphMap; - label: string; - value: string; -}) { - return ( - - - - - - {label} - - {value} - - - - ); -} +}); diff --git a/src/components/moment-capture/MomentSaveState.tsx b/src/components/moment-capture/MomentSaveState.tsx index 95ce448..5c56eef 100644 --- a/src/components/moment-capture/MomentSaveState.tsx +++ b/src/components/moment-capture/MomentSaveState.tsx @@ -1,5 +1,3 @@ -import { View } from 'react-native'; - import { AppText } from '@/components/AppText'; type MomentSaveStateProps = { @@ -13,24 +11,18 @@ export function MomentSaveState({ message, type = 'info' }: MomentSaveStateProps } return ( - - {message} - + {message} + ); } diff --git a/src/components/my/AuthAccountCard.tsx b/src/components/my/AuthAccountCard.tsx index 13ac306..15949e2 100644 --- a/src/components/my/AuthAccountCard.tsx +++ b/src/components/my/AuthAccountCard.tsx @@ -1,41 +1,33 @@ -import { Feather } from '@expo/vector-icons'; import { router } from 'expo-router'; import { useState } from 'react'; -import { Alert, Linking, Pressable, View } from 'react-native'; +import { Alert, View } from 'react-native'; -import { useLogoutMutation } from '@/api/authQueries'; +import { useDeleteAccountMutation, useLogoutMutation } from '@/api/authQueries'; import { AppText } from '@/components/AppText'; -import { SOUNDLOG_SUPPORT_EMAIL } from '@/constants/legal'; +import { MySettingsRow } from '@/components/my/MySettingsRow'; +import { SectionTitle } from '@/components/SectionTitle'; import { useAuthStore } from '@/store/authStore'; +import { clearAccountSession } from '@/utils/accountSession'; import { migrateLocalDataToAccount } from '@/utils/localDataMigration'; -function openAccountDeletionEmail(userId?: string, email?: string) { - const subject = encodeURIComponent('Soundlog 계정 삭제 요청'); - const body = encodeURIComponent( - [ - 'Soundlog 계정 삭제를 요청합니다.', - '', - `계정 ID: ${userId ?? '확인 필요'}`, - `계정 이메일: ${email ?? '확인 필요'}`, - '', - '삭제 요청 처리에 필요한 추가 확인 절차가 있다면 안내해주세요.', - ].join('\n'), - ); +function getAccountInitial(displayName?: string, email?: string) { + const source = displayName?.trim() || email?.trim() || 'S'; - return Linking.openURL(`mailto:${SOUNDLOG_SUPPORT_EMAIL}?subject=${subject}&body=${body}`); + return source.slice(0, 1).toUpperCase(); } export function AuthAccountCard() { const [isMigratingLocalData, setIsMigratingLocalData] = useState(false); const [migrationMessage, setMigrationMessage] = useState(); + const deleteAccountMutation = useDeleteAccountMutation(); const logoutMutation = useLogoutMutation(); - const { logoutLocal, refreshToken, status, user } = useAuthStore(); + const { refreshToken, status, user } = useAuthStore(); const handleLogout = async () => { try { await logoutMutation.mutateAsync(refreshToken); } finally { - logoutLocal(); + clearAccountSession(); router.replace('/auth/login' as never); } }; @@ -46,15 +38,18 @@ export function AuthAccountCard() { try { const result = await migrateLocalDataToAccount(); - const failedCount = result.momentLogFailedCount + result.libraryFailedCount; + const failedCount = + result.momentLogFailedCount + result.libraryFailedCount; setMigrationMessage( failedCount > 0 - ? `순간 ${result.momentLogSyncedCount}/${result.summary.momentLogCount}개, 보관함 ${result.librarySyncedCount}/${result.summary.libraryTrackCount}개를 동기화했어요. 실패한 항목은 다시 시도할 수 있어요.` - : `순간 ${result.summary.momentLogCount}개, 보관함 ${result.summary.libraryTrackCount}개를 서버 동기화에 반영했어요.`, + ? `리캡 ${result.momentLogSyncedCount}/${result.summary.momentLogCount}개, 보관함 ${result.librarySyncedCount}/${result.summary.libraryTrackCount}개를 동기화했어요. 실패한 항목은 다시 시도할 수 있어요.` + : `리캡 ${result.summary.momentLogCount}개, 보관함 ${result.summary.libraryTrackCount}개를 서버 동기화에 반영했어요.`, ); } catch { - setMigrationMessage('동기화 요청에 실패했어요. 네트워크 상태를 확인해주세요.'); + setMigrationMessage( + '동기화 요청에 실패했어요. 네트워크 상태를 확인해주세요.', + ); } finally { setIsMigratingLocalData(false); } @@ -64,82 +59,88 @@ export function AuthAccountCard() { router.push('/auth/login' as never); }; - const handleDeleteAccountRequest = () => { + const handleDeleteAccount = () => { Alert.alert( - '계정 삭제 요청', - '요청을 보내면 계정과 서버에 동기화된 여행 기록 삭제 절차를 안내받게 됩니다.', + 'Soundlog 계정을 삭제할까요?', + '계정과 서버에 저장된 여행 기록, 리캡, 보관함 데이터가 즉시 삭제됩니다. 이 작업은 되돌릴 수 없어요.', [ { style: 'cancel', text: '취소' }, { onPress: () => { - void openAccountDeletionEmail(user?.id, user?.email).catch(() => { - setMigrationMessage( - `메일 앱을 열지 못했어요. ${SOUNDLOG_SUPPORT_EMAIL} 으로 계정 삭제를 요청해주세요.`, - ); - }); + setMigrationMessage(undefined); + void deleteAccountMutation + .mutateAsync() + .then(() => { + clearAccountSession(); + router.replace('/auth/login' as never); + }) + .catch(() => { + setMigrationMessage( + '계정을 삭제하지 못했어요. 네트워크 상태를 확인한 뒤 다시 시도해주세요.', + ); + }); }, style: 'destructive', - text: '요청 메일 작성', + text: '계정 삭제', }, ], ); }; if (status === 'authenticated' && user) { + const accountInitial = getAccountInitial(user.displayName, user.email); + return ( - - - - - - - - 로그인됨 + + + + + + + {accountInitial} - + + + {user.displayName} - - Soundlog 계정{user.email ? ` · ${user.email}` : ''} + + {user.email ?? 'Soundlog 계정'} + + 로그인됨 + - - - - {isMigratingLocalData ? '동기화 중' : '로컬 기록 동기화'} - - - - - {logoutMutation.isPending ? '정리 중' : '로그아웃'} - - - - - - - 계정 삭제 요청 - - + void handleMigrate()} + rightText={isMigratingLocalData ? '동기화 중' : undefined} + /> + void handleLogout()} + rightText={logoutMutation.isPending ? '정리 중' : undefined} + /> + {migrationMessage ? ( - + {migrationMessage} ) : null} @@ -148,31 +149,15 @@ export function AuthAccountCard() { } return ( - - - - - - - 계정 - - 로그인이 필요해요 - - - Soundlog의 추천, 기록, Recap은 계정에 저장된 상태로 사용할 수 있어요. - - - - - + + - - Soundlog 계정으로 로그인 - - + rightText="로그인 필요" + /> ); } diff --git a/src/components/my/MySettingsRow.tsx b/src/components/my/MySettingsRow.tsx new file mode 100644 index 0000000..e725780 --- /dev/null +++ b/src/components/my/MySettingsRow.tsx @@ -0,0 +1,4 @@ +export { + SettingsRow as MySettingsRow, + type SettingsRowProps as MySettingsRowProps, +} from '@/components/SettingsRow'; diff --git a/src/components/my/PermissionSettingsCard.tsx b/src/components/my/PermissionSettingsCard.tsx index 0fb1561..b07c983 100644 --- a/src/components/my/PermissionSettingsCard.tsx +++ b/src/components/my/PermissionSettingsCard.tsx @@ -3,7 +3,11 @@ import { ActivityIndicator, Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; import { PermissionStatusRow } from '@/components/my/PermissionStatusRow'; -import type { NativePermissionItem, NativePermissionKind } from '@/utils/nativePermissions'; +import { SectionTitle } from '@/components/SectionTitle'; +import type { + NativePermissionItem, + NativePermissionKind, +} from '@/utils/nativePermissions'; type PermissionSettingsCardProps = { errorMessage?: string; @@ -27,51 +31,45 @@ export function PermissionSettingsCard({ const isUnavailable = items.every((item) => item.status === 'unavailable'); return ( - - - - 권한 설정 - - 여행 기록에 필요한 접근 권한 - - - 권한 요청은 버튼을 눌렀을 때만 실행돼요. - - - - {isLoading ? ( - - ) : ( - - )} - - + + + {isLoading ? ( + + ) : ( + + )} + + } + title="권한 설정" + /> {errorMessage ? ( - - {errorMessage} - + + {errorMessage} + ) : null} {isUnavailable ? ( - - - 웹에서는 실제 기기 권한을 확인할 수 없어요. 모바일 앱에서 확인해주세요. - - + + 웹에서는 실제 기기 권한을 확인할 수 없어요. 모바일 앱에서 + 확인해주세요. + ) : null} - - {items.map((item, index) => ( - 0 ? 'border-t border-white/10' : ''} - > + + {items.map((item) => ( + = { +const statusMeta: Record< + NativePermissionStatus, + { color: string; label: string } +> = { denied: { - bg: 'rgba(248,113,113,0.14)', color: '#FCA5A5', label: '꺼짐', }, error: { - bg: 'rgba(251,191,36,0.14)', color: '#FCD34D', label: '확인 실패', }, granted: { - bg: 'rgba(34,197,94,0.14)', color: '#86EFAC', label: '허용됨', }, limited: { - bg: 'rgba(96,165,250,0.14)', color: '#93C5FD', label: '제한됨', }, unavailable: { - bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.55)', label: '미지원', }, undetermined: { - bg: 'rgba(196,181,253,0.14)', color: '#C4B5FD', label: '확인 필요', }, @@ -44,10 +39,8 @@ export function PermissionStatusBadge({ status }: PermissionStatusBadgeProps) { const meta = statusMeta[status]; return ( - - - {meta.label} - - + + {meta.label} + ); } diff --git a/src/components/my/PermissionStatusRow.tsx b/src/components/my/PermissionStatusRow.tsx index 241324a..e1ce3c2 100644 --- a/src/components/my/PermissionStatusRow.tsx +++ b/src/components/my/PermissionStatusRow.tsx @@ -3,7 +3,10 @@ import { ActivityIndicator, Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; import { PermissionStatusBadge } from '@/components/my/PermissionStatusBadge'; -import type { NativePermissionItem, NativePermissionKind } from '@/utils/nativePermissions'; +import type { + NativePermissionItem, + NativePermissionKind, +} from '@/utils/nativePermissions'; type PermissionStatusRowProps = { isRequesting: boolean; @@ -13,7 +16,10 @@ type PermissionStatusRowProps = { onRequest: (kind: NativePermissionKind) => void; }; -const permissionIcons: Record = { +const permissionIcons: Record< + NativePermissionKind, + keyof typeof Feather.glyphMap +> = { camera: 'camera', location: 'map-pin', mediaLibrary: 'image', @@ -83,37 +89,74 @@ export function PermissionStatusRow({ onRequest(item.kind); }; - return ( - - - + const content = ( + <> + + - - - {item.title} - - - + + + {item.title} + + {item.description} - {action ? ( - - {isRequesting ? ( - - ) : ( - {action.label} - )} - - ) : null} - + + + {isRequesting ? ( + + ) : action ? ( + + + {action.label} + + + + ) : null} + + + ); + + if (!action) { + return ( + {content} + ); + } + + return ( + ({ + opacity: isRequesting ? 0.5 : pressed ? 0.62 : 1, + })} + > + {content} + ); } diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index e12edb9..b670ebf 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -1,48 +1,48 @@ -import { Feather } from '@expo/vector-icons'; -import { Tabs, router } from 'expo-router'; -import { - Platform, - Pressable, - StyleProp, - View, - ViewStyle, -} from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Feather } from "@expo/vector-icons"; +import { router, Tabs } from "expo-router"; +import { Platform, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { colors } from '@/constants/colors'; -import { getTabBarHeight } from '@/constants/layout'; -import { useTravelSessionStore } from '@/store/travelSessionStore'; +import { colors } from "@/constants/colors"; +import { getTabBarHeight } from "@/constants/layout"; type TabIconName = keyof typeof Feather.glyphMap; +type CameraTabButtonProps = { + accessibilityState?: { + selected?: boolean; + }; +}; const webGlassTabBarStyle = { - backdropFilter: 'blur(22px) saturate(150%)', - WebkitBackdropFilter: 'blur(22px) saturate(150%)', + backdropFilter: "blur(22px) saturate(150%)", + WebkitBackdropFilter: "blur(22px) saturate(150%)", }; function TabIcon({ name, color }: { name: TabIconName; color: string }) { return ; } -function CameraTabButton({ style }: { style?: StyleProp }) { - const { session } = useTravelSessionStore(); - const openCamera = () => router.push('/camera'); - const handlePress = () => { - if (session.status === 'active') { - openCamera(); - return; - } - - openCamera(); - }; - +function CameraTabButton({ accessibilityState }: CameraTabButtonProps) { return ( - + { + router.push({ + params: { returnTo: "tabs" }, + pathname: "/camera", + } as never); + }} + style={{ + elevation: 12, + shadowColor: "#000", + shadowOffset: { height: 12, width: 0 }, + shadowOpacity: 0.32, + shadowRadius: 18, + }} > @@ -57,100 +57,84 @@ export function BottomNavigation() { return ( , - title: '홈', + tabBarIcon: ({ color }) => ( + + ), + title: "지도", }} /> , - title: 'Recap', + tabBarIcon: ({ color }) => ( + + ), + title: "음악추천", }} /> null, - tabBarButton: ({ style }) => , - title: '', + tabBarButton: (props) => , + tabBarIcon: () => null, + tabBarLabel: "", + title: "카메라", }} /> , - title: '보관함', + tabBarIcon: ({ color }) => ( + + ), + title: "로그", }} /> , - title: '마이', - }} - /> - - - - ( + + ), + title: "마이", }} /> diff --git a/src/components/onboarding/OnboardingScreen.tsx b/src/components/onboarding/OnboardingScreen.tsx index 0031194..f80371f 100644 --- a/src/components/onboarding/OnboardingScreen.tsx +++ b/src/components/onboarding/OnboardingScreen.tsx @@ -1,12 +1,14 @@ import { router, useLocalSearchParams } from 'expo-router'; -import { LinearGradient } from 'expo-linear-gradient'; import { useMemo, useState } from 'react'; import { Pressable, ScrollView, Switch, View } from 'react-native'; import { meApi } from '@/api/meApi'; import { AppText } from '@/components/AppText'; -import { BrandLogo } from '@/components/BrandLogo'; +import { IconButton } from '@/components/IconButton'; +import { PageHeader } from '@/components/PageHeader'; import { Screen } from '@/components/Screen'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; import { useAuthStore } from '@/store/authStore'; import { useHomeFilterStore } from '@/store/homeFilterStore'; import { @@ -74,7 +76,7 @@ export function OnboardingScreen() { const isEditMode = mode === 'edit'; const { completeOnboarding, profile, updateProfile } = useUserProfileStore(); const { status } = useAuthStore(); - const { setSelectedMoodFilter, setSelectedTopFilter } = useHomeFilterStore(); + const { setSelectedMoodFilter } = useHomeFilterStore(); const [currentStep, setCurrentStep] = useState(isEditMode ? 'setup' : 'intro'); const [selectedTravelLabel, setSelectedTravelLabel] = useState(() => getTravelLabelFromProfile(profile), @@ -98,7 +100,6 @@ export function OnboardingScreen() { ); const applyHomeFilters = (input: UserProfileInput) => { - setSelectedTopFilter('전체'); setSelectedMoodFilter(input.preferredMoods[0] ?? '전체'); }; @@ -157,87 +158,62 @@ export function OnboardingScreen() { const renderIntro = () => ( <> - - - - Soundlog - - router.push('/auth/login' as never)} - style={{ opacity: isSaving ? 0.45 : 1 }} - > - 로그인 - - + router.push('/auth/login' as never)} + style={{ opacity: isSaving ? 0.45 : 1 }} + > + 로그인 + + ) + } + title="Soundlog" + /> - + 지금 장소의 음악을{'\n'}여행 앨범으로 - + 음악은 외부 앱에서 듣고, Soundlog에는 여행의 사운드트랙을 남겨요. - - - - - - Recap preview - - - 서울숲 산책{'\n'}사운드트랙 - - - - - - - - - - ALBUM - 대표 곡 - 사진과 장소를 한 장으로 - - - FILM - - {[0, 1, 2, 3].map((item) => ( - - ))} - - - - - - - 앱 안에서는 음원을 재생하지 않고, 곡 정보와 외부 링크, 여행 기록을 이어 붙입니다. - - + + + + + + - + - + {saveErrorMessage ? ( - - - {saveErrorMessage} - - + + {saveErrorMessage} + ) : null} { if (status !== 'authenticated') { @@ -249,19 +225,10 @@ export function OnboardingScreen() { }} style={{ opacity: isSaving ? 0.55 : 1 }} > - - {status === 'authenticated' ? '시작하기' : '로그인하고 시작하기'} - - - router.push('/auth/login' as never)} - style={{ opacity: isSaving ? 0.55 : 1 }} - > - - 계정 만들기 또는 로그인 + + {status === 'authenticated' + ? '추천 취향 설정하기' + : '계정 만들기 또는 로그인'} @@ -270,68 +237,77 @@ export function OnboardingScreen() { const renderSetup = () => ( <> - - (isEditMode ? router.replace('/my' as never) : setCurrentStep('intro'))} - > - - - handlePrimarySetup(false)} - style={{ opacity: isSaving ? 0.45 : 1 }} - > - - {isEditMode ? '변경 없이 나가기' : '나중에 하기'} - - - + + isEditMode + ? router.replace('/my' as never) + : setCurrentStep('intro') + } + /> + } + rightContent={ + { + if (isEditMode) { + router.replace('/my' as never); + return; + } + + handlePrimarySetup(false); + }} + style={{ opacity: isSaving ? 0.45 : 1 }} + > + + {isEditMode ? '취소' : '나중에'} + + + } + title={isEditMode ? '추천 취향 수정' : '추천 취향'} + /> - - - 어떤 여행 중인가요? - - - 지금 장면과 듣고 싶은 분위기만 고르면 첫 추천을 바로 시작할게요. - - + + 장소 기반 추천에 반영할 여행 스타일과 무드를 선택하세요. + - - {travelOptions.map((option) => { - const selected = selectedTravelLabel === option.label; + + + + {travelOptions.map((option) => { + const selected = selectedTravelLabel === option.label; - return ( - setSelectedTravelLabel(option.label)} - > - setSelectedTravelLabel(option.label)} > - {option.label} - - - ); - })} + + {option.label} + + + ); + })} + - - 어떤 분위기인가요? - + {moodOptions.map((mood) => { const selected = selectedMood === mood; @@ -340,17 +316,17 @@ export function OnboardingScreen() { setSelectedMood(mood)} > {mood} @@ -362,17 +338,12 @@ export function OnboardingScreen() { - 권한 - - - - - 현재 위치로 추천받기 - - - 거부해도 산책·잔잔한 기본 추천과 수동 탐색은 계속 가능해요. - - + + - - + } + /> {saveErrorMessage ? ( - - - {saveErrorMessage} - - + + {saveErrorMessage} + ) : null} handlePrimarySetup(true)} + onPress={() => handlePrimarySetup(locationRecommendationEnabled)} style={{ opacity: isSaving ? 0.55 : 1 }} > - - {isSaving ? '저장 중...' : '위치 추천 켜기'} + + {isSaving + ? '저장 중...' + : isEditMode + ? '추천 설정 저장하기' + : '설정 저장하고 시작하기'} - handlePrimarySetup(false)} - style={{ opacity: isSaving ? 0.55 : 1 }} - > - 나중에 하기 - + {!isEditMode ? ( + handlePrimarySetup(false)} + style={{ opacity: isSaving ? 0.55 : 1 }} + > + + 나중에 하기 + + + ) : null} ); diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx index c7d48e6..674d0da 100644 --- a/src/components/playlist/PlaylistCurationScreen.tsx +++ b/src/components/playlist/PlaylistCurationScreen.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; -import { ScrollView, useWindowDimensions, View } from 'react-native'; +import { View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; import { libraryApi } from '@/api/libraryApi'; import { usePlaylistCurationQuery } from '@/api/playlistQueries'; import { syncRecommendationEvent } from '@/api/recommendationEventApi'; import { AppText } from '@/components/AppText'; +import { IconButton } from '@/components/IconButton'; import { MiniPlayer } from '@/components/MiniPlayer'; import { PlaylistBackground } from '@/components/playlist/PlaylistBackground'; import { PlaylistBottomSheet } from '@/components/playlist/PlaylistBottomSheet'; @@ -31,7 +33,6 @@ type PlaylistCurationScreenProps = { export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenProps) { const insets = useSafeAreaInsets(); - const { height } = useWindowDimensions(); const { currentTrack, setTrack } = usePlayerStore(); const { selectedMoodFilter, setSelectedMoodFilter } = useHomeFilterStore(); const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); @@ -81,7 +82,6 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro const hasMiniPlayer = Boolean(currentTrack); const listBottomPadding = getCurationListBottomPadding(insets.bottom, hasMiniPlayer); - const usesPlainMoodPage = playlistId === 'calm-walk' || Boolean(playlist?.accentColor); const selectTrackForSoundlog = (track: Track) => { if (!playlist) { @@ -101,7 +101,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro value: 'playlist_detail', }), ); - setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); + setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 리캡에 담을 수 있어요.'); }; const selectFirstTrack = () => { @@ -203,14 +203,13 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro - {usesPlainMoodPage ? ( - - {playlist ? ( + + router.back()} /> + + + - ) : null} - {playlistContent} - - ) : ( - - ) : undefined - } - > - {playlistContent} - - )} + ) : undefined + } + > + {playlistContent} + {actionMessage ? ( - + {playlist.regionName} {playlist.placeName ? ( @@ -93,12 +93,12 @@ export function PlaylistHeroInfo({ - + diff --git a/src/components/playlist/PlaylistState.tsx b/src/components/playlist/PlaylistState.tsx index 14621a9..7f854c3 100644 --- a/src/components/playlist/PlaylistState.tsx +++ b/src/components/playlist/PlaylistState.tsx @@ -5,10 +5,10 @@ import { AppText } from '@/components/AppText'; export function PlaylistLoadingState() { return ( - + {[0, 1, 2, 3, 4].map((item) => ( - + @@ -33,8 +33,14 @@ export function PlaylistErrorState({ onRetry }: PlaylistErrorStateProps) { 네트워크 상태를 확인한 뒤 다시 시도해주세요. {onRetry ? ( - - 다시 시도 + + + 다시 시도 + ) : null} diff --git a/src/components/playlist/TrackActionMenu.tsx b/src/components/playlist/TrackActionMenu.tsx index e27b606..f301457 100644 --- a/src/components/playlist/TrackActionMenu.tsx +++ b/src/components/playlist/TrackActionMenu.tsx @@ -26,6 +26,9 @@ type MenuActionProps = { function MenuAction({ disabled = false, icon, label, onPress }: MenuActionProps) { return ( {track ? ( - - - 곡을 누르면 SoundLog 안에서 현재 여행 음악으로 선택돼요. - - + + 곡을 누르면 Soundlog에서 현재 음악으로 선택돼요. + ) : null} {actionMessage ? ( - - - {actionMessage} - - + + {actionMessage} + ) : null} diff --git a/src/components/playlist/TrackList.tsx b/src/components/playlist/TrackList.tsx index 0434fff..b632644 100644 --- a/src/components/playlist/TrackList.tsx +++ b/src/components/playlist/TrackList.tsx @@ -43,7 +43,7 @@ export function TrackList({ 추천 곡 - 좋아요 · 저장 · 열기로 지금 장소의 사운드트랙을 골라보세요. + 좋아요 · 저장 · 선택으로 지금 장소의 사운드트랙을 골라보세요. {tracks.map((item) => ( diff --git a/src/components/playlist/TrackRow.tsx b/src/components/playlist/TrackRow.tsx index 157a82c..52780b1 100644 --- a/src/components/playlist/TrackRow.tsx +++ b/src/components/playlist/TrackRow.tsx @@ -1,6 +1,5 @@ import { Feather } from '@expo/vector-icons'; import { Image } from 'expo-image'; -import { LinearGradient } from 'expo-linear-gradient'; import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; @@ -27,105 +26,75 @@ export function TrackRow({ track, }: TrackRowProps) { const keyColor = getTrackKeyColor(track); - const activeBackground = hexToRgba(keyColor, 0.78); - const activeGlow = hexToRgba(keyColor, 0.24); + const activeBackground = hexToRgba(keyColor, 0.16); return ( - - - - onPress(track)} + onPress(track)} + > + - - {track.albumImageUrl ? ( - - ) : ( - - )} - + {track.albumImageUrl ? ( + + ) : ( + + )} + - - - - {track.title} - - {isLiked ? : null} - {isSaved ? : null} - - - {track.artist} + + + + {track.title} + {isActive ? : null} - - - - onToggleLike(track)} - > - - + + {track.artist} + + + - onToggleSave(track)} - > - - {isSaved ? '저장됨' : '저장'} - - + onToggleLike(track)} + > + + - onPress(track)} - > - - 열기 - - - - + onToggleSave(track)} + > + + ); } diff --git a/src/components/recap-share/RecapMusicSummary.tsx b/src/components/recap-share/RecapMusicSummary.tsx index 709a292..5f06ff6 100644 --- a/src/components/recap-share/RecapMusicSummary.tsx +++ b/src/components/recap-share/RecapMusicSummary.tsx @@ -1,16 +1,12 @@ import { Feather } from '@expo/vector-icons'; -import { View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; import { RecapShare, RecapShareMoment } from '@/types/domain'; import { formatRecapRecordedAt } from '@/utils/dateFormat'; -type SummaryRowProps = { - icon: keyof typeof Feather.glyphMap; - label: string; - value: string; -}; - function createFallbackMoment(recap: RecapShare): RecapShareMoment { return { artistName: recap.artistName, @@ -34,10 +30,22 @@ function createTrackKey(moment: RecapShareMoment) { return `${moment.trackTitle.trim()}::${moment.artistName.trim()}`; } +function hasMusic(moment: RecapShareMoment) { + return ( + moment.artistName.trim() !== '음악 없음' && + moment.trackTitle.trim() !== '음악 없음' && + moment.trackTitle.trim() !== '저장된 순간' + ); +} + function createPhotoCount(moments: RecapShareMoment[]) { return moments.filter((moment) => Boolean(moment.imageUrl)).length; } +function createLocationMoments(moments: RecapShareMoment[]) { + return moments.filter((moment) => Boolean(moment.location)); +} + function createPlaceFlow(moments: RecapShareMoment[], fallbackPlaceName: string) { const firstPlace = moments[0]?.placeName?.trim() || fallbackPlaceName; const lastPlace = moments[moments.length - 1]?.placeName?.trim() || fallbackPlaceName; @@ -54,36 +62,80 @@ function createRecordedRange(moments: RecapShareMoment[], fallbackRecordedAt: st return firstLabel === lastLabel ? firstLabel : `${firstLabel} -> ${lastLabel}`; } -export function RecapMusicSummary({ recap }: { recap: RecapShare }) { +function createLocationFlow(moments: RecapShareMoment[], fallbackPlaceName: string) { + const locationMoments = createLocationMoments(moments); + + if (!locationMoments.length) { + return '촬영 위치 없음'; + } + + const firstPlace = locationMoments[0]?.placeName?.trim() || fallbackPlaceName; + const lastPlace = + locationMoments[locationMoments.length - 1]?.placeName?.trim() || + fallbackPlaceName; + + return firstPlace === lastPlace + ? firstPlace + : `${firstPlace} -> ${lastPlace}`; +} + +export function RecapMusicSummary({ + onOpenMap, + recap, +}: { + onOpenMap?: () => void; + recap: RecapShare; +}) { const moments = getMoments(recap); const photoCount = createPhotoCount(moments); + const locationMoments = createLocationMoments(moments); const placeCount = createUniqueCount(moments.map((moment) => moment.placeName)); - const trackCount = createUniqueCount(moments.map(createTrackKey)); + const musicMoments = moments.filter(hasMusic); + const trackCount = createUniqueCount(musicMoments.map(createTrackKey)); + const locationFlow = createLocationFlow(moments, recap.placeName); const placeFlow = createPlaceFlow(moments, recap.placeName); const recordedRange = createRecordedRange(moments, recap.recordedAt); + const hasLocations = locationMoments.length > 0; return ( - - - - - 대표 정보 - - - {recap.placeName} · {recap.trackTitle} - - - 사진 {photoCount}장 · 곡 {trackCount || 1}개 · {recap.artistName} - - - - - - - - - {moments.length}개 Moment · {placeCount || 1}곳 · {placeFlow} · {recordedRange} - + + + 0 + ? `${recap.trackTitle} · ${recap.artistName}` + : '음악 없이 남긴 기록' + } + icon="music" + label={recap.placeName} + rightText={`사진 ${photoCount} · 곡 ${trackCount}`} + /> + + + + + ) : null + } + /> ); } diff --git a/src/components/recap-share/RecapPreviewCard.tsx b/src/components/recap-share/RecapPreviewCard.tsx index 69ca41a..69a29ce 100644 --- a/src/components/recap-share/RecapPreviewCard.tsx +++ b/src/components/recap-share/RecapPreviewCard.tsx @@ -1,16 +1,57 @@ +import { Feather } from '@expo/vector-icons'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; -import { StyleSheet, View } from 'react-native'; +import { + Animated, + type LayoutChangeEvent, + PanResponder, + StyleSheet, + View, +} from 'react-native'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppText } from '@/components/AppText'; import { RecordDisc } from '@/components/recap-share/RecordDisc'; -import { RecapShare, RecapShareMoment, RecapTemplateId } from '@/types/domain'; +import { + GeoPoint, + RecapShare, + RecapShareMoment, + RecapTemplateId, + RoutePoint, +} from '@/types/domain'; type RecapPreviewCardProps = { + isMusicStickerDraggable?: boolean; + musicSticker?: RecapMusicStickerSettings; + onMusicStickerDragChange?: (isDragging: boolean) => void; recap: RecapShare; template?: RecapTemplateId; }; +export type RecapMusicStickerTemplate = 'badge' | 'label' | 'player'; +export type RecapMusicStickerTheme = 'glass' | 'lime' | 'mono'; + +export type RecapMusicStickerSettings = { + template: RecapMusicStickerTemplate; + theme: RecapMusicStickerTheme; + visible: boolean; +}; + +type RecapCanvasSize = { + height: number; + width: number; +}; + +const RECAP_STICKER_PADDING = 14; +const recapMusicStickerSizes: Record< + RecapMusicStickerTemplate, + { height: number; width: number } +> = { + badge: { height: 68, width: 190 }, + label: { height: 72, width: 214 }, + player: { height: 82, width: 226 }, +}; + function RecapBackground({ imageUrl, overlayClassName = 'bg-black/35', @@ -18,12 +59,17 @@ function RecapBackground({ imageUrl?: string; overlayClassName?: string; }) { + const [failedImageUrl, setFailedImageUrl] = useState(); + const visibleImageUrl = + imageUrl && failedImageUrl !== imageUrl ? imageUrl : undefined; + return ( <> - {imageUrl ? ( + {visibleImageUrl ? ( setFailedImageUrl(visibleImageUrl)} + source={{ uri: visibleImageUrl }} style={StyleSheet.absoluteFill} transition={300} /> @@ -44,16 +90,33 @@ function RecapBackground({ function RecapLpTemplate({ recap }: { recap: RecapShare }) { return ( <> - + + @@ -75,10 +138,13 @@ function RecapLpTemplate({ recap }: { recap: RecapShare }) { - + - + location.lat); + const lngValues = locations.map((location) => location.lng); + const minLat = Math.min(...latValues); + const maxLat = Math.max(...latValues); + const minLng = Math.min(...lngValues); + const maxLng = Math.max(...lngValues); + const latRange = maxLat - minLat; + const lngRange = maxLng - minLng; + + return locations.map((location, index) => { + if (latRange < 0.0001 && lngRange < 0.0001) { + return fallbackMapPinPositions[index % fallbackMapPinPositions.length]; + } + + const normalizedLat = + latRange < 0.0001 ? 0.5 : (location.lat - minLat) / latRange; + const normalizedLng = + lngRange < 0.0001 ? 0.5 : (location.lng - minLng) / lngRange; + + return { + left: clamp(52 + normalizedLng * 178, 42, 232), + top: clamp(272 - normalizedLat * 178, 86, 276), + }; + }); +} + +function createLocationPinPositions(moments: LocatedRecapMoment[]) { + return createLocationPositions(moments.map((moment) => moment.location)); +} + +function getRouteLocations( + routePoints: RoutePoint[] | undefined, + moments: LocatedRecapMoment[], +) { + if (routePoints && routePoints.length > 1) { + return routePoints; + } + + return moments.map((moment) => moment.location); +} + +function createRouteSegments(positions: Array<{ left: number; top: number }>) { + return positions.slice(1).flatMap((position, index) => { + const previousPosition = positions[index]; + const deltaX = position.left - previousPosition.left; + const deltaY = position.top - previousPosition.top; + const length = Math.sqrt(deltaX ** 2 + deltaY ** 2); + + if (length < 2) { + return []; + } + + return [ + { + angle: `${Math.atan2(deltaY, deltaX) * (180 / Math.PI)}deg`, + left: (previousPosition.left + position.left) / 2 - length / 2, + top: (previousPosition.top + position.top) / 2 - ROUTE_LINE_HEIGHT / 2, + width: length, + }, + ]; + }); +} + function RecapFilmTemplate({ recap }: { recap: RecapShare }) { const moments = ( recap.moments?.length ? recap.moments : [createFallbackMoment(recap)] @@ -261,15 +411,23 @@ function RecapFilmTemplate({ recap }: { recap: RecapShare }) { } function RecapMapTemplate({ recap }: { recap: RecapShare }) { - const moments = ( - recap.moments?.length ? recap.moments : [createFallbackMoment(recap)] - ).slice(0, 4); - const pinPositions = [ - { left: 54, top: 126 }, - { left: 190, top: 92 }, - { left: 226, top: 220 }, - { left: 104, top: 270 }, - ]; + const recapMoments = recap.moments?.length + ? recap.moments + : [createFallbackMoment(recap)]; + const locatedMoments = recapMoments.filter(hasLocation); + const hasRecordedLocations = locatedMoments.length > 0; + const moments = (hasRecordedLocations ? locatedMoments : recapMoments).slice( + 0, + 4, + ); + const routeLocations = getRouteLocations(recap.routePoints, locatedMoments); + const routeSegments = + routeLocations.length > 1 + ? createRouteSegments(createLocationPositions(routeLocations)) + : []; + const pinPositions = hasRecordedLocations + ? createLocationPinPositions(moments as LocatedRecapMoment[]) + : fallbackMapPinPositions; return ( <> @@ -320,20 +478,51 @@ function RecapMapTemplate({ recap }: { recap: RecapShare }) { /> + {routeSegments.map((segment, index) => ( + + ))} + - SOUNDLOG MAP + {routeSegments.length + ? 'TRAVEL ROUTE' + : hasRecordedLocations + ? 'MOMENT MAP' + : 'SOUNDLOG MAP'} - + {recap.placeName} + + + {hasRecordedLocations + ? routeSegments.length && recap.routePoints?.length + ? `${recap.routePoints.length}개 이동 좌표 저장됨` + : `${locatedMoments.length}개 촬영 위치 저장됨` + : '장소 흐름 미리보기'} + + {moments.map((moment, index) => ( @@ -341,7 +530,10 @@ function RecapMapTemplate({ recap }: { recap: RecapShare }) { - + {moment.placeName} @@ -350,12 +542,18 @@ function RecapMapTemplate({ recap }: { recap: RecapShare }) { - 대표 사운드 + {hasRecordedLocations ? '촬영 위치와 대표 사운드' : '대표 사운드'} - + {recap.trackTitle} - + {moments.length}개 장소 · {recap.artistName} @@ -363,16 +561,430 @@ function RecapMapTemplate({ recap }: { recap: RecapShare }) { ); } +function clampStickerPosition( + position: { x: number; y: number }, + canvasSize: RecapCanvasSize, + stickerSize: { height: number; width: number }, +) { + if (!canvasSize.height || !canvasSize.width) { + return { x: RECAP_STICKER_PADDING, y: RECAP_STICKER_PADDING }; + } + + return { + x: Math.min( + Math.max(position.x, RECAP_STICKER_PADDING), + Math.max( + RECAP_STICKER_PADDING, + canvasSize.width - stickerSize.width - RECAP_STICKER_PADDING, + ), + ), + y: Math.min( + Math.max(position.y, RECAP_STICKER_PADDING), + Math.max( + RECAP_STICKER_PADDING, + canvasSize.height - stickerSize.height - RECAP_STICKER_PADDING, + ), + ), + }; +} + +function getDefaultMusicStickerPosition( + canvasSize: RecapCanvasSize, + stickerSize: { height: number; width: number }, +) { + return clampStickerPosition( + { + x: canvasSize.width - stickerSize.width - RECAP_STICKER_PADDING, + y: canvasSize.height - stickerSize.height - RECAP_STICKER_PADDING, + }, + canvasSize, + stickerSize, + ); +} + +function getRecapMusicStickerThemeStyle(theme: RecapMusicStickerTheme) { + if (theme === 'lime') { + return { + container: { + backgroundColor: 'rgba(183,230,40,0.94)', + borderColor: 'rgba(255,255,255,0.42)', + }, + iconBubble: { backgroundColor: 'rgba(5,9,22,0.14)' }, + iconColor: '#050916', + metaText: { color: 'rgba(5,9,22,0.62)' }, + primaryText: { color: '#050916' }, + secondaryText: { color: 'rgba(5,9,22,0.68)' }, + }; + } + + if (theme === 'mono') { + return { + container: { + backgroundColor: 'rgba(255,255,255,0.92)', + borderColor: 'rgba(255,255,255,0.66)', + }, + iconBubble: { backgroundColor: 'rgba(5,9,22,0.08)' }, + iconColor: 'rgba(5,9,22,0.72)', + metaText: { color: 'rgba(5,9,22,0.52)' }, + primaryText: { color: '#050916' }, + secondaryText: { color: 'rgba(5,9,22,0.62)' }, + }; + } + + return { + container: { + backgroundColor: 'rgba(5,9,22,0.7)', + borderColor: 'rgba(255,255,255,0.2)', + }, + iconBubble: { backgroundColor: 'rgba(255,255,255,0.1)' }, + iconColor: 'rgba(255,255,255,0.82)', + metaText: { color: 'rgba(255,255,255,0.5)' }, + primaryText: { color: '#FFFFFF' }, + secondaryText: { color: 'rgba(255,255,255,0.66)' }, + }; +} + +function getRecapMusicStickerTemplateStyle( + template: RecapMusicStickerTemplate, +) { + if (template === 'badge') { + return { + borderRadius: 999, + paddingHorizontal: 14, + paddingVertical: 10, + }; + } + + if (template === 'label') { + return { + borderRadius: 16, + paddingHorizontal: 12, + paddingVertical: 10, + }; + } + + return { + borderRadius: 18, + paddingHorizontal: 14, + paddingVertical: 10, + }; +} + +function RecapMusicSticker({ + artist, + isDragging, + settings, + title, +}: { + artist: string; + isDragging: boolean; + settings: RecapMusicStickerSettings; + title: string; +}) { + const themeStyle = getRecapMusicStickerThemeStyle(settings.theme); + + if (settings.template === 'badge') { + return ( + + + + + + + Current music + + + {title} + + + {isDragging ? ( + + ) : null} + + ); + } + + if (settings.template === 'label') { + return ( + + + + Soundlog pick + + + + + {title} + + + {artist} + + + ); + } + + return ( + <> + + + Now playing + + + + + + + + + + {title} + + + {artist} + + + + + ); +} + export function RecapPreviewCard({ + isMusicStickerDraggable = true, + musicSticker = { template: 'player', theme: 'glass', visible: true }, + onMusicStickerDragChange, recap, template = 'lp', }: RecapPreviewCardProps) { + const stickerPan = useRef( + new Animated.ValueXY({ + x: RECAP_STICKER_PADDING, + y: RECAP_STICKER_PADDING, + }), + ).current; + const stickerPositionRef = useRef({ + x: RECAP_STICKER_PADDING, + y: RECAP_STICKER_PADDING, + }); + const didPlaceStickerRef = useRef(false); + const [canvasSize, setCanvasSize] = useState({ + height: 0, + width: 0, + }); + const [isDraggingSticker, setIsDraggingSticker] = useState(false); + const stickerSize = recapMusicStickerSizes[musicSticker.template]; + const stickerThemeStyle = getRecapMusicStickerThemeStyle(musicSticker.theme); + const handleLayout = (event: LayoutChangeEvent) => { + const { height, width } = event.nativeEvent.layout; + + setCanvasSize((current) => + current.height === height && current.width === width + ? current + : { height, width }, + ); + }; + const finishDragging = useCallback(() => { + setIsDraggingSticker(false); + onMusicStickerDragChange?.(false); + stickerPan.stopAnimation((value) => { + stickerPositionRef.current = clampStickerPosition( + value, + canvasSize, + stickerSize, + ); + stickerPan.setValue(stickerPositionRef.current); + }); + }, [canvasSize, onMusicStickerDragChange, stickerPan, stickerSize]); + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => + isMusicStickerDraggable && + (Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2), + onMoveShouldSetPanResponderCapture: (_, gestureState) => + isMusicStickerDraggable && + (Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2), + onPanResponderGrant: () => { + setIsDraggingSticker(true); + onMusicStickerDragChange?.(true); + stickerPan.stopAnimation((value) => { + const nextPosition = clampStickerPosition( + value, + canvasSize, + stickerSize, + ); + stickerPositionRef.current = nextPosition; + stickerPan.setValue(nextPosition); + }); + }, + onPanResponderMove: (_, gestureState) => { + stickerPan.setValue( + clampStickerPosition( + { + x: stickerPositionRef.current.x + gestureState.dx, + y: stickerPositionRef.current.y + gestureState.dy, + }, + canvasSize, + stickerSize, + ), + ); + }, + onPanResponderRelease: finishDragging, + onPanResponderTerminationRequest: () => false, + onPanResponderTerminate: finishDragging, + onShouldBlockNativeResponder: () => true, + onStartShouldSetPanResponder: () => isMusicStickerDraggable, + onStartShouldSetPanResponderCapture: () => isMusicStickerDraggable, + }), + [ + canvasSize, + finishDragging, + isMusicStickerDraggable, + onMusicStickerDragChange, + stickerPan, + stickerSize, + ], + ); + + useEffect(() => { + didPlaceStickerRef.current = false; + }, [recap.id, template]); + + useEffect(() => { + if (!canvasSize.height || !canvasSize.width) { + return; + } + + if (!didPlaceStickerRef.current) { + const defaultPosition = getDefaultMusicStickerPosition( + canvasSize, + stickerSize, + ); + + stickerPositionRef.current = defaultPosition; + stickerPan.setValue(defaultPosition); + didPlaceStickerRef.current = true; + return; + } + + const nextPosition = clampStickerPosition( + stickerPositionRef.current, + canvasSize, + stickerSize, + ); + + stickerPositionRef.current = nextPosition; + stickerPan.setValue(nextPosition); + }, [canvasSize, stickerPan, stickerSize]); + return ( - + {template === 'album' ? : null} {template === 'lp' ? : null} {template === 'film' ? : null} {template === 'map' ? : null} + {musicSticker.visible ? ( + + + + ) : null} ); } + +const styles = StyleSheet.create({ + lpAccentWash: { + height: 180, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + lpTrackPanel: { + backgroundColor: 'rgba(5,9,22,0.88)', + }, + routeSegment: { + backgroundColor: 'rgba(183,230,40,0.88)', + borderRadius: 999, + height: ROUTE_LINE_HEIGHT, + position: 'absolute', + shadowColor: '#B7E628', + shadowOffset: { height: 0, width: 0 }, + shadowOpacity: 0.4, + shadowRadius: 8, + }, + recapMusicSticker: { + borderWidth: 1, + left: 0, + position: 'absolute', + shadowColor: '#000000', + shadowOffset: { height: 8, width: 0 }, + shadowOpacity: 0.28, + shadowRadius: 18, + top: 0, + }, + recapMusicStickerDragging: { + shadowOpacity: 0.4, + shadowRadius: 24, + }, +}); diff --git a/src/components/recap-share/RecapRouteMap.tsx b/src/components/recap-share/RecapRouteMap.tsx new file mode 100644 index 0000000..10b2d3e --- /dev/null +++ b/src/components/recap-share/RecapRouteMap.tsx @@ -0,0 +1,428 @@ +import { Feather } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + View, +} from "react-native"; +import MapView, { Marker, Polyline, type Region } from "react-native-maps"; + +import { AppText } from "@/components/AppText"; +import { googleDarkMapStyle } from "@/components/travel/live-sound-map/SoundMapView"; +import type { + GeoPoint, + RecapShare, + RecapShareMoment, +} from "@/types/domain"; + +function formatMomentDate(value: string) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "기록 시간 없음"; + } + + return new Intl.DateTimeFormat("ko-KR", { + day: "numeric", + hour: "2-digit", + minute: "2-digit", + month: "long", + }).format(date); +} + +function createMomentGroups( + moments: Array, +) { + const groups = new Map< + string, + { + location: GeoPoint; + moments: Array; + } + >(); + + moments.forEach((moment) => { + const key = `${moment.location.lat.toFixed(4)}:${moment.location.lng.toFixed(4)}`; + const existing = groups.get(key); + + if (existing) { + existing.moments.push(moment); + return; + } + + groups.set(key, { + location: moment.location, + moments: [moment], + }); + }); + + return [...groups.values()]; +} + +function MomentPreview({ + moment, + onOpen, +}: { + moment: RecapShareMoment; + onOpen: () => void; +}) { + return ( + + + {moment.imageUrl ? ( + + ) : ( + + + + )} + + + + {formatMomentDate(moment.recordedAt)} + + + {moment.placeName} + + + + 상세 보기 + + + + + + ); +} + +function MomentDetailModal({ + moment, + onClose, +}: { + moment?: RecapShareMoment; + onClose: () => void; +}) { + return ( + + + + + + + + 저장된 리캡 + + + {moment?.placeName ?? "리캡 상세"} + + + + + + + + + + {moment?.imageUrl ? ( + + ) : ( + + + 저장된 사진이 없어요 + + )} + + + + + + + + {moment?.trackTitle ?? "저장된 순간"} + + + {moment?.artistName ?? "음악 없음"} + + + + + + + {moment?.placeName ?? "장소 없음"} + + + + + + {moment ? formatMomentDate(moment.recordedAt) : "기록 시간 없음"} + + + + + + + + ); +} + +function toCoordinate(location: GeoPoint) { + return { + latitude: location.lat, + longitude: location.lng, + }; +} + +function createInitialRegion(location: GeoPoint): Region { + return { + latitude: location.lat, + latitudeDelta: 0.018, + longitude: location.lng, + longitudeDelta: 0.018, + }; +} + +export function RecapRouteMap({ recap }: { recap: RecapShare }) { + const mapRef = useRef(null); + const [selectedMoments, setSelectedMoments] = useState([]); + const [detailMoment, setDetailMoment] = useState(); + const routeLocations = useMemo( + () => + recap.routePoints?.length + ? recap.routePoints.map(({ lat, lng }) => ({ lat, lng })) + : (recap.moments ?? []) + .map((moment) => moment.location) + .filter((location): location is GeoPoint => Boolean(location)), + [recap.moments, recap.routePoints], + ); + const locatedMoments = useMemo( + () => + (recap.moments ?? []).filter( + (moment): moment is typeof moment & { location: GeoPoint } => + Boolean(moment.location), + ), + [recap.moments], + ); + const mapCoordinates = useMemo( + () => routeLocations.map(toCoordinate), + [routeLocations], + ); + const momentGroups = useMemo( + () => createMomentGroups(locatedMoments), + [locatedMoments], + ); + const hasRecordedRoute = (recap.routePoints?.length ?? 0) > 1; + const hasRouteLine = mapCoordinates.length > 1; + const mapTitle = hasRecordedRoute + ? "내 여행 로드맵" + : hasRouteLine + ? "촬영 위치 흐름" + : "기록 위치"; + const mapDescription = hasRecordedRoute + ? `여행모드에서 저장한 GPS 경로 ${recap.routePoints?.length ?? 0}개를 연결했어요.` + : hasRouteLine + ? "저장된 촬영 위치를 시간순으로 연결했어요." + : routeLocations.length === 1 + ? "이 로그를 남긴 위치를 지도에서 확인해요." + : "이 로그에는 저장된 위치가 없어요."; + + useEffect( + function fitRecordedRoute() { + if (mapCoordinates.length === 0) { + return; + } + + const frame = requestAnimationFrame(() => { + if (mapCoordinates.length === 1) { + mapRef.current?.animateToRegion( + createInitialRegion(routeLocations[0]), + 260, + ); + return; + } + + mapRef.current?.fitToCoordinates(mapCoordinates, { + animated: true, + edgePadding: { bottom: 44, left: 44, right: 44, top: 44 }, + }); + }); + + return () => cancelAnimationFrame(frame); + }, + [mapCoordinates, routeLocations], + ); + + useEffect( + function clearSelectionWhenLogChanges() { + setSelectedMoments([]); + setDetailMoment(undefined); + }, + [recap.id], + ); + + return ( + + + + + GPS 여행 경로 + + + {mapTitle} + + + {mapDescription} + + + + + + + + {routeLocations.length > 0 ? ( + + + {hasRouteLine ? ( + + ) : null} + + {momentGroups.map((group, index) => ( + moment.id).join(":")} + onPress={() => setSelectedMoments(group.moments)} + > + + + {group.moments.length > 1 ? group.moments.length : index + 1} + + + + ))} + + {locatedMoments.length === 0 && routeLocations.length > 0 ? ( + <> + + {routeLocations.length > 1 ? ( + + ) : null} + + ) : null} + + + {selectedMoments.length > 0 ? ( + + + + 이 위치의 리캡 {selectedMoments.length}개 + + setSelectedMoments([])} + > + + + + + {selectedMoments.map((moment) => ( + setDetailMoment(moment)} + /> + ))} + + + ) : null} + + ) : ( + + + + 다음 기록부터 저장된 GPS 위치로 여행 경로를 만들어드릴게요. + + + )} + + setDetailMoment(undefined)} + /> + + ); +} diff --git a/src/components/recap-share/RecapShareScreen.tsx b/src/components/recap-share/RecapShareScreen.tsx index 19bc35c..285e8fc 100644 --- a/src/components/recap-share/RecapShareScreen.tsx +++ b/src/components/recap-share/RecapShareScreen.tsx @@ -1,57 +1,89 @@ -import { useMemo, useRef, useState } from 'react'; -import { ScrollView, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useQueryClient } from "@tanstack/react-query"; +import { router } from "expo-router"; -import { useRecapShareQuery } from '@/api/recapQueries'; -import { AppText } from '@/components/AppText'; +import { ApiError } from "@/api/client"; +import { recapApi } from "@/api/recapApi"; +import { recapQueryKeys, useRecapShareQuery } from "@/api/recapQueries"; +import { AppText } from "@/components/AppText"; +import { IconButton } from "@/components/IconButton"; +import { PageHeader } from "@/components/PageHeader"; import { RecapCaptureFrame, - RecapCaptureFrameHandle, -} from '@/components/recap-share/RecapCaptureFrame'; -import { RecapMusicSummary } from '@/components/recap-share/RecapMusicSummary'; -import { RecapPreviewCard } from '@/components/recap-share/RecapPreviewCard'; + type RecapCaptureFrameHandle, +} from "@/components/recap-share/RecapCaptureFrame"; +import { RecapMusicSummary } from "@/components/recap-share/RecapMusicSummary"; +import { RecapRouteMap } from "@/components/recap-share/RecapRouteMap"; import { RecapShareEmptyState, RecapShareErrorState, RecapShareLoadingState, -} from '@/components/recap-share/RecapShareState'; -import { RecapTemplateSelector } from '@/components/recap-share/RecapTemplateSelector'; -import { ShareActionList } from '@/components/recap-share/ShareActionList'; -import { Screen } from '@/components/Screen'; -import { getTabBarHeight } from '@/constants/layout'; -import { useRecapShareActions } from '@/hooks/useRecapShareActions'; -import { useMomentLogStore } from '@/store/momentLogStore'; -import { RecapShare, RecapTemplateId } from '@/types/domain'; -import { formatRecapRecordedAt } from '@/utils/dateFormat'; +} from "@/components/recap-share/RecapShareState"; +import { + RecapSoundLogCard, + RecapSoundLogList, +} from "@/components/recap-share/RecapSoundLogList"; +import { RecapTravelSummaryCard } from "@/components/recap-share/RecapTravelSummaryCard"; +import { ShareActionList } from "@/components/recap-share/ShareActionList"; +import { Screen } from "@/components/Screen"; +import { SectionTitle } from "@/components/SectionTitle"; +import { getTabBarHeight } from "@/constants/layout"; +import { useRecapShareActions } from "@/hooks/useRecapShareActions"; +import { useMomentLogStore } from "@/store/momentLogStore"; +import { useTravelSessionStore } from "@/store/travelSessionStore"; +import type { + RecapItem, + RecapShare, + RecapShareMoment, + RecapVisibility, +} from "@/types/domain"; +import { formatRecapRecordedAt } from "@/utils/dateFormat"; import { createMomentLogGroups, extractSessionIdFromRecapId, momentLogGroupToRecapShare, momentLogToRecapShare, -} from '@/utils/recapMappers'; +} from "@/utils/recapMappers"; +import { getRecapSoundLogs } from "@/utils/recapTravelSummary"; type RecapShareScreenProps = { recapId?: string; }; +function isTravelLogRecap(recap?: RecapShare | null) { + return Boolean(recap?.sessionId); +} + function createRecapTitle(recap?: RecapShare | null) { if (!recap) { - return 'Recap'; + return "리캡"; } - return `${recap.placeName} 사운드`; + return isTravelLogRecap(recap) + ? `${recap.placeName} 여행 로그` + : `${recap.placeName} 리캡`; } export function RecapShareScreen({ recapId }: RecapShareScreenProps) { const insets = useSafeAreaInsets(); - const [selectedTemplate, setSelectedTemplate] = useState('album'); - const captureAspectRatio = selectedTemplate === 'album' ? 1 : 3 / 4; + const queryClient = useQueryClient(); + const captureFrameRef = useRef(null); + const [isUpdatingVisibility, setIsUpdatingVisibility] = useState(false); + const [isUpdatingThumbnail, setIsUpdatingThumbnail] = useState(false); + const [visibility, setVisibility] = useState("private"); + const [thumbnailMessage, setThumbnailMessage] = useState(); + const [visibilityMessage, setVisibilityMessage] = useState(); const momentLogs = useMomentLogStore((state) => state.logs); + const session = useTravelSessionStore((state) => state.session); const sessionId = extractSessionIdFromRecapId(recapId); const localMomentGroup = useMemo( () => sessionId - ? createMomentLogGroups(momentLogs).find((group) => group.sessionId === sessionId) + ? createMomentLogGroups(momentLogs).find( + (group) => group.sessionId === sessionId, + ) : undefined, [momentLogs, sessionId], ); @@ -59,7 +91,16 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { ? undefined : momentLogs.find((item) => item.id === recapId); const localRecap = localMomentGroup - ? momentLogGroupToRecapShare(localMomentGroup) + ? momentLogGroupToRecapShare( + localMomentGroup, + localMomentGroup.sessionId === session.id + ? { + endedAt: session.endedAt, + routePoints: session.routePoints, + startedAt: session.startedAt, + } + : {}, + ) : localMomentLog ? momentLogToRecapShare(localMomentLog) : undefined; @@ -71,33 +112,207 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { refetch, } = useRecapShareQuery(recapId, { enabled: Boolean(recapId) && !localRecap }); const recap = localRecap ?? remoteRecap; - const captureRef = useRef(null); - const { activeAction, message, save, share } = useRecapShareActions({ - capture: () => captureRef.current?.capture() ?? Promise.resolve(undefined), - recapId: isLocalRecap ? undefined : recapId, + const isTravelLog = isTravelLogRecap(recap); + const soundLogs = recap ? getRecapSoundLogs(recap) : []; + const shareMoment = soundLogs[soundLogs.length - 1]; + const itemLabel = isTravelLog ? "로그" : "리캡"; + const canManageVisibility = Boolean(isLocalRecap || recap?.isMine); + const canSelectThumbnail = Boolean( + !isLocalRecap && isTravelLog && recap?.isMine, + ); + const shareActions = useRecapShareActions({ + capture: () => + captureFrameRef.current?.capture() ?? Promise.resolve(undefined), + recapId: isLocalRecap ? undefined : recap?.id, }); + const handleChangeVisibility = async (nextVisibility: RecapVisibility) => { + if (!recap || !canManageVisibility || isUpdatingVisibility) { + return; + } + + if (isLocalRecap) { + setVisibilityMessage( + `서버에 저장된 ${itemLabel}만 공개 범위를 바꿀 수 있어요.`, + ); + return; + } + + setIsUpdatingVisibility(true); + setVisibilityMessage(undefined); + + try { + const updatedRecap = await recapApi.updateRecapVisibility( + recap.id, + nextVisibility, + ); + setVisibility(nextVisibility); + if (updatedRecap) { + queryClient.setQueriesData( + { queryKey: recapQueryKeys.lists }, + (currentRecaps) => + currentRecaps?.map((currentRecap) => + currentRecap.id === updatedRecap.id + ? { + ...currentRecap, + ...updatedRecap, + visibility: nextVisibility, + } + : currentRecap, + ), + ); + } + setVisibilityMessage( + nextVisibility === "public" + ? "전체공개로 바꿨어요. 현재 위치 300m 이내 지도에 리캡 핀이 남아요." + : "나만보기로 바꿨어요. 다른 사람의 지도에는 보이지 않아요.", + ); + void refetch(); + void queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); + } catch (error) { + setVisibilityMessage( + error instanceof ApiError + ? error.message + : "공개 범위를 바꾸지 못했어요. 잠시 후 다시 시도해주세요.", + ); + } finally { + setIsUpdatingVisibility(false); + } + }; + + const handleSelectThumbnail = async (moment: RecapShareMoment) => { + if ( + !recap || + !canSelectThumbnail || + isUpdatingThumbnail || + recap.thumbnailMomentId === moment.id + ) { + return; + } + + setIsUpdatingThumbnail(true); + setThumbnailMessage(undefined); + + try { + const updatedRecap = await recapApi.updateRecapThumbnail( + recap.id, + moment.id, + ); + + queryClient.setQueryData( + recapQueryKeys.share(recap.id), + (currentRecap) => + currentRecap + ? { + ...currentRecap, + backgroundImageUrl: moment.imageUrl, + thumbnailMomentId: moment.id, + } + : currentRecap, + ); + + if (updatedRecap) { + queryClient.setQueriesData( + { queryKey: recapQueryKeys.lists }, + (currentRecaps) => + currentRecaps?.map((currentRecap) => + currentRecap.id === updatedRecap.id + ? { ...currentRecap, ...updatedRecap } + : currentRecap, + ), + ); + } + + setThumbnailMessage("이 리캡을 로그의 대표 썸네일로 지정했어요."); + await refetch(); + void queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); + } catch (error) { + setThumbnailMessage( + error instanceof ApiError + ? error.message + : "로그 썸네일을 바꾸지 못했어요. 잠시 후 다시 시도해주세요.", + ); + } finally { + setIsUpdatingThumbnail(false); + } + }; + + useEffect(() => { + if (recap?.visibility) { + setVisibility(recap.visibility); + } + }, [recap?.visibility]); return ( - - {createRecapTitle(recap)} - - + router.back()} /> + } + title={createRecapTitle(recap)} + /> + {recap - ? '사운드트랙 앨범 결과물을 저장하거나 공유해요.' - : '여행의 사운드트랙 앨범을 불러오고 있어요.'} + ? isTravelLog + ? "여행모드에서 남긴 기록들이 하나의 로그로 묶였어요." + : "여행모드 밖에서 만든 독립 리캡이에요." + : "리캡과 로그를 불러오고 있어요."} - + {recap ? ( + + + {formatRecapRecordedAt(recap.recordedAt)} + + + {canManageVisibility ? ( + + {(["private", "public"] as RecapVisibility[]).map((option) => { + const selected = visibility === option; + + return ( + void handleChangeVisibility(option)} + > + + {option === "public" ? "전체공개" : "나만보기"} + + + ); + })} + + ) : null} + + {visibilityMessage ? ( + + {visibilityMessage} + + ) : null} + + ) : null} + + {isLoading && !localRecap ? ( ) : isError ? ( @@ -106,72 +321,96 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { ) : ( <> - - - - - - {isLocalRecap ? ( - - - 서버 동기화 전 로컬 Recap이에요. 이미지 저장과 공유는 가능하고, Moment 동기화 후 서버 Recap으로 저장할 수 있어요. + + + 서버 동기화 전 로컬 {itemLabel}이에요. 리캡 동기화 후 서버에 + 저장할 수 있어요. ) : null} - - + void handleSelectThumbnail(moment)} + recap={recap} /> + {thumbnailMessage ? ( + + {thumbnailMessage} + + ) : null} - - {formatRecapRecordedAt(recap.recordedAt)} - + {isTravelLog ? ( + <> + + + - + + + + + ) : null} + + - - { - if (action === 'save') { - save(); - } else { - share(); - } - }} - /> - + + + + {shareMoment ? ( + + + + + + ) : null} + - {message ? ( - - - {message.text} - + + { + if (action === "save") { + void shareActions.save(); + return; + } + + void shareActions.share(); + }} + /> + {shareActions.message ? ( + + + {shareActions.message.text} + + + ) : null} - ) : null} + )} diff --git a/src/components/recap-share/RecapShareState.tsx b/src/components/recap-share/RecapShareState.tsx index f46497f..32ef945 100644 --- a/src/components/recap-share/RecapShareState.tsx +++ b/src/components/recap-share/RecapShareState.tsx @@ -1,12 +1,13 @@ import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { SettingsRow } from '@/components/SettingsRow'; export function RecapShareLoadingState() { return ( - - + + {[0, 1].map((item) => ( @@ -22,16 +23,19 @@ type RecapShareErrorStateProps = { export function RecapShareErrorState({ onRetry }: RecapShareErrorStateProps) { return ( - - - 공유할 음악 기록을 불러오지 못했어요 - - - 네트워크 상태를 확인한 뒤 다시 시도해주세요. - + + {onRetry ? ( - - 다시 시도 + + 다시 시도 ) : null} @@ -40,13 +44,12 @@ export function RecapShareErrorState({ onRetry }: RecapShareErrorStateProps) { export function RecapShareEmptyState() { return ( - - - 공유할 음악 기록을 찾을 수 없어요 - - - 여행 중 저장한 음악 로그가 생기면 이곳에서 공유할 수 있어요. - + + ); } diff --git a/src/components/recap-share/RecapSoundLogList.tsx b/src/components/recap-share/RecapSoundLogList.tsx new file mode 100644 index 0000000..4c04fc1 --- /dev/null +++ b/src/components/recap-share/RecapSoundLogList.tsx @@ -0,0 +1,324 @@ +import { Feather } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useEffect, useState } from "react"; +import { + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + ScrollView, + StyleSheet, + View, +} from "react-native"; + +import { AppText } from "@/components/AppText"; +import { SectionTitle } from "@/components/SectionTitle"; +import type { RecapShare, RecapShareMoment } from "@/types/domain"; +import { getRecapSoundLogs } from "@/utils/recapTravelSummary"; + +function formatLogTime(value: string) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "시간 없음"; + } + + return new Intl.DateTimeFormat("ko-KR", { + hour: "2-digit", + hour12: false, + minute: "2-digit", + }).format(date); +} + +function formatLogDate(value: string) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "날짜 없음"; + } + + return new Intl.DateTimeFormat("ko-KR", { + day: "2-digit", + month: "long", + }).format(date); +} + +function SoundLogBackground({ imageUrl }: { imageUrl?: string }) { + const [failedImageUrl, setFailedImageUrl] = useState(); + const visibleImageUrl = + imageUrl && failedImageUrl !== imageUrl ? imageUrl : undefined; + + return ( + <> + {visibleImageUrl ? ( + setFailedImageUrl(visibleImageUrl)} + source={{ uri: visibleImageUrl }} + style={StyleSheet.absoluteFill} + transition={260} + /> + ) : ( + + )} + + + ); +} + +function DetailRow({ + icon, + label, + value, +}: { + icon: keyof typeof Feather.glyphMap; + label: string; + value: string; +}) { + return ( + + + + + + + {label} + + + {value} + + + + ); +} + +export function RecapSoundLogCard({ + fill = false, + index, + moment, + total, + width, +}: { + fill?: boolean; + index: number; + moment: RecapShareMoment; + total: number; + width?: number; +}) { + const orderLabel = String(index + 1).padStart(2, "0"); + const totalLabel = String(total).padStart(2, "0"); + + return ( + + + + + + + + + {orderLabel} / {totalLabel} + + + + + {formatLogTime(moment.recordedAt)} + + + + + + + SOUNDLOG + + + {moment.trackTitle} + + + {moment.artistName} + + + + + + + + + + + ); +} + +export function RecapSoundLogList({ + canSelectThumbnail = false, + isTravelLog, + isUpdatingThumbnail = false, + onSelectThumbnail, + recap, +}: { + canSelectThumbnail?: boolean; + isTravelLog: boolean; + isUpdatingThumbnail?: boolean; + onSelectThumbnail?: (moment: RecapShareMoment) => void; + recap: RecapShare; +}) { + const soundLogs = getRecapSoundLogs(recap); + const [activeIndex, setActiveIndex] = useState(0); + const [viewportWidth, setViewportWidth] = useState(0); + const progress = + soundLogs.length > 0 ? (activeIndex + 1) / soundLogs.length : 0; + const activeMoment = soundLogs[activeIndex]; + const selectedThumbnailId = recap.thumbnailMomentId ?? soundLogs[0]?.id; + const isActiveMomentSelected = activeMoment?.id === selectedThumbnailId; + + useEffect(() => { + setActiveIndex(0); + }, [recap.id, soundLogs.length]); + + const handleLayout = (event: LayoutChangeEvent) => { + const nextWidth = event.nativeEvent.layout.width; + + setViewportWidth((current) => + current === nextWidth ? current : nextWidth, + ); + }; + + const handleScrollEnd = (event: NativeSyntheticEvent) => { + const pageWidth = event.nativeEvent.layoutMeasurement.width; + + if (!pageWidth) { + return; + } + + const nextIndex = Math.round(event.nativeEvent.contentOffset.x / pageWidth); + setActiveIndex(Math.min(Math.max(nextIndex, 0), soundLogs.length - 1)); + }; + + return ( + + + + {activeIndex + 1} / {soundLogs.length} + + } + title={isTravelLog ? "여행 사운드로그" : "독립 리캡"} + /> + + {soundLogs.length}개 리캡 + + + + {viewportWidth > 0 ? ( + + {soundLogs.map((moment, index) => ( + + ))} + + ) : ( + + )} + + + + + + {canSelectThumbnail && isTravelLog && activeMoment ? ( + onSelectThumbnail?.(activeMoment)} + > + + + {isUpdatingThumbnail + ? "변경 중" + : isActiveMomentSelected + ? "대표 썸네일" + : "로그 썸네일로 지정"} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + fill: { + flex: 1, + }, + soundLogImage: { + aspectRatio: 4 / 5, + }, +}); diff --git a/src/components/recap-share/RecapTemplateSelector.tsx b/src/components/recap-share/RecapTemplateSelector.tsx index a15b95d..8434ce3 100644 --- a/src/components/recap-share/RecapTemplateSelector.tsx +++ b/src/components/recap-share/RecapTemplateSelector.tsx @@ -1,7 +1,7 @@ import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; -import { RecapTemplateId } from '@/types/domain'; +import type { RecapTemplateId } from '@/types/domain'; const templateOptions: Array<{ id: RecapTemplateId; @@ -14,32 +14,34 @@ const templateOptions: Array<{ ]; type RecapTemplateSelectorProps = { - selectedTemplate: RecapTemplateId; onSelect: (template: RecapTemplateId) => void; + selectedTemplate: RecapTemplateId; }; export function RecapTemplateSelector({ - selectedTemplate, onSelect, + selectedTemplate, }: RecapTemplateSelectorProps) { return ( - + {templateOptions.map((option) => { const isSelected = selectedTemplate === option.id; return ( onSelect(option.id)} > {option.label} diff --git a/src/components/recap-share/RecapTravelSummaryCard.tsx b/src/components/recap-share/RecapTravelSummaryCard.tsx new file mode 100644 index 0000000..68ad391 --- /dev/null +++ b/src/components/recap-share/RecapTravelSummaryCard.tsx @@ -0,0 +1,81 @@ +import { View } from 'react-native'; + +import { AppText } from '@/components/AppText'; +import { SectionTitle } from '@/components/SectionTitle'; +import { SettingsRow } from '@/components/SettingsRow'; +import type { RecapShare } from '@/types/domain'; +import { getRecapTravelSummary } from '@/utils/recapTravelSummary'; + +function formatTravelDuration(minutes: number) { + if (minutes < 1) { + return '1분 미만'; + } + + if (minutes < 60) { + return `${minutes}분`; + } + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + return remainingMinutes ? `${hours}시간 ${remainingMinutes}분` : `${hours}시간`; +} + +function formatTravelDistance(distanceMeters: number) { + if (distanceMeters < 1) { + return '이동 기록 없음'; + } + + if (distanceMeters < 1000) { + return `${distanceMeters}m`; + } + + const distanceKilometers = distanceMeters / 1000; + + return `${distanceKilometers >= 10 ? distanceKilometers.toFixed(0) : distanceKilometers.toFixed(1)}km`; +} + +function createPlaceRouteLabel(placeNames: string[]) { + const routePlaces = placeNames.filter(Boolean); + + if (routePlaces.length === 0) { + return '장소 기록 없음'; + } + + if (routePlaces.length <= 3) { + return routePlaces.join(' -> '); + } + + return `${routePlaces.slice(0, 3).join(' -> ')} 외 ${routePlaces.length - 3}곳`; +} + +export function RecapTravelSummaryCard({ recap }: { recap: RecapShare }) { + const summary = getRecapTravelSummary(recap); + const distanceLabel = formatTravelDistance(summary.distanceMeters); + const durationLabel = formatTravelDuration(summary.durationMinutes); + const routeLabel = createPlaceRouteLabel(summary.placeNames); + const routePointLabel = summary.routePointCount + ? `${summary.routePointCount}개` + : `${summary.recordedLocationCount}개`; + + return ( + + + + {summary.startPlaceName}에서 {summary.endPlaceName}까지 + + + + + + + ); +} diff --git a/src/components/recap-share/RecordDisc.tsx b/src/components/recap-share/RecordDisc.tsx index 3cfe9c6..161c494 100644 --- a/src/components/recap-share/RecordDisc.tsx +++ b/src/components/recap-share/RecordDisc.tsx @@ -7,7 +7,7 @@ type RecordDiscProps = { export function RecordDisc({ imageUrl }: RecordDiscProps) { return ( - + {imageUrl ? ( - ) : null} - + ) : ( + <> + + + + + )} + diff --git a/src/components/recap/RecapEmptyState.tsx b/src/components/recap/RecapEmptyState.tsx index d2ed1ec..1d15a35 100644 --- a/src/components/recap/RecapEmptyState.tsx +++ b/src/components/recap/RecapEmptyState.tsx @@ -1,43 +1,11 @@ -import { Feather } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Pressable, View } from 'react-native'; - -import { AppText } from '@/components/AppText'; +import { SettingsRow } from '@/components/SettingsRow'; export function RecapEmptyState() { return ( - - - - - - - 아직 저장한 여행 앨범이 없어요 - - - 카메라 버튼으로 장소와 음악을 함께 저장하면, 여행이 끝난 뒤 Recap - 앨범으로 다시 볼 수 있어요. - - router.push('/camera')} - > - - 첫 순간 저장하기 - - - - + ); } diff --git a/src/components/recap/RecapListCard.tsx b/src/components/recap/RecapListCard.tsx index 065683d..abc3eb1 100644 --- a/src/components/recap/RecapListCard.tsx +++ b/src/components/recap/RecapListCard.tsx @@ -1,11 +1,11 @@ -import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { Feather } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { Pressable, StyleSheet, View } from "react-native"; -import { AppText } from '@/components/AppText'; -import { RecapItem } from '@/types/domain'; -import { formatRecapRecordedAt } from '@/utils/dateFormat'; +import { AppText } from "@/components/AppText"; +import { RecapItem } from "@/types/domain"; +import { formatRecapRecordedAt } from "@/utils/dateFormat"; type RecapListCardProps = { imageUrl?: string; @@ -16,12 +16,12 @@ type RecapListCardProps = { export function RecapListCard({ imageUrl, item, onPress }: RecapListCardProps) { const momentCountLabel = item.momentCount && item.momentCount > 1 - ? `저장된 순간 ${item.momentCount}개` + ? `기록 ${item.momentCount}개` : undefined; return ( ) : ( )} - {momentCountLabel ?? 'Music Recap'} + {momentCountLabel ?? "독립 리캡"} diff --git a/src/components/recap/RecapListScreen.tsx b/src/components/recap/RecapListScreen.tsx index 54a8b91..f5467b2 100644 --- a/src/components/recap/RecapListScreen.tsx +++ b/src/components/recap/RecapListScreen.tsx @@ -1,387 +1,727 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { router } from 'expo-router'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useMemo, useState } from 'react'; -import { Pressable, ScrollView, View } from 'react-native'; - -import { recapApi } from '@/api/recapApi'; -import { recapQueryKeys, useRecapListQuery } from '@/api/recapQueries'; -import { AppText } from '@/components/AppText'; -import { RecapEmptyState } from '@/components/recap/RecapEmptyState'; -import { RecapListCard } from '@/components/recap/RecapListCard'; -import { Screen } from '@/components/Screen'; -import { useMomentLogStore } from '@/store/momentLogStore'; -import { useTravelSessionStore } from '@/store/travelSessionStore'; -import type { MomentLog } from '@/types/domain'; +import { Feather } from "@expo/vector-icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { router, useLocalSearchParams } from "expo-router"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { + Animated, + GestureResponderEvent, + NativeScrollEvent, + NativeSyntheticEvent, + Pressable, + ScrollView, + StyleSheet, + useWindowDimensions, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { ApiError } from "@/api/client"; +import { recapApi } from "@/api/recapApi"; +import { recapQueryKeys, useRecapListQuery } from "@/api/recapQueries"; +import { AppText } from "@/components/AppText"; +import { PageHeader } from "@/components/PageHeader"; +import { RecapEmptyState } from "@/components/recap/RecapEmptyState"; +import { Screen } from "@/components/Screen"; +import { getTabBarHeight } from "@/constants/layout"; +import { useMomentLogStore } from "@/store/momentLogStore"; +import type { MomentLog, RecapItem, RecapVisibility } from "@/types/domain"; import { createMomentLogGroups, - createSessionRecapId, - extractSessionIdFromRecapId, momentLogGroupToRecapItem, -} from '@/utils/recapMappers'; +} from "@/utils/recapMappers"; +import { flushPendingMomentActions } from "@/utils/momentLogSync"; + +type LogFeedTabId = "others" | "all"; -type RecapListEntry = { +type LogGridEntry = { imageUrl?: string; - item: ReturnType; + item: RecapItem; + owner: "mine" | "other"; shareId: string; + source: "local" | "server"; + syncStatus?: MomentLog["syncStatus"]; }; -type CurrentTripRecapCardProps = { - isCreating: boolean; - message?: string; - momentCount: number; - onPress: () => void; - representativeLog?: MomentLog; - status: 'empty' | 'failed' | 'local' | 'ready' | 'synced'; - trackCount: number; - unsyncedMomentCount: number; -}; +const logFeedTabs: Array<{ + label: string; + value: LogFeedTabId; +}> = [ + { + label: "다른사람 보기", + value: "others", + }, + { + label: "모든 사람 보기", + value: "all", + }, +]; -function getUniqueTrackCount(logs: MomentLog[]) { - return new Set(logs.map((log) => log.track?.id).filter(Boolean)).size; +function sortEntriesByCreatedAt(entries: LogGridEntry[]) { + return [...entries].sort( + (first, second) => + new Date(second.item.createdAt).getTime() - + new Date(first.item.createdAt).getTime(), + ); } -function getNewestMomentLog(logs: MomentLog[]) { - return [...logs].sort( - (first, second) => - new Date(second.createdAt).getTime() - new Date(first.createdAt).getTime(), - )[0]; +function dedupeEntries(entries: LogGridEntry[]) { + const seenKeys = new Set(); + + return entries.filter((entry) => { + const key = `${entry.source}:${entry.shareId}`; + + if (seenKeys.has(key)) { + return false; + } + + seenKeys.add(key); + return true; + }); +} + +function getEntryImageUrl(entry: LogGridEntry) { + return entry.imageUrl ?? entry.item.representativeTrack.albumImageUrl; +} + +function getVisibilityLabel(visibility?: RecapVisibility) { + return visibility === "public" ? "공개" : "비공개"; +} + +function getMomentCountLabel(item: RecapItem) { + if (item.momentCount && item.momentCount > 1) { + return `${item.momentCount}개`; + } + + return "1개"; +} + +function getGroupSyncStatus(logs: MomentLog[]): MomentLog["syncStatus"] { + if (logs.some((log) => log.syncStatus === "failed")) { + return "failed"; + } + + if (logs.some((log) => log.syncStatus === "pending")) { + return "pending"; + } + + if (logs.some((log) => log.syncStatus === "local")) { + return "local"; + } + + return "synced"; +} + +function getLocalSyncLabel(syncStatus?: MomentLog["syncStatus"]) { + if (syncStatus === "failed") { + return "재시도"; + } + + if (syncStatus === "pending") { + return "동기화 중"; + } + + return "기기 저장"; } -function CurrentTripRecapCard({ - isCreating, - message, - momentCount, +type LogGridCardProps = { + entry: LogGridEntry; + itemSize: number; + isMine: boolean; + isUpdating: boolean; + onChangeVisibility: ( + entry: LogGridEntry, + visibility: RecapVisibility, + event: GestureResponderEvent, + ) => void; + onPress: () => void; +}; + +function LogGridCard({ + entry, + itemSize, + isMine, + isUpdating, + onChangeVisibility, onPress, - representativeLog, - status, - trackCount, - unsyncedMomentCount, -}: CurrentTripRecapCardProps) { - const statusLabel = isCreating - ? '생성 중...' - : status === 'failed' - ? '실패 · 재시도 가능' - : status === 'synced' - ? '완료' - : status === 'local' - ? '로컬 미리보기' - : status === 'ready' - ? '생성 가능' - : '생성 전'; - const buttonLabel = isCreating - ? '생성 중...' - : status === 'failed' - ? '다시 시도' - : status === 'synced' - ? 'Recap 열기' - : status === 'local' - ? unsyncedMomentCount > 0 - ? '로컬 Recap 열기' - : '서버 Recap으로 저장' - : status === 'empty' - ? '첫 Moment 저장' - : 'Recap 만들기'; - const representativeTrackLabel = representativeLog?.track - ? `${representativeLog.track.title} - ${representativeLog.track.artist}` - : '대표 곡 없음'; +}: LogGridCardProps) { + const imageUrl = getEntryImageUrl(entry); + const [failedImageUrl, setFailedImageUrl] = useState(); + const visibleImageUrl = + imageUrl && failedImageUrl !== imageUrl ? imageUrl : undefined; + const visibility = entry.item.visibility ?? "private"; + const nextVisibility: RecapVisibility = + visibility === "public" ? "private" : "public"; + const canChangeVisibility = entry.source === "server"; return ( - - - - - 이번 여행 + + + + + {visibleImageUrl ? ( + setFailedImageUrl(visibleImageUrl)} + source={{ uri: visibleImageUrl }} + style={StyleSheet.absoluteFill} + transition={180} + /> + ) : ( + + )} + + {!visibleImageUrl ? ( + + + + + + {entry.item.title} + + + ) : null} + + + {getMomentCountLabel(entry.item)} - - {statusLabel} + + + + + + {entry.item.placeName} + + + + + + + {isMine ? ( + onChangeVisibility(entry, nextVisibility, event)} + style={{ opacity: canChangeVisibility ? 1 : 0.62 }} + > + + {isUpdating + ? "변경중" + : canChangeVisibility + ? getVisibilityLabel(visibility) + : getLocalSyncLabel(entry.syncStatus)} - - {momentCount}개 Moment · {trackCount}곡 + + ) : null} + + ); +} + +type LogFeedPageProps = { + actionMessage?: string; + contentBottomPadding: number; + emptyMessage: string; + entries: LogGridEntry[]; + hasAnyLog: boolean; + isActive: boolean; + isError: boolean; + isLoading: boolean; + itemSize: number; + onChangeVisibility: ( + entry: LogGridEntry, + visibility: RecapVisibility, + event: GestureResponderEvent, + ) => void; + onOpenEntry: (entry: LogGridEntry) => void; + tabId: LogFeedTabId; + updatingRecapId?: string; + width: number; +}; + +function LogFeedPage({ + actionMessage, + contentBottomPadding, + emptyMessage, + entries, + hasAnyLog, + isActive, + isError, + isLoading, + itemSize, + onChangeVisibility, + onOpenEntry, + tabId, + updatingRecapId, + width, +}: LogFeedPageProps) { + return ( + + {actionMessage ? ( + + + {actionMessage} - {status === 'local' && !message ? ( - - {unsyncedMomentCount > 0 - ? `서버에 올라가지 않은 Moment ${unsyncedMomentCount}개가 있어 로컬 결과물로 먼저 볼 수 있어요.` - : '모든 Moment가 동기화됐어요. 서버 Recap으로 저장할 수 있어요.'} - - ) : null} - - Recap + ) : null} + + {isLoading ? ( + + + 로그 데이터를 불러오는 중이에요. + - + ) : null} - - - {representativeLog?.placeName ?? '아직 대표 장소가 없어요'} - - - {representativeTrackLabel} - - + {isError && !hasAnyLog ? ( + + + 로그 데이터를 불러오지 못했어요. 잠시 후 다시 확인해주세요. + + + ) : null} - {message ? ( - - {message} + {!isLoading && !hasAnyLog ? ( + + ) : null} - - - {buttonLabel} - - - + {entries.length > 0 ? ( + + {entries.map((entry) => ( + onOpenEntry(entry)} + /> + ))} + + ) : hasAnyLog && !isLoading ? ( + + + {emptyMessage} + + + ) : null} + ); } export function RecapListScreen() { + const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); + const params = useLocalSearchParams<{ view?: string | string[] }>(); + const initialView = Array.isArray(params.view) ? params.view[0] : params.view; const queryClient = useQueryClient(); const momentLogs = useMomentLogStore((state) => state.logs); - const { session, setSessionRecapId } = useTravelSessionStore(); - const [currentRecapStatus, setCurrentRecapStatus] = - useState<'empty' | 'failed' | 'local' | 'ready' | 'synced'>('empty'); - const [currentRecapMessage, setCurrentRecapMessage] = useState(); - const [isCreatingCurrentRecap, setIsCreatingCurrentRecap] = useState(false); - const { - data: serverRecapItems = [], - isError, - isLoading, - } = useRecapListQuery(); - const currentTripLogs = useMemo( + const [selectedTab, setSelectedTab] = useState( + initialView === "all" ? "all" : "others", + ); + const [updatingRecapId, setUpdatingRecapId] = useState(); + const [actionMessage, setActionMessage] = useState(); + const pagerRef = useRef(null); + const initialTabIndex = initialView === "all" ? 1 : 0; + const scrollX = useRef(new Animated.Value(initialTabIndex * width)).current; + const mineRecapsQuery = useRecapListQuery("mine"); + const otherRecapsQuery = useRecapListQuery("others"); + const contentBottomPadding = getTabBarHeight(insets.bottom) + 56; + const gridItemSize = Math.floor((width - 1) / 2); + const isLoading = mineRecapsQuery.isLoading || otherRecapsQuery.isLoading; + const isError = mineRecapsQuery.isError || otherRecapsQuery.isError; + + const serverMineEntries: LogGridEntry[] = useMemo( + () => + (mineRecapsQuery.data ?? []) + .filter((item) => Boolean(item.sessionId)) + .map((item) => ({ + imageUrl: item.backgroundImageUrl, + item, + owner: "mine" as const, + shareId: item.id, + source: "server", + })), + [mineRecapsQuery.data], + ); + const serverSessionIds = useMemo( () => - session.status === 'idle' - ? [] - : momentLogs.filter((log) => log.sessionId === session.id), - [momentLogs, session.id, session.status], + new Set( + serverMineEntries.map(({ item }) => item.sessionId).filter(Boolean), + ), + [serverMineEntries], ); - const currentTripRepresentativeLog = useMemo( - () => getNewestMomentLog(currentTripLogs), - [currentTripLogs], + const serverRecapIds = useMemo( + () => new Set(serverMineEntries.map(({ item }) => item.id)), + [serverMineEntries], + ); + const localEntries: LogGridEntry[] = useMemo( + () => + createMomentLogGroups(momentLogs) + .filter((group) => Boolean(group.sessionId)) + .map((group) => ({ + imageUrl: group.logs[0]?.photoUri, + item: momentLogGroupToRecapItem(group), + owner: "mine" as const, + shareId: group.id, + source: "local" as const, + syncStatus: getGroupSyncStatus(group.logs), + })) + .filter( + ({ item }) => + !serverRecapIds.has(item.id) && + (!item.sessionId || !serverSessionIds.has(item.sessionId)), + ), + [momentLogs, serverRecapIds, serverSessionIds], + ); + const otherEntries = useMemo( + () => + sortEntriesByCreatedAt( + (otherRecapsQuery.data ?? []) + .filter((item) => Boolean(item.sessionId)) + .map((item) => ({ + imageUrl: item.backgroundImageUrl, + item, + owner: "other" as const, + shareId: item.id, + source: "server" as const, + })), + ), + [otherRecapsQuery.data], ); - const currentTripTrackCount = useMemo( - () => getUniqueTrackCount(currentTripLogs), - [currentTripLogs], + const myEntries = useMemo( + () => sortEntriesByCreatedAt([...localEntries, ...serverMineEntries]), + [localEntries, serverMineEntries], ); - const currentTripSyncedLogs = useMemo( - () => currentTripLogs.filter((log) => log.syncStatus === 'synced'), - [currentTripLogs], + const allEntries = useMemo( + () => + sortEntriesByCreatedAt(dedupeEntries([...myEntries, ...otherEntries])), + [myEntries, otherEntries], ); - const currentTripUnsyncedMomentCount = - currentTripLogs.length - currentTripSyncedLogs.length; - const isLocalSessionRecap = Boolean(extractSessionIdFromRecapId(session.recapId)); - const serverSessionRecapId = session.recapId && !isLocalSessionRecap - ? session.recapId - : undefined; - const currentTripStatus = - isCreatingCurrentRecap || currentRecapStatus === 'failed' - ? currentRecapStatus - : serverSessionRecapId - ? 'synced' - : isLocalSessionRecap - ? 'local' - : currentTripLogs.length > 0 - ? 'ready' - : 'empty'; - const serverRecaps: RecapListEntry[] = serverRecapItems.map((item) => ({ - imageUrl: item.representativeTrack.albumImageUrl, - item, - shareId: item.id, - })); - const serverSessionIds = new Set(serverRecaps.map(({ item }) => item.sessionId).filter(Boolean)); - const localRecaps: RecapListEntry[] = createMomentLogGroups(momentLogs) - .filter((group) => !group.sessionId || !serverSessionIds.has(group.sessionId)) - .map((group) => ({ - imageUrl: group.logs[0]?.photoUri, - item: momentLogGroupToRecapItem(group), - shareId: group.id, - })); - const recaps = [...localRecaps, ...serverRecaps]; - const hasRecaps = recaps.length > 0; - const savedMomentCount = recaps.reduce( - (sum, { item }) => sum + (item.momentCount ?? 1), - 0, + const hasAnyLog = otherEntries.length > 0 || allEntries.length > 0; + const handleOpenEntry = useCallback((entry: LogGridEntry) => { + router.push(`/recap-share/${entry.shareId}`); + }, []); + + const handleSelectTab = useCallback( + (tab: LogFeedTabId) => { + const tabIndex = tab === "others" ? 0 : 1; + + setActionMessage(undefined); + setSelectedTab(tab); + pagerRef.current?.scrollTo({ + animated: true, + x: tabIndex * width, + y: 0, + }); + }, + [width], ); - const handleCreateRecap = () => { - const latestLocalRecap = localRecaps[0]; - if (latestLocalRecap) { - router.push(`/recap-share/${latestLocalRecap.shareId}`); - return; - } + const handlePagerScrollEnd = useCallback( + (event: NativeSyntheticEvent) => { + const tabIndex = Math.round(event.nativeEvent.contentOffset.x / width); + const nextTab = logFeedTabs[tabIndex]?.value ?? "others"; + + if (nextTab !== selectedTab) { + setActionMessage(undefined); + setSelectedTab(nextTab); + } + }, + [selectedTab, width], + ); - router.push('/camera'); + const updateVisibilityCache = ( + recapId: string, + visibility: RecapVisibility, + replacement?: RecapItem, + ) => { + queryClient.setQueryData( + recapQueryKeys.list("mine"), + (previous = []) => + previous.map((item) => + item.id === recapId + ? { + ...(replacement ?? item), + visibility, + } + : item, + ), + ); }; - const handleCurrentTripRecap = async () => { - if (isCreatingCurrentRecap) { - return; - } - if (currentTripLogs.length === 0) { - router.push('/camera'); + const handleChangeVisibility = async ( + entry: LogGridEntry, + nextVisibility: RecapVisibility, + event: GestureResponderEvent, + ) => { + event.stopPropagation(); + + if (updatingRecapId) { return; } - const localRecapId = createSessionRecapId(session.id); + if (entry.owner !== "mine" || entry.source === "local") { + if (entry.source !== "local") { + return; + } - if (serverSessionRecapId && currentRecapStatus !== 'failed') { - router.push(`/recap-share/${serverSessionRecapId}`); - return; - } + setUpdatingRecapId(entry.item.id); + setActionMessage("기기에 저장된 로그를 서버와 다시 동기화하고 있어요."); - const hasOnlySyncedLogs = currentTripUnsyncedMomentCount === 0; - const representativeTrackId = currentTripLogs.find((log) => log.track?.id)?.track?.id; + try { + const result = await flushPendingMomentActions(); - if (!hasOnlySyncedLogs) { - setSessionRecapId(localRecapId); - setCurrentRecapStatus('local'); - setCurrentRecapMessage( - `아직 동기화되지 않은 Moment ${currentTripUnsyncedMomentCount}개가 있어 로컬 Recap으로 먼저 보여드릴게요.`, - ); - router.push(`/recap-share/${localRecapId}`); + if (result.failureCount > 0) { + setActionMessage( + "동기화하지 못했어요. 네트워크를 확인한 뒤 다시 눌러주세요.", + ); + return; + } + + setActionMessage( + "서버 동기화를 마쳤어요. 로그 목록을 새로 불러올게요.", + ); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }), + queryClient.invalidateQueries({ queryKey: ["moment-logs"] }), + ]); + } finally { + setUpdatingRecapId(undefined); + } return; } - setIsCreatingCurrentRecap(true); - setCurrentRecapMessage(undefined); + const previousRecaps = queryClient.getQueryData( + recapQueryKeys.list("mine"), + ); + + setUpdatingRecapId(entry.item.id); + setActionMessage(undefined); + updateVisibilityCache(entry.item.id, nextVisibility); try { - const serverRecap = await recapApi.createRecap({ - momentLogIds: currentTripSyncedLogs.map((log) => log.id), - representativeTrackId, - sessionId: session.id, - templateId: 'lp', - title: `${currentTripRepresentativeLog?.placeName ?? '이번 여행'} Recap`, - }); - const recapId = serverRecap?.id ?? localRecapId; - - setSessionRecapId(recapId); - setCurrentRecapStatus('synced'); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); - router.push(`/recap-share/${recapId}`); - } catch { - setSessionRecapId(localRecapId); - setCurrentRecapStatus('failed'); - setCurrentRecapMessage('서버 Recap 생성에 실패해서 로컬 Recap으로 먼저 보여드릴게요.'); - router.push(`/recap-share/${localRecapId}`); + const updatedRecap = await recapApi.updateRecapVisibility( + entry.item.id, + nextVisibility, + ); + + if (updatedRecap) { + updateVisibilityCache(entry.item.id, nextVisibility, updatedRecap); + } + + setActionMessage( + nextVisibility === "public" + ? "전체공개로 바꿨어요. 다른사람 보기와 지도 공개 영역에 표시돼요." + : "비공개로 바꿨어요. 내 로그에서만 확인할 수 있어요.", + ); + void queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); + } catch (error) { + queryClient.setQueryData(recapQueryKeys.list("mine"), previousRecaps); + setActionMessage( + error instanceof ApiError + ? error.message + : "공개 범위를 바꾸지 못했어요. 잠시 후 다시 시도해주세요.", + ); } finally { - setIsCreatingCurrentRecap(false); + setUpdatingRecapId(undefined); } }; return ( - - - - - - SOUNDLOG ARCHIVE - - - - Recap - - - 여행별 사운드트랙 앨범 생성 상태를 확인하고 다시 꺼내보세요. - + + + - - - - {recaps.length} - - - Recap - - - - - {savedMomentCount} - - - Saved moments - - - + + + {logFeedTabs.map((tab) => { + const selected = selectedTab === tab.value; + const count = + tab.value === "others" ? otherEntries.length : allEntries.length; - - - {hasRecaps ? '새 Recap 생성' : '첫 Moment로 Recap 만들기'} - - - + return ( + handleSelectTab(tab.value)} + > + + + {tab.label} + + + {count} + + + + ); + })} + + + - + - - {isLoading ? ( - - Recap 데이터를 불러오는 중이에요. - - ) : null} - - {isError && !hasRecaps ? ( - - Recap 데이터를 불러오지 못했어요. 잠시 후 다시 확인해주세요. - - ) : null} - - {!isLoading && !hasRecaps ? : null} - - - {hasRecaps ? ( - - 최근 Recap - - ) : null} - {recaps.map(({ imageUrl, item, shareId }) => ( - router.push(`/recap-share/${shareId}`)} - /> - ))} - - + + ); } + +const styles = StyleSheet.create({ + gridCard: { + backgroundColor: "#151927", + }, + pager: { + flex: 1, + }, + tabIndicator: { + backgroundColor: "#B9F20D", + bottom: 0, + height: 2, + left: 0, + position: "absolute", + }, +}); diff --git a/src/components/travel/CommunityRecapCard.tsx b/src/components/travel/CommunityRecapCard.tsx index 57ad79c..faff331 100644 --- a/src/components/travel/CommunityRecapCard.tsx +++ b/src/components/travel/CommunityRecapCard.tsx @@ -1,15 +1,25 @@ -import { Feather } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import { useEffect, useState } from 'react'; -import { Pressable, TextInput, View } from 'react-native'; - -import { communityApi } from '@/api/communityApi'; -import { AppText } from '@/components/AppText'; -import { SoundlogButton, SoundlogMetric, SoundlogSurface } from '@/design-system'; -import { useAuthStore } from '@/store/authStore'; -import { useTravelRoomStore } from '@/store/travelRoomStore'; -import type { PlaceContext, RecapItem, Track, TravelRoom, TravelRoomMoment } from '@/types/domain'; -import { shareTravelRoomInvite } from '@/utils/travelRoomInvite'; +import { Feather } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { useEffect, useState } from "react"; +import { Pressable, TextInput, View } from "react-native"; + +import { communityApi } from "@/api/communityApi"; +import { AppText } from "@/components/AppText"; +import { + SoundlogButton, + SoundlogMetric, + SoundlogSurface, +} from "@/design-system"; +import { useAuthStore } from "@/store/authStore"; +import { useTravelRoomStore } from "@/store/travelRoomStore"; +import type { + PlaceContext, + RecapItem, + Track, + TravelRoom, + TravelRoomMoment, +} from "@/types/domain"; +import { shareTravelRoomInvite } from "@/utils/travelRoomInvite"; type CommunityRecapCardProps = { currentPlace?: PlaceContext; @@ -17,43 +27,49 @@ type CommunityRecapCardProps = { momentCount: number; onRecapCreated: (recap: RecapItem) => void; sessionId: string; - sessionStatus: 'active' | 'ended' | 'idle'; + sessionStatus: "active" | "ended" | "idle"; trackCount: number; }; -const previewMembers = ['나', '수', '재', '채']; -const previewContributors = ['수경', '재걸', '채린']; +const previewMembers = ["나", "수", "재", "채"]; +const previewContributors = ["수경", "재걸", "채린"]; const COLLAPSED_MOMENT_COUNT = 2; const MAX_MEMBER_AVATAR_COUNT = 5; -type TravelRoomMomentComment = NonNullable[number]; -type TravelRoomMember = TravelRoom['members'][number]; +type TravelRoomMomentComment = NonNullable< + TravelRoomMoment["comments"] +>[number]; +type TravelRoomMember = TravelRoom["members"][number]; -function createPreviewCandidateMoments(placeName: string, currentTrack?: Track): TravelRoomMoment[] { +function createPreviewCandidateMoments( + placeName: string, + currentTrack?: Track, +): TravelRoomMoment[] { const fallbackTrack: Track = currentTrack ?? { - artist: 'Soundlog', - fallbackColor: '#2B176C', - id: 'preview-community-track', - title: '곡명 A', + artist: "Soundlog", + fallbackColor: "#2B176C", + id: "preview-community-track", + title: "곡명 A", }; return [ { commentCount: 1, createdAt: new Date().toISOString(), - id: 'preview-candidate-ocean', - placeName: placeName === '이번 여행' ? '주문진 바다 컷' : `${placeName} 컷`, - status: 'candidate', + id: "preview-candidate-ocean", + placeName: + placeName === "이번 여행" ? "주문진 바다 컷" : `${placeName} 컷`, + status: "candidate", track: fallbackTrack, - userId: '수경', + userId: "수경", }, { commentCount: 2, createdAt: new Date().toISOString(), - id: 'preview-candidate-cafe', - note: '카페 거리 컷', - placeName: '카페 거리 컷', - status: 'candidate', + id: "preview-candidate-cafe", + note: "카페 거리 컷", + placeName: "카페 거리 컷", + status: "candidate", track: currentTrack ? { ...currentTrack, @@ -61,23 +77,28 @@ function createPreviewCandidateMoments(placeName: string, currentTrack?: Track): title: currentTrack.title, } : { - artist: 'Soundlog', - fallbackColor: '#B7E628', - id: 'preview-community-track-b', - title: '곡명 B', + artist: "Soundlog", + fallbackColor: "#B7E628", + id: "preview-community-track-b", + title: "곡명 B", }, - userId: '재걸', + userId: "재걸", }, ]; } function getUniqueTrackCount(moments: TravelRoomMoment[]) { - return new Set(moments.map((moment) => moment.track?.id).filter(Boolean)).size; + return new Set(moments.map((moment) => moment.track?.id).filter(Boolean)) + .size; } -function getMemberLabel(member: TravelRoomMember, index: number, currentUserId?: string) { +function getMemberLabel( + member: TravelRoomMember, + index: number, + currentUserId?: string, +) { if (member.userId === currentUserId) { - return '나'; + return "나"; } const displayName = member.displayName ?? member.userId; @@ -85,8 +106,9 @@ function getMemberLabel(member: TravelRoomMember, index: number, currentUserId?: } function getMemberCaption(member: TravelRoomMember, currentUserId?: string) { - const name = member.userId === currentUserId ? '나' : member.displayName ?? '동행자'; - const role = member.role === 'owner' ? '방장' : '참여자'; + const name = + member.userId === currentUserId ? "나" : (member.displayName ?? "동행자"); + const role = member.role === "owner" ? "방장" : "참여자"; return `${name} · ${role}`; } @@ -118,23 +140,33 @@ function CandidateMomentRow({ pendingComment: boolean; pendingStatus: boolean; }) { - const contributor = moment.userId || previewContributors[index] || '동행자'; + const contributor = moment.userId || previewContributors[index] || "동행자"; const trackLabel = moment.track ? `${contributor} 추가 · ${moment.track.title}` - : `${contributor} 추가 · ${moment.note ?? '곡 없이 남긴 후보'}`; + : `${contributor} 추가 · ${moment.note ?? "곡 없이 남긴 후보"}`; const latestComment = moment.comments?.at(-1); const commentCount = moment.commentCount ?? moment.comments?.length ?? 0; - const visibleComments = commentsExpanded ? moment.comments ?? [] : latestComment ? [latestComment] : []; - const nextStatusLabel = moment.status === 'accepted' ? '후보로' : '채택'; + const visibleComments = commentsExpanded + ? (moment.comments ?? []) + : latestComment + ? [latestComment] + : []; + const nextStatusLabel = moment.status === "accepted" ? "후보로" : "채택"; return ( - - {moment.placeName ?? '장소 미정'} + + {moment.placeName ?? "장소 미정"} - + {trackLabel} @@ -143,7 +175,7 @@ function CandidateMomentRow({ accessibilityRole="button" className={` rounded-full px-2.5 py-1 - ${moment.status === 'accepted' ? 'bg-white/10' : 'bg-soundlog-lime'} + ${moment.status === "accepted" ? "bg-white/10" : "bg-soundlog-lime"} `} disabled={pendingStatus} onPress={onToggleStatus} @@ -151,10 +183,10 @@ function CandidateMomentRow({ - {pendingStatus ? '변경 중' : nextStatusLabel} + {pendingStatus ? "변경 중" : nextStatusLabel} ) : null} @@ -163,7 +195,7 @@ function CandidateMomentRow({ - {moment.status === 'accepted' ? '채택됨' : '채택 후보'} + {moment.status === "accepted" ? "채택됨" : "채택 후보"} @@ -177,7 +209,7 @@ function CandidateMomentRow({ - {commentsExpanded ? '댓글 접기' : `댓글 ${commentCount}개 보기`} + {commentsExpanded ? "댓글 접기" : `댓글 ${commentCount}개 보기`} {visibleComments.length > 0 ? ( @@ -214,10 +246,12 @@ function CandidateMomentRow({ className="h-9 items-center justify-center rounded-full border border-soundlog-lime/40 bg-soundlog-lime/10" disabled={pendingComment || !commentDraft.trim()} onPress={onSubmitComment} - style={{ opacity: pendingComment || !commentDraft.trim() ? 0.55 : 1 }} + style={{ + opacity: pendingComment || !commentDraft.trim() ? 0.55 : 1, + }} > - {pendingComment ? '댓글 저장 중' : '댓글 추가'} + {pendingComment ? "댓글 저장 중" : "댓글 추가"} @@ -226,7 +260,10 @@ function CandidateMomentRow({ ); } -function replaceRoomMoment(room: TravelRoom, updatedMoment: TravelRoomMoment): TravelRoom { +function replaceRoomMoment( + room: TravelRoom, + updatedMoment: TravelRoomMoment, +): TravelRoom { return { ...room, moments: room.moments.map((moment) => @@ -273,12 +310,17 @@ export function CommunityRecapCard({ const [isJoiningRoom, setIsJoiningRoom] = useState(false); const [isRestoringRoom, setIsRestoringRoom] = useState(false); const [isSharingInvite, setIsSharingInvite] = useState(false); - const [commentDrafts, setCommentDrafts] = useState>({}); - const [expandedCommentIds, setExpandedCommentIds] = useState>({}); - const [joinDisplayName, setJoinDisplayName] = useState(''); - const [joinInviteCode, setJoinInviteCode] = useState(''); + const [commentDrafts, setCommentDrafts] = useState>( + {}, + ); + const [expandedCommentIds, setExpandedCommentIds] = useState< + Record + >({}); + const [joinDisplayName, setJoinDisplayName] = useState(""); + const [joinInviteCode, setJoinInviteCode] = useState(""); const [message, setMessage] = useState(); - const [pendingCommentMomentId, setPendingCommentMomentId] = useState(); + const [pendingCommentMomentId, setPendingCommentMomentId] = + useState(); const [pendingStatusMomentId, setPendingStatusMomentId] = useState(); const [showAllMoments, setShowAllMoments] = useState(false); const authStatus = useAuthStore((state) => state.status); @@ -286,29 +328,41 @@ export function CommunityRecapCard({ const room = useTravelRoomStore((state) => state.roomsBySessionId[sessionId]); const setTravelRoom = useTravelRoomStore((state) => state.setRoom); - const placeName = currentPlace?.title ?? '이번 여행'; - const canUseRoom = sessionStatus !== 'idle'; + const placeName = currentPlace?.title ?? "이번 여행"; + const canUseRoom = sessionStatus !== "idle"; const normalizedInviteCode = joinInviteCode.trim().toUpperCase(); - const previewCandidateMoments = createPreviewCandidateMoments(placeName, currentTrack); + const previewCandidateMoments = createPreviewCandidateMoments( + placeName, + currentTrack, + ); const allCandidateMoments = room?.moments ?? previewCandidateMoments; const candidateMoments = showAllMoments ? allCandidateMoments : allCandidateMoments.slice(0, COLLAPSED_MOMENT_COUNT); - const hiddenMomentCount = Math.max(allCandidateMoments.length - candidateMoments.length, 0); + const hiddenMomentCount = Math.max( + allCandidateMoments.length - candidateMoments.length, + 0, + ); const displayMemberCount = room?.memberCount ?? 4; - const displayMomentCount = room?.momentCount ?? Math.max(momentCount, candidateMoments.length); + const displayMomentCount = + room?.momentCount ?? Math.max(momentCount, candidateMoments.length); const displayTrackCount = room ? Math.max(getUniqueTrackCount(room.moments), 1) : Math.max(trackCount, currentTrack ? 1 : candidateMoments.length); const displayRoomTitle = room?.title ?? `${placeName} 여행방`; - const myMemberRole = room?.members.find((member) => member.userId === currentUserId)?.role; - const canCommentRoom = Boolean(room && authStatus === 'authenticated'); - const canModerateRoom = myMemberRole === 'owner'; + const myMemberRole = room?.members.find( + (member) => member.userId === currentUserId, + )?.role; + const canCommentRoom = Boolean(room && authStatus === "authenticated"); + const canModerateRoom = myMemberRole === "owner"; const visibleMembers = room?.members.slice(0, MAX_MEMBER_AVATAR_COUNT); - const memberOverflowCount = Math.max((room?.memberCount ?? 0) - (visibleMembers?.length ?? 0), 0); + const memberOverflowCount = Math.max( + (room?.memberCount ?? 0) - (visibleMembers?.length ?? 0), + 0, + ); useEffect(() => { - if (!canUseRoom || room || authStatus !== 'authenticated' || !sessionId) { + if (!canUseRoom || room || authStatus !== "authenticated" || !sessionId) { return; } @@ -331,7 +385,9 @@ export function CommunityRecapCard({ }) .catch(() => { if (!ignore) { - setMessage('기존 공동 여행방을 불러오지 못했어요. 초대 코드는 직접 입력할 수 있어요.'); + setMessage( + "기존 공동 여행방을 불러오지 못했어요. 초대 코드는 직접 입력할 수 있어요.", + ); } }) .finally(() => { @@ -357,18 +413,20 @@ export function CommunityRecapCard({ const nextRoom = await communityApi.createTravelRoom({ sessionId, title: `${placeName} 사운드 여행방`, - visibility: 'invite_only', + visibility: "invite_only", }); if (!nextRoom) { - setMessage('로그인 후 공동 Recap 여행방을 만들 수 있어요.'); + setMessage("로그인 후 공동 Recap 여행방을 만들 수 있어요."); return; } setTravelRoom(sessionId, nextRoom); - setMessage('초대 코드를 공유하면 동행자가 Moment 후보를 함께 올릴 수 있어요.'); + setMessage( + "초대 코드를 공유하면 동행자가 Moment 후보를 함께 올릴 수 있어요.", + ); } catch { - setMessage('공동 여행방을 만들지 못했어요. 잠시 후 다시 시도해주세요.'); + setMessage("공동 여행방을 만들지 못했어요. 잠시 후 다시 시도해주세요."); } finally { setIsCreatingRoom(false); } @@ -390,9 +448,15 @@ export function CommunityRecapCard({ try { const result = await shareTravelRoomInvite(room); - setMessage(result === 'copied' ? '초대 메시지를 클립보드에 복사했어요.' : '초대 메시지를 공유했어요.'); + setMessage( + result === "copied" + ? "초대 메시지를 클립보드에 복사했어요." + : "초대 메시지를 공유했어요.", + ); } catch { - setMessage('초대 메시지를 공유하지 못했어요. 초대 코드를 직접 전달해주세요.'); + setMessage( + "초대 메시지를 공유하지 못했어요. 초대 코드를 직접 전달해주세요.", + ); } finally { setIsSharingInvite(false); } @@ -414,7 +478,7 @@ export function CommunityRecapCard({ ); if (!moment) { - setMessage('서버에 연결된 로그인 상태에서 후보를 추가할 수 있어요.'); + setMessage("서버에 연결된 로그인 상태에서 후보를 추가할 수 있어요."); return; } @@ -425,9 +489,11 @@ export function CommunityRecapCard({ }; setTravelRoom(sessionId, nextRoom); - setMessage('현재 선택 곡을 공동 Recap 후보에 추가했어요.'); + setMessage("현재 선택 곡을 공동 Recap 후보에 추가했어요."); } catch { - setMessage('후보 추가에 실패했어요. 현재 곡이나 네트워크 상태를 확인해주세요.'); + setMessage( + "후보 추가에 실패했어요. 현재 곡이나 네트워크 상태를 확인해주세요.", + ); } finally { setIsAddingMoment(false); } @@ -444,19 +510,23 @@ export function CommunityRecapCard({ try { const recap = await communityApi.createTravelRoomRecap(room.id, { representativeTrackId: currentTrack?.id, - templateId: 'album', + templateId: "album", title: `${placeName} 공동 Recap`, }); if (!recap) { - setMessage('공동 Recap 생성은 로그인된 서버 세션에서 사용할 수 있어요.'); + setMessage( + "공동 Recap 생성은 로그인된 서버 세션에서 사용할 수 있어요.", + ); return; } - setMessage('공동 Recap을 만들었어요.'); + setMessage("공동 Recap을 만들었어요."); onRecapCreated(recap); } catch { - setMessage('공동 Recap 생성에 실패했어요. 후보 Moment가 있는지 확인해주세요.'); + setMessage( + "공동 로그 생성에 실패했어요. 후보 리캡이 있는지 확인해주세요.", + ); } finally { setIsCreatingRecap(false); } @@ -476,16 +546,20 @@ export function CommunityRecapCard({ }); if (!nextRoom) { - setMessage('로그인 후 초대 코드로 공동 여행방에 참여할 수 있어요.'); + setMessage("로그인 후 초대 코드로 공동 여행방에 참여할 수 있어요."); return; } setTravelRoom(sessionId, nextRoom); - setJoinDisplayName(''); - setJoinInviteCode(''); - setMessage('공동 여행방에 참여했어요. 이제 현재 곡을 Recap 후보로 올릴 수 있어요.'); + setJoinDisplayName(""); + setJoinInviteCode(""); + setMessage( + "공동 여행방에 참여했어요. 이제 현재 곡을 Recap 후보로 올릴 수 있어요.", + ); } catch { - setMessage('초대 코드로 여행방에 참여하지 못했어요. 코드를 다시 확인해주세요.'); + setMessage( + "초대 코드로 여행방에 참여하지 못했어요. 코드를 다시 확인해주세요.", + ); } finally { setIsJoiningRoom(false); } @@ -499,7 +573,8 @@ export function CommunityRecapCard({ setMessage(undefined); try { - const nextStatus = moment.status === 'accepted' ? 'candidate' : 'accepted'; + const nextStatus = + moment.status === "accepted" ? "candidate" : "accepted"; const updatedMoment = await communityApi.updateTravelRoomMomentStatus( room.id, moment.id, @@ -507,14 +582,20 @@ export function CommunityRecapCard({ ); if (!updatedMoment) { - setMessage('방장 계정으로 로그인하면 후보 채택 상태를 바꿀 수 있어요.'); + setMessage("방장 계정으로 로그인하면 후보 채택 상태를 바꿀 수 있어요."); return; } setTravelRoom(sessionId, replaceRoomMoment(room, updatedMoment)); - setMessage(nextStatus === 'accepted' ? '후보 Moment를 채택했어요.' : '후보 상태로 되돌렸어요.'); + setMessage( + nextStatus === "accepted" + ? "후보 리캡을 채택했어요." + : "후보 상태로 되돌렸어요.", + ); } catch { - setMessage('후보 상태를 변경하지 못했어요. 방장 권한이나 네트워크 상태를 확인해주세요.'); + setMessage( + "후보 상태를 변경하지 못했어요. 방장 권한이나 네트워크 상태를 확인해주세요.", + ); } finally { setPendingStatusMomentId(undefined); } @@ -534,18 +615,27 @@ export function CommunityRecapCard({ setMessage(undefined); try { - const comment = await communityApi.addTravelRoomMomentComment(room.id, moment.id, body); + const comment = await communityApi.addTravelRoomMomentComment( + room.id, + moment.id, + body, + ); if (!comment) { - setMessage('로그인 후 후보 Moment에 댓글을 남길 수 있어요.'); + setMessage("로그인 후 후보 리캡에 댓글을 남길 수 있어요."); return; } - setTravelRoom(sessionId, appendRoomMomentComment(room, moment.id, comment)); - setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: '' })); - setMessage('후보 Moment에 댓글을 남겼어요.'); + setTravelRoom( + sessionId, + appendRoomMomentComment(room, moment.id, comment), + ); + setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: "" })); + setMessage("후보 리캡에 댓글을 남겼어요."); } catch { - setMessage('댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.'); + setMessage( + "댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.", + ); } finally { setPendingCommentMomentId(undefined); } @@ -575,13 +665,17 @@ export function CommunityRecapCard({ ? visibleMembers?.map((member, index) => ( {getMemberLabel(member, index, currentUserId)} @@ -591,13 +685,13 @@ export function CommunityRecapCard({ : previewMembers.slice(0, displayMemberCount).map((member, index) => ( {member} @@ -606,20 +700,35 @@ export function CommunityRecapCard({ ))} {memberOverflowCount > 0 ? ( - +{memberOverflowCount} + + +{memberOverflowCount} + ) : null} - + {room && visibleMembers?.length - ? visibleMembers.map((member) => getMemberCaption(member, currentUserId)).join(' · ') - : '초대 코드로 함께 참여'} + ? visibleMembers + .map((member) => getMemberCaption(member, currentUserId)) + .join(" · ") + : "초대 코드로 함께 참여"} - + - + {isRestoringRoom ? ( @@ -632,22 +741,29 @@ export function CommunityRecapCard({ {message ? ( - {message} + + {message} + ) : null} - Recap 후보 + + Recap 후보 + - {room?.inviteCode ? `초대 코드 ${room.inviteCode}` : '초대 코드 기반 참여'} + {room?.inviteCode + ? `초대 코드 ${room.inviteCode}` + : "초대 코드 기반 참여"} {candidateMoments.length === 0 ? ( - 아직 후보가 없어요. 현재 곡을 먼저 올리거나 초대 코드로 동행자를 초대해보세요. + 아직 후보가 없어요. 현재 곡을 먼저 올리거나 초대 코드로 동행자를 + 초대해보세요. ) : ( @@ -655,12 +771,15 @@ export function CommunityRecapCard({ - setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: value })) + setCommentDrafts((drafts) => ({ + ...drafts, + [moment.id]: value, + })) } onSubmitComment={() => void handleSubmitMomentComment(moment)} onToggleComments={() => @@ -688,7 +807,9 @@ export function CommunityRecapCard({ onPress={() => setShowAllMoments((value) => !value)} > - {showAllMoments ? '후보 접기' : `후보 ${hiddenMomentCount}개 더 보기`} + {showAllMoments + ? "후보 접기" + : `후보 ${hiddenMomentCount}개 더 보기`} ) : null} @@ -697,7 +818,8 @@ export function CommunityRecapCard({ {!room ? ( - 서버 여행방을 만들면 이 미리보기 후보 대신 동행자가 올린 사진, 곡, 메모가 실시간으로 쌓여요. + 서버 여행방을 만들면 이 미리보기 후보 대신 동행자가 올린 사진, 곡, + 메모가 실시간으로 쌓여요. ) : null} @@ -726,7 +848,7 @@ export function CommunityRecapCard({ void handleJoinRoom()} variant="ghost" /> @@ -740,7 +862,7 @@ export function CommunityRecapCard({ > - {isCreatingRoom ? '여행방 생성 중' : '새 여행방 만들기'} + {isCreatingRoom ? "여행방 생성 중" : "새 여행방 만들기"} @@ -758,7 +880,7 @@ export function CommunityRecapCard({ disabled={isSharingInvite} fullWidth iconName="send" - label={isSharingInvite ? '공유 중' : '초대 공유'} + label={isSharingInvite ? "공유 중" : "초대 공유"} onPress={() => void handleShareInvite()} variant="ghost" /> @@ -766,14 +888,14 @@ export function CommunityRecapCard({ void handleAddCurrentTrack()} variant="ghost" /> void handleCreateRecap()} /> diff --git a/src/components/travel/EndTravelConfirmModal.tsx b/src/components/travel/EndTravelConfirmModal.tsx index 54d2954..700405d 100644 --- a/src/components/travel/EndTravelConfirmModal.tsx +++ b/src/components/travel/EndTravelConfirmModal.tsx @@ -1,9 +1,10 @@ -import { Feather } from '@expo/vector-icons'; -import { Modal, Pressable, View } from 'react-native'; +import { Feather } from "@expo/vector-icons"; +import { Modal, Pressable, View } from "react-native"; -import { AppText } from '@/components/AppText'; +import { AppText } from "@/components/AppText"; type EndTravelConfirmModalProps = { + isConfirming?: boolean; momentCount: number; onCancel: () => void; onConfirm: () => void; @@ -11,38 +12,54 @@ type EndTravelConfirmModalProps = { }; export function EndTravelConfirmModal({ + isConfirming = false, momentCount, onCancel, onConfirm, visible, }: EndTravelConfirmModalProps) { return ( - + - 여행을 종료할까요? + + 여행을 종료할까요? + - 지금까지 저장한 Moment {momentCount}개가 Recap 생성을 위한 여행 기록으로 묶여요. + 지금까지 저장한 리캡 {momentCount}개가 하나의 여행 로그로 묶여요. - 취소 + + 취소 + - 종료 + + {isConfirming ? "정리 중" : "종료"} + diff --git a/src/components/travel/MomentCard.tsx b/src/components/travel/MomentCard.tsx index a6f29f5..d151a09 100644 --- a/src/components/travel/MomentCard.tsx +++ b/src/components/travel/MomentCard.tsx @@ -30,7 +30,7 @@ export function MomentCard({ item, onPress, onRetry }: MomentCardProps) { className="min-h-[132px] flex-row overflow-hidden rounded-[22px] border border-white/10 bg-white/10" > - Moment + 리캡 {syncLabel ? ( diff --git a/src/components/travel/RecapCard.tsx b/src/components/travel/RecapCard.tsx index 1a1142c..c38c645 100644 --- a/src/components/travel/RecapCard.tsx +++ b/src/components/travel/RecapCard.tsx @@ -48,7 +48,7 @@ export function RecapCard({ item, onPress }: RecapCardProps) { - Moment {item.momentCount} + 리캡 {item.momentCount} diff --git a/src/components/travel/TravelModeBottomSheet.tsx b/src/components/travel/TravelModeBottomSheet.tsx index 9721dd9..521d25e 100644 --- a/src/components/travel/TravelModeBottomSheet.tsx +++ b/src/components/travel/TravelModeBottomSheet.tsx @@ -12,6 +12,7 @@ type TravelModeBottomSheetProps = { onSelectMode: (mode: TravelMode) => void; onStart: () => void; selectedMode?: TravelMode; + submitLabel?: string; visible: boolean; }; @@ -20,6 +21,7 @@ export function TravelModeBottomSheet({ onSelectMode, onStart, selectedMode, + submitLabel = '여행 시작', visible, }: TravelModeBottomSheetProps) { const insets = useSafeAreaInsets(); @@ -38,7 +40,7 @@ export function TravelModeBottomSheet({ 여행 모드 선택 - 지금의 여행 맥락을 고르면 음악 추천과 Moment가 같은 세션으로 묶여요. + 지금의 여행 맥락을 고르면 음악 추천과 리캡이 같은 로그로 묶여요. - 여행 시작 + {submitLabel} diff --git a/src/components/travel/TravelReportModal.tsx b/src/components/travel/TravelReportModal.tsx index 7147162..4316b41 100644 --- a/src/components/travel/TravelReportModal.tsx +++ b/src/components/travel/TravelReportModal.tsx @@ -1,12 +1,17 @@ -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 { useSafeAreaInsets } from 'react-native-safe-area-context'; +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 { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AppText } from '@/components/AppText'; +import { AppText } from "@/components/AppText"; -import { modeIconByValue, modeLabelByValue, sampleMoments, type TravelRecap } from './travelData'; +import { + modeIconByValue, + modeLabelByValue, + sampleMoments, + type TravelRecap, +} from "./travelData"; type TravelReportModalProps = { item?: TravelRecap; @@ -31,33 +36,54 @@ const STORY_PAGE_COUNT = 6; function CoverGeometry({ accent }: { accent: string }) { return ( <> - - - - - - + + + + + + - + ); } -function DotPattern({ color = 'rgba(255,255,255,0.16)' }: { color?: string }) { +function DotPattern({ color = "rgba(255,255,255,0.16)" }: { color?: string }) { return ( {Array.from({ length: 42 }).map((_, index) => { @@ -96,16 +122,19 @@ function StoryBackdrop({ hideInnerCircles?: boolean; hideShapes?: boolean; minimal?: boolean; - pattern?: 'dots'; + pattern?: "dots"; }) { return ( - {pattern === 'dots' ? : null} + {pattern === "dots" ? : null} {minimal ? ( ) : hideShapes ? null : ( <> - + {hideInnerCircles ? null : ( )} @@ -128,14 +157,23 @@ function StoryBackdrop({ ); } -function ProgressBars({ currentIndex, total }: { currentIndex: number; total: number }) { +function ProgressBars({ + currentIndex, + total, +}: { + currentIndex: number; + total: number; +}) { return ( {Array.from({ length: total }).map((_, index) => ( - + ))} @@ -162,7 +200,7 @@ function StoryShell({ hideShapes?: boolean; minimalBackdrop?: boolean; palette: [string, string, string]; - pattern?: 'dots'; + pattern?: "dots"; }) { return ( value.trim()); - const startedDateText = startedAtText.split(' ')[0] ?? ''; - const endedAtText = rawEndedAtText.includes('.') + const [startedAtText = item.periodText, rawEndedAtText = item.periodText] = + item.periodText.split(" - ").map((value) => value.trim()); + const startedDateText = startedAtText.split(" ")[0] ?? ""; + const endedAtText = rawEndedAtText.includes(".") ? rawEndedAtText : `${startedDateText} ${rawEndedAtText}`.trim(); - const recapThumbnails = [sampleMoments[0], sampleMoments[1], sampleMoments[0]].filter(Boolean); + const recapThumbnails = [ + sampleMoments[0], + sampleMoments[1], + sampleMoments[0], + ].filter(Boolean); const pages: StoryPage[] = [ { - accent: '#F2C94C', + accent: "#F2C94C", hideBottomBar: true, hideInnerCircles: true, - key: 'cover', - palette: ['#070B1F', '#070B1F', '#070B1F'], + key: "cover", + palette: ["#070B1F", "#070B1F", "#070B1F"], node: ( @@ -252,7 +297,7 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP Soundlog - Travel{'\n'}Recap + Travel{"\n"}Recap {modeIcon} @@ -272,18 +317,18 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP Travel location - {item.locations.join('\n')} + {item.locations.join("\n")} ), }, { - accent: '#FF352B', + accent: "#FF352B", hideBottomBar: true, hideInnerCircles: true, - key: 'summary', - palette: ['#F2C94C', '#F2C94C', '#F2C94C'], + key: "summary", + palette: ["#F2C94C", "#F2C94C", "#F2C94C"], node: ( @@ -291,10 +336,11 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP Music records - {item.playTimeText.replace('음악 기록 ', '')} + {item.playTimeText.replace("음악 기록 ", "")} - 음악으로 남긴{'\n'}{item.durationText} + 음악으로 남긴{"\n"} + {item.durationText} @@ -305,7 +351,9 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP {item.playCount} - 회 기록 + + 회 기록 + @@ -314,23 +362,25 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP {item.trackCount} - 곡 감상 + + 곡 감상 + ), }, { - accent: '#FF352B', + accent: "#FF352B", hideInnerCircles: true, - key: 'most', - palette: ['#07131A', '#07131A', '#111C22'], + key: "most", + palette: ["#07131A", "#07131A", "#111C22"], node: ( Most recorded - 이 여행에서{'\n'}가장 많이 기록한 노래 + 이 여행에서{"\n"}가장 많이 기록한 노래 @@ -356,11 +406,11 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP ), }, { - accent: '#F2C94C', + accent: "#F2C94C", hideInnerCircles: true, hideBottomBar: true, - key: 'ranking', - palette: ['#FF352B', '#FF352B', '#FF352B'], + key: "ranking", + palette: ["#FF352B", "#FF352B", "#FF352B"], node: ( @@ -371,14 +421,25 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP {item.topTracks.map((track, index) => ( - - {index + 1} + + + {index + 1} + - + {track.title} - + {track.artist} @@ -390,25 +451,27 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP ), }, { - accent: '#FF352B', + accent: "#FF352B", hideBottomBar: true, hideGrayCircle: true, hideInnerCircles: true, - key: 'recaps', - palette: ['#F2C94C', '#F2C94C', '#F2C94C'], + key: "recaps", + palette: ["#F2C94C", "#F2C94C", "#F2C94C"], node: ( Saved recaps - 이 여행에서{'\n'}남긴 기록 + 이 여행에서{"\n"}남긴 기록 {item.momentCount} - 개의 Recap + + 개의 Recap + @@ -427,9 +490,13 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP ) : ( - + 음악 기록 @@ -443,17 +510,17 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP ), }, { - accent: '#FF352B', + accent: "#FF352B", hideBottomBar: true, hideInnerCircles: true, - key: 'share', - palette: ['#07131A', '#07131A', '#111C22'], + key: "share", + palette: ["#07131A", "#07131A", "#111C22"], node: ( Travel summary - 숫자로 남은{'\n'}이번 여행 + 숫자로 남은{"\n"}이번 여행 @@ -462,7 +529,7 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP Travel time - {item.durationText.replace('의 여행', '')} + {item.durationText.replace("의 여행", "")} @@ -485,7 +552,7 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP - Moments + 리캡 {item.momentCount} @@ -506,7 +573,8 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP ]; const goPrevious = () => setPageIndex((index) => Math.max(0, index - 1)); - const goNext = () => setPageIndex((index) => Math.min(pages.length - 1, index + 1)); + const goNext = () => + setPageIndex((index) => Math.min(pages.length - 1, index + 1)); const currentPage = pages[pageIndex]; return ( @@ -519,7 +587,7 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP hideInnerCircles={currentPage.hideInnerCircles} hideShapes={currentPage.hideShapes} palette={currentPage.palette} - pattern={currentPage.key === 'summary' ? 'dots' : undefined} + pattern={currentPage.key === "summary" ? "dots" : undefined} > @@ -548,9 +616,9 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP style={{ bottom: 0, left: 0, - position: 'absolute', + position: "absolute", top: insets.top + 104, - width: '50%', + width: "50%", zIndex: 3, }} /> @@ -560,10 +628,10 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP onPress={goNext} style={{ bottom: 0, - position: 'absolute', + position: "absolute", right: 0, top: insets.top + 104, - width: '50%', + width: "50%", zIndex: 3, }} /> diff --git a/src/components/travel/TravelRoomDetailScreen.tsx b/src/components/travel/TravelRoomDetailScreen.tsx index dbe58ad..e8383db 100644 --- a/src/components/travel/TravelRoomDetailScreen.tsx +++ b/src/components/travel/TravelRoomDetailScreen.tsx @@ -1,28 +1,36 @@ -import { Feather } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import { useEffect, useMemo, useState } from 'react'; -import { Pressable, ScrollView, TextInput, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { communityApi } from '@/api/communityApi'; -import { AppText } from '@/components/AppText'; -import { Screen } from '@/components/Screen'; -import { getTabBarHeight } from '@/constants/layout'; -import { SoundlogButton, SoundlogMetric, SoundlogSurface } from '@/design-system'; -import { useAuthStore } from '@/store/authStore'; -import { usePlayerStore } from '@/store/playerStore'; -import { useTravelRoomStore } from '@/store/travelRoomStore'; -import { useTravelSessionStore } from '@/store/travelSessionStore'; -import type { TravelRoom, TravelRoomMoment } from '@/types/domain'; -import { shareTravelRoomInvite } from '@/utils/travelRoomInvite'; +import { router } from "expo-router"; +import { useEffect, useMemo, useState } from "react"; +import { ScrollView, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { communityApi } from "@/api/communityApi"; +import { AppText } from "@/components/AppText"; +import { IconButton } from "@/components/IconButton"; +import { PageHeader } from "@/components/PageHeader"; +import { Screen } from "@/components/Screen"; +import { SectionTitle } from "@/components/SectionTitle"; +import { SettingsRow } from "@/components/SettingsRow"; +import { getTabBarHeight } from "@/constants/layout"; +import { SoundlogButton } from "@/design-system"; +import { useAuthStore } from "@/store/authStore"; +import { usePlayerStore } from "@/store/playerStore"; +import { useTravelRoomStore } from "@/store/travelRoomStore"; +import { useTravelSessionStore } from "@/store/travelSessionStore"; +import type { TravelRoom, TravelRoomMoment } from "@/types/domain"; +import { shareTravelRoomInvite } from "@/utils/travelRoomInvite"; type TravelRoomDetailScreenProps = { roomId: string; }; -type TravelRoomMomentComment = NonNullable[number]; +type TravelRoomMomentComment = NonNullable< + TravelRoomMoment["comments"] +>[number]; -function replaceRoomMoment(room: TravelRoom, updatedMoment: TravelRoomMoment): TravelRoom { +function replaceRoomMoment( + room: TravelRoom, + updatedMoment: TravelRoomMoment, +): TravelRoom { return { ...room, moments: room.moments.map((moment) => @@ -54,45 +62,53 @@ function appendRoomMomentComment( }; } -function createMemberLabel(member: TravelRoom['members'][number], currentUserId?: string) { +function createMemberLabel( + member: TravelRoom["members"][number], + currentUserId?: string, +) { if (member.userId === currentUserId) { - return '나'; + return "나"; } - return member.displayName ?? '동행자'; + return member.displayName ?? "동행자"; } function createMomentTrackLabel(moment: TravelRoomMoment) { if (!moment.track) { - return moment.note ?? '음악 없이 남긴 후보'; + return moment.note ?? "음악 없이 남긴 후보"; } return `${moment.track.title} - ${moment.track.artist}`; } -function createStatusLabel(status: TravelRoomMoment['status']) { - if (status === 'accepted') { - return '채택됨'; +function createStatusLabel(status: TravelRoomMoment["status"]) { + if (status === "accepted") { + return "채택됨"; } - if (status === 'rejected') { - return '보류'; + if (status === "rejected") { + return "보류"; } - return '채택 후보'; + return "채택 후보"; } function sortMoments(moments: TravelRoomMoment[]) { return [...moments].sort((first, second) => { if (first.status === second.status) { - return new Date(second.createdAt).getTime() - new Date(first.createdAt).getTime(); + return ( + new Date(second.createdAt).getTime() - + new Date(first.createdAt).getTime() + ); } - return first.status === 'accepted' ? -1 : 1; + return first.status === "accepted" ? -1 : 1; }); } -export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) { +export function TravelRoomDetailScreen({ + roomId, +}: TravelRoomDetailScreenProps) { const insets = useSafeAreaInsets(); const authStatus = useAuthStore((state) => state.status); const currentUserId = useAuthStore((state) => state.user?.id); @@ -100,21 +116,31 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) const setRoomById = useTravelRoomStore((state) => state.setRoomById); const { currentTrack } = usePlayerStore(); const currentPlace = useTravelSessionStore((state) => state.currentPlace); - const [commentDrafts, setCommentDrafts] = useState>({}); + const [commentDrafts, setCommentDrafts] = useState>( + {}, + ); const [isAddingMoment, setIsAddingMoment] = useState(false); const [isCreatingRecap, setIsCreatingRecap] = useState(false); const [isLoading, setIsLoading] = useState(!cachedRoom); const [isSharingInvite, setIsSharingInvite] = useState(false); const [message, setMessage] = useState(); - const [pendingCommentMomentId, setPendingCommentMomentId] = useState(); + const [pendingCommentMomentId, setPendingCommentMomentId] = + useState(); const [pendingStatusMomentId, setPendingStatusMomentId] = useState(); const room = cachedRoom; const hasCachedRoom = Boolean(cachedRoom); - const sortedMoments = useMemo(() => sortMoments(room?.moments ?? []), [room?.moments]); - const acceptedMomentCount = sortedMoments.filter((moment) => moment.status === 'accepted').length; - const myMemberRole = room?.members.find((member) => member.userId === currentUserId)?.role; - const canModerate = myMemberRole === 'owner'; - const canUseServerRoom = authStatus === 'authenticated'; + const sortedMoments = useMemo( + () => sortMoments(room?.moments ?? []), + [room?.moments], + ); + const acceptedMomentCount = sortedMoments.filter( + (moment) => moment.status === "accepted", + ).length; + const myMemberRole = room?.members.find( + (member) => member.userId === currentUserId, + )?.role; + const canModerate = myMemberRole === "owner"; + const canUseServerRoom = authStatus === "authenticated"; useEffect(() => { if (!canUseServerRoom || !roomId) { @@ -135,7 +161,9 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) }) .catch(() => { if (!ignore && !hasCachedRoom) { - setMessage('여행방을 불러오지 못했어요. 초대 코드나 로그인 상태를 확인해주세요.'); + setMessage( + "여행방을 불러오지 못했어요. 초대 코드나 로그인 상태를 확인해주세요.", + ); } }) .finally(() => { @@ -160,12 +188,14 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) try { const result = await shareTravelRoomInvite(room); setMessage( - result === 'copied' - ? '초대 메시지를 클립보드에 복사했어요.' - : '초대 메시지를 공유했어요.', + result === "copied" + ? "초대 메시지를 클립보드에 복사했어요." + : "초대 메시지를 공유했어요.", ); } catch { - setMessage('초대 메시지를 공유하지 못했어요. 초대 코드를 직접 전달해주세요.'); + setMessage( + "초대 메시지를 공유하지 못했어요. 초대 코드를 직접 전달해주세요.", + ); } finally { setIsSharingInvite(false); } @@ -187,7 +217,7 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) ); if (!moment) { - setMessage('로그인된 서버 세션에서 후보를 추가할 수 있어요.'); + setMessage("로그인된 서버 세션에서 후보를 추가할 수 있어요."); return; } @@ -196,9 +226,11 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) momentCount: room.momentCount + 1, moments: [moment, ...room.moments], }); - setMessage('현재 곡을 공동 Recap 후보에 추가했어요.'); + setMessage("현재 곡을 공동 Recap 후보에 추가했어요."); } catch { - setMessage('후보를 추가하지 못했어요. 현재 곡이나 네트워크 상태를 확인해주세요.'); + setMessage( + "후보를 추가하지 못했어요. 현재 곡이나 네트워크 상태를 확인해주세요.", + ); } finally { setIsAddingMoment(false); } @@ -209,7 +241,7 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) return; } - const nextStatus = moment.status === 'accepted' ? 'candidate' : 'accepted'; + const nextStatus = moment.status === "accepted" ? "candidate" : "accepted"; setPendingStatusMomentId(moment.id); setMessage(undefined); @@ -222,14 +254,20 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) ); if (!updatedMoment) { - setMessage('방장 계정으로 로그인하면 후보 채택 상태를 바꿀 수 있어요.'); + setMessage("방장 계정으로 로그인하면 후보 채택 상태를 바꿀 수 있어요."); return; } setRoomById(replaceRoomMoment(room, updatedMoment)); - setMessage(nextStatus === 'accepted' ? '후보 Moment를 채택했어요.' : '후보로 되돌렸어요.'); + setMessage( + nextStatus === "accepted" + ? "후보 리캡을 채택했어요." + : "후보로 되돌렸어요.", + ); } catch { - setMessage('후보 상태를 변경하지 못했어요. 방장 권한이나 네트워크 상태를 확인해주세요.'); + setMessage( + "후보 상태를 변경하지 못했어요. 방장 권한이나 네트워크 상태를 확인해주세요.", + ); } finally { setPendingStatusMomentId(undefined); } @@ -250,18 +288,24 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) setMessage(undefined); try { - const comment = await communityApi.addTravelRoomMomentComment(room.id, moment.id, body); + const comment = await communityApi.addTravelRoomMomentComment( + room.id, + moment.id, + body, + ); if (!comment) { - setMessage('로그인 후 후보 Moment에 댓글을 남길 수 있어요.'); + setMessage("로그인 후 후보 리캡에 댓글을 남길 수 있어요."); return; } setRoomById(appendRoomMomentComment(room, moment.id, comment)); - setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: '' })); - setMessage('후보 Moment에 댓글을 남겼어요.'); + setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: "" })); + setMessage("후보 리캡에 댓글을 남겼어요."); } catch { - setMessage('댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.'); + setMessage( + "댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.", + ); } finally { setPendingCommentMomentId(undefined); } @@ -277,22 +321,27 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) try { const representativeTrackId = - sortedMoments.find((moment) => moment.status === 'accepted' && moment.track)?.track?.id ?? - sortedMoments.find((moment) => moment.track)?.track?.id; + sortedMoments.find( + (moment) => moment.status === "accepted" && moment.track, + )?.track?.id ?? sortedMoments.find((moment) => moment.track)?.track?.id; const recap = await communityApi.createTravelRoomRecap(room.id, { representativeTrackId, - templateId: 'album', + templateId: "album", title: `${room.title} 공동 Recap`, }); if (!recap) { - setMessage('공동 Recap 생성은 로그인된 서버 세션에서 사용할 수 있어요.'); + setMessage( + "공동 Recap 생성은 로그인된 서버 세션에서 사용할 수 있어요.", + ); return; } router.push(`/recap-share/${recap.id}` as never); } catch { - setMessage('공동 Recap 생성에 실패했어요. 채택 후보나 대표 곡을 확인해주세요.'); + setMessage( + "공동 Recap 생성에 실패했어요. 채택 후보나 대표 곡을 확인해주세요.", + ); } finally { setIsCreatingRecap(false); } @@ -308,82 +357,81 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) }} showsVerticalScrollIndicator={false} > - - router.back()} - > - - - 공동 Recap - + router.back()} + /> + } + title="공동 여행방" + /> {isLoading ? ( - - 여행방을 불러오고 있어요 - - 동행자가 올린 후보와 댓글을 최신 상태로 확인하고 있어요. - - + + + + ) : !room ? ( - - 여행방을 찾지 못했어요 - - 초대 코드로 먼저 참여하거나 네트워크 상태를 확인해주세요. - + + + {message ? ( - {message} + + {message} + ) : null} - + ) : ( <> - - - + + - Travel Room - - - {room.title} - - - 초대 코드로 모인 동행자의 사진, 곡, 메모를 하나의 사운드트랙 앨범으로 정리해요. + {canModerate ? "방장" : "참여자"} - - - - {canModerate ? '방장' : '참여자'} - - - - - - - - - - - - 초대 코드 - - - {room.inviteCode} - - void handleShareInvite()} - size="compact" - variant="ghost" - /> - - + } + title={room.title} + /> + + 동행자의 사진, 곡과 메모를 모아 하나의 공동 리캡으로 정리해요. + + void handleShareInvite()} + rightText={isSharingInvite ? "공유 중" : room.inviteCode} + /> + + + {message ? ( - - {message} - + + {message} + ) : null} @@ -391,7 +439,7 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) disabled={!currentTrack || isAddingMoment} fullWidth iconName="music" - label={isAddingMoment ? '추가 중' : '현재 곡 후보 추가'} + label={isAddingMoment ? "추가 중" : "현재 곡 후보 추가"} onPress={() => void handleAddCurrentTrack()} variant="ghost" /> @@ -399,86 +447,80 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) disabled={isCreatingRecap || room.momentCount === 0} fullWidth iconName="image" - label={isCreatingRecap ? '생성 중' : 'Recap 생성'} + label={isCreatingRecap ? "생성 중" : "Recap 생성"} onPress={() => void handleCreateRecap()} /> - + - 참여자 - + + {room.members.map((member) => ( - - - - {createMemberLabel(member, currentUserId)} - - - {member.role === 'owner' ? '방장' : '참여자'} - - - {member.userId === currentUserId ? ( - - - 나 - - - ) : null} - + label={createMemberLabel(member, currentUserId)} + rightText={member.userId === currentUserId ? "나" : undefined} + /> ))} - - - Recap 후보 - - 채택된 후보가 있으면 채택 후보만 Recap에 우선 반영돼요. - - - + + + 채택된 후보가 있으면 채택 후보만 공동 리캡에 우선 반영돼요. + {sortedMoments.length === 0 ? ( - - 아직 후보가 없어요 - - 현재 곡을 먼저 올리거나 초대 코드로 동행자를 초대해보세요. - - + ) : ( sortedMoments.map((moment) => { const isPendingStatus = pendingStatusMomentId === moment.id; - const isPendingComment = pendingCommentMomentId === moment.id; - const commentDraft = commentDrafts[moment.id] ?? ''; + const isPendingComment = + pendingCommentMomentId === moment.id; + const commentDraft = commentDrafts[moment.id] ?? ""; return ( - + - - {moment.placeName ?? '장소 미정'} + + {moment.placeName ?? "장소 미정"} - + {createMomentTrackLabel(moment)} - {moment.note ? ` · ${moment.note}` : ''} + {moment.note ? ` · ${moment.note}` : ""} {createStatusLabel(moment.status)} @@ -487,7 +529,7 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) {moment.comments?.length ? ( - + {moment.comments.map((comment) => ( - setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: value })) + setCommentDrafts((drafts) => ({ + ...drafts, + [moment.id]: value, + })) } placeholder="후보에 댓글 남기기" placeholderTextColor="rgba(255,255,255,0.35)" @@ -516,30 +561,42 @@ export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) + void handleToggleMomentStatus(moment) } - onPress={() => void handleToggleMomentStatus(moment)} size="compact" - variant={moment.status === 'accepted' ? 'ghost' : 'primary'} + variant={ + moment.status === "accepted" + ? "ghost" + : "primary" + } /> ) : null} void handleSubmitComment(moment)} size="compact" variant="ghost" /> - + ); }) )} diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx index a437900..366a233 100644 --- a/src/components/travel/TravelScreen.tsx +++ b/src/components/travel/TravelScreen.tsx @@ -1,13 +1,16 @@ import { useQueryClient } from '@tanstack/react-query'; -import { router } from 'expo-router'; +import { router, useLocalSearchParams } from 'expo-router'; import { Image } from 'expo-image'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, ScrollView, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ApiError } from '@/api/client'; import { momentLogApi } from '@/api/momentLogApi'; -import { momentLogQueryKeys, useMomentLogListQuery } from '@/api/momentLogQueries'; +import { + momentLogQueryKeys, + useMomentLogListQuery, +} from '@/api/momentLogQueries'; import { recapApi } from '@/api/recapApi'; import { recapQueryKeys } from '@/api/recapQueries'; import { travelSessionApi } from '@/api/travelSessionApi'; @@ -16,6 +19,10 @@ import { RecapListCard } from '@/components/recap/RecapListCard'; import { Screen } from '@/components/Screen'; import { AppText } from '@/components/AppText'; import { getHomeContentBottomPadding } from '@/constants/layout'; +import { + createRoutePoint, + useTravelRouteTracking, +} from '@/hooks/useTravelRouteTracking'; import { useAuthStore } from '@/store/authStore'; import { useMomentLogStore, @@ -25,12 +32,14 @@ import { } from '@/store/momentLogStore'; import { usePlayerStore } from '@/store/playerStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; +import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; import type { MomentLog, MoodTag, Track, TravelMode } from '@/types/domain'; +import { flushPendingTravelLogFinalizations } from '@/utils/travelLogSync'; import { CommunityRecapCard } from './CommunityRecapCard'; import { EndTravelConfirmModal } from './EndTravelConfirmModal'; -import { LiveSoundMapSection } from './live-sound-map'; import { MomentCard } from './MomentCard'; +import { RecapMapSection } from './recap-map'; import { TravelModeBottomSheet } from './TravelModeBottomSheet'; import { TravelStatusCard } from './TravelStatusCard'; import { formatKoreanDateTime } from './travelFormat'; @@ -55,7 +64,11 @@ type MomentLogEditDraft = { track?: Track; }; -const moodOptions = Object.entries(moodLabelByValue) as Array<[MoodTag, string]>; +const moodOptions = Object.entries(moodLabelByValue) as Array< + [MoodTag, string] +>; +const LIVE_SOUND_MAP_FOCUS = 'sound-map'; +const LIVE_SOUND_MAP_SCROLL_OFFSET = 16; function momentLogPatchFromPayload( moment: MomentLog, @@ -64,13 +77,17 @@ function momentLogPatchFromPayload( return { moodTags: payload.moodTags, note: payload.note ?? undefined, - photoUri: payload.removePhoto ? undefined : payload.replacePhotoUri ?? moment.photoUri, + photoUri: payload.removePhoto + ? undefined + : (payload.replacePhotoUri ?? moment.photoUri), placeName: payload.placeName ?? undefined, track: payload.track, }; } -function momentLogCreatePayloadFromLog(log: MomentLog): MomentLogCreateQueuePayload { +function momentLogCreatePayloadFromLog( + log: MomentLog, +): MomentLogCreateQueuePayload { return { createdAt: log.createdAt, location: log.location, @@ -80,7 +97,9 @@ function momentLogCreatePayloadFromLog(log: MomentLog): MomentLogCreateQueuePayl placeCategory: log.placeCategory, placeId: log.placeId, placeName: log.placeName, + recapVisibility: log.recapVisibility, sessionId: log.sessionId, + templateId: log.templateId, track: log.track, travelMode: log.travelMode, }; @@ -92,7 +111,10 @@ type TravelLogSummaryCardProps = { momentCount: number; onCreateRecap: () => void; onDeleteMoment: (moment: MomentLog) => Promise | void; - onEditMoment: (moment: MomentLog, draft: MomentLogEditDraft) => Promise | void; + onEditMoment: ( + moment: MomentLog, + draft: MomentLogEditDraft, + ) => Promise | void; onOpenMoment: (moment: MomentLog) => void; pendingMomentActionId?: string; sessionStatus: 'active' | 'ended' | 'idle'; @@ -144,10 +166,10 @@ function TravelLogSummaryCard({ const title = sessionStatus === 'idle' ? '최근 여행 로그' : '이번 여행 로그'; const buttonLabel = momentCount === 0 - ? '첫 Moment 남기기' + ? '첫 리캡 남기기' : sessionStatus === 'active' - ? 'Recap 만들기' - : 'Recap 보기'; + ? '로그 만들기' + : '로그 보기'; const resetEditDraft = () => { setEditingMomentId(undefined); setEditMoodDraft([]); @@ -164,17 +186,21 @@ function TravelLogSummaryCard({ if (result.status === 'selected') { setEditPhotoUriDraft(result.uri); - setEditPhotoMessage('새 사진을 선택했어요. 저장하면 Moment에 반영돼요.'); + setEditPhotoMessage('새 사진을 선택했어요. 저장하면 리캡에 반영돼요.'); return; } if (result.status === 'permission-denied') { - setEditPhotoMessage('사진을 교체하려면 사진 보관함 접근 권한이 필요해요.'); + setEditPhotoMessage( + '사진을 교체하려면 사진 보관함 접근 권한이 필요해요.', + ); return; } if (result.status === 'unavailable') { - setEditPhotoMessage('사진을 불러오지 못했어요. 잠시 후 다시 시도해주세요.'); + setEditPhotoMessage( + '사진을 불러오지 못했어요. 잠시 후 다시 시도해주세요.', + ); } }; @@ -186,7 +212,7 @@ function TravelLogSummaryCard({ {title} - {momentCount}개 순간 · {trackCount}곡 + 리캡 {momentCount}개 · {trackCount}곡 @@ -200,7 +226,8 @@ function TravelLogSummaryCard({ {logs.length === 0 ? ( - 아직 저장한 순간이 없어요. 지금 장소의 곡을 하나 고르고 사진이나 메모로 남겨보세요. + 아직 저장한 리캡이 없어요. 지금 장소의 곡을 하나 고르고 사진이나 + 메모로 남겨보세요. ) : ( @@ -211,7 +238,7 @@ function TravelLogSummaryCard({ ? `${log.track.title} - ${log.track.artist}` : '음악 없음'; const metaLabel = `${log.photoUri ? '사진' : '사진 없음'} / ${trackLabel} / ${getMomentMoodLabel(log)}`; - const actionLabelPrefix = log.placeName ?? '저장한 순간'; + const actionLabelPrefix = log.placeName ?? '저장한 리캡'; const draftTrackLabel = editTrackDraft ? `${editTrackDraft.title} - ${editTrackDraft.artist}` : '음악 없음'; @@ -227,10 +254,17 @@ function TravelLogSummaryCard({ onPress={() => onOpenMoment(log)} > - - {formatMomentTime(log.createdAt)} {log.placeName ?? '위치 없음'} + + {formatMomentTime(log.createdAt)}{' '} + {log.placeName ?? '위치 없음'} - + {metaLabel} {log.note ? ` · ${log.note}` : ''} @@ -337,7 +371,9 @@ function TravelLogSummaryCard({ disabled={isActionPending} onPress={() => { setEditPhotoUriDraft(undefined); - setEditPhotoMessage('저장하면 Moment 사진이 제거돼요.'); + setEditPhotoMessage( + '저장하면 리캡 사진이 제거돼요.', + ); }} > @@ -351,7 +387,10 @@ function TravelLogSummaryCard({ 연결된 곡 - + {draftTrackLabel} {currentTrack ? ( @@ -373,7 +412,7 @@ function TravelLogSummaryCard({ - 취소 + + 취소 + ) : ( - 수정 + + 수정 + (); + const focusTarget = Array.isArray(params.focus) + ? params.focus[0] + : params.focus; + const focusAt = Array.isArray(params.focusAt) + ? params.focusAt[0] + : params.focusAt; + const scrollRef = useRef(null); const [isModeSheetVisible, setIsModeSheetVisible] = useState(false); const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false); const [isStartingTravel, setIsStartingTravel] = useState(false); @@ -472,6 +530,7 @@ export function TravelScreen() { const [isSyncingMomentUploads, setIsSyncingMomentUploads] = useState(false); const [isSyncingPendingActions, setIsSyncingPendingActions] = useState(false); const [recapMessage, setRecapMessage] = useState(); + const [soundMapSectionY, setSoundMapSectionY] = useState(); const [clockTick, setClockTick] = useState(0); const authStatus = useAuthStore((state) => state.status); const { @@ -513,7 +572,10 @@ export function TravelScreen() { return new Set( pendingCreateActions - .filter((action) => now - new Date(action.queuedAt).getTime() > staleThresholdMs) + .filter( + (action) => + now - new Date(action.queuedAt).getTime() > staleThresholdMs, + ) .map((action) => action.momentLogId), ); }, [clockTick, pendingCreateActions]); @@ -555,14 +617,17 @@ export function TravelScreen() { (log) => log.syncStatus === 'failed' || log.syncStatus === 'local' || - (log.syncStatus === 'pending' && stalePendingCreateMomentIds.has(log.id)), + (log.syncStatus === 'pending' && + stalePendingCreateMomentIds.has(log.id)), ), [stalePendingCreateMomentIds, travelLogMoments], ); const pendingMomentUploadCount = useMemo( () => travelLogMoments.filter( - (log) => log.syncStatus === 'pending' && !stalePendingCreateMomentIds.has(log.id), + (log) => + log.syncStatus === 'pending' && + !stalePendingCreateMomentIds.has(log.id), ).length, [stalePendingCreateMomentIds, travelLogMoments], ); @@ -622,32 +687,65 @@ export function TravelScreen() { setIsStartingTravel(true); try { + const startLocation = currentLocation ?? currentPlace?.location; + const startedAt = new Date().toISOString(); + const initialRoutePoints = startLocation + ? [createRoutePoint(startLocation, new Date(startedAt))] + : undefined; const serverSession = await travelSessionApi.createTravelSession({ - location: currentLocation ?? currentPlace?.location, + location: startLocation, + routePoints: initialRoutePoints, + startedAt, travelMode: nextMode, }); startSession({ id: serverSession?.id, - startedAt: serverSession?.startedAt, + routePoints: serverSession?.routePoints ?? initialRoutePoints, + startedAt: serverSession?.startedAt ?? startedAt, }); } catch { - startSession(); - setRecapMessage('서버 여행 세션 연결에 실패해서 로컬 세션으로 먼저 시작했어요.'); + const startLocation = currentLocation ?? currentPlace?.location; + const startedAt = new Date().toISOString(); + + startSession({ + routePoints: startLocation + ? [createRoutePoint(startLocation, new Date(startedAt))] + : undefined, + startedAt, + }); + setRecapMessage( + '서버 여행 세션 연결에 실패해서 로컬 세션으로 먼저 시작했어요.', + ); } finally { setIsStartingTravel(false); setIsModeSheetVisible(false); } }; + const handleSubmitTravelMode = async () => { + if (session.status === 'active') { + setIsModeSheetVisible(false); + setRecapMessage( + '현재 여행 상태를 수정했어요. 다음 추천과 리캡에 반영돼요.', + ); + return; + } + + await handleStartTravel(); + }; const retryMomentLog = async (log: MomentLog) => { - if (log.syncStatus === 'pending' && !stalePendingCreateMomentIds.has(log.id)) { + if ( + log.syncStatus === 'pending' && + !stalePendingCreateMomentIds.has(log.id) + ) { return false; } const queuedCreateAction = pendingCreateActions.find( (action) => action.momentLogId === log.id, ); - const createPayload = queuedCreateAction?.payload ?? momentLogCreatePayloadFromLog(log); + const createPayload = + queuedCreateAction?.payload ?? momentLogCreatePayloadFromLog(log); queueCreate(log.id, createPayload); updateLog(log.id, { syncStatus: 'pending' }); @@ -691,11 +789,11 @@ export function TravelScreen() { } await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); setRecapMessage( failureCount === 0 - ? '대기 중인 Moment 업로드를 모두 동기화했어요.' - : `Moment ${failureCount}개는 아직 서버에 올리지 못했어요.`, + ? '대기 중인 리캡 업로드를 모두 동기화했어요.' + : `리캡 ${failureCount}개는 아직 서버에 올리지 못했어요.`, ); } finally { setIsSyncingMomentUploads(false); @@ -715,50 +813,41 @@ export function TravelScreen() { endSession(); setIsEndConfirmVisible(false); - try { - await travelSessionApi.endTravelSession(endedSessionId, { - endedAt, - location: currentLocation ?? currentPlace?.location, - }); - } catch { - setRecapMessage('서버 여행 세션 종료 동기화에 실패했지만 로컬 Recap은 계속 만들 수 있어요.'); - } - if (logsForSession.length === 0) { setSessionRecapId(undefined); - setRecapMessage('저장한 Moment가 없어 Recap 대신 빈 기록 화면으로 이동할 수 있어요.'); - return; - } - - const syncedLogs = logsForSession.filter((log) => log.syncStatus === 'synced'); - const hasOnlySyncedLogs = syncedLogs.length === logsForSession.length; - const representativeTrackId = logsForSession.find((log) => log.track?.id)?.track?.id; - - if (!hasOnlySyncedLogs) { - setSessionRecapId(localRecapId); - setRecapMessage('아직 동기화되지 않은 Moment가 있어 로컬 Recap으로 먼저 만들었어요.'); - router.push(`/recap-share/${localRecapId}`); + setRecapMessage('저장한 리캡이 없어 빈 로그 화면으로 이동할 수 있어요.'); return; } setIsCreatingRecap(true); try { - const serverRecap = await recapApi.createRecap({ - momentLogIds: syncedLogs.map((log) => log.id), - representativeTrackId, + useTravelLogSyncStore.getState().queueFinalization({ + endedAt, + location: currentLocation ?? currentPlace?.location, + routePoints: session.routePoints, sessionId: endedSessionId, - templateId: 'lp', - title: `${logsForSession[0]?.placeName ?? '여행'} Recap`, + templateId: 'album', + title: `${logsForSession[0]?.placeName ?? '여행'} 로그`, }); - const recapId = serverRecap?.id ?? localRecapId; + const syncResult = await flushPendingTravelLogFinalizations(); + const recapId = syncResult.createdRecapIds[endedSessionId] ?? localRecapId; setSessionRecapId(recapId); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); + + if (recapId === localRecapId) { + setRecapMessage( + '서버 동기화가 끝나면 여행 로그가 자동으로 완성돼요. 지금은 기기 기록을 보여드릴게요.', + ); + } + router.push(`/recap-share/${recapId}`); } catch { setSessionRecapId(localRecapId); - setRecapMessage('서버 Recap 생성이 실패해서 로컬 Recap으로 먼저 보여드릴게요.'); + setRecapMessage( + '서버 로그 생성이 실패해서 로컬 로그로 먼저 보여드릴게요.', + ); router.push(`/recap-share/${localRecapId}`); } finally { setIsCreatingRecap(false); @@ -802,33 +891,42 @@ export function TravelScreen() { let deletedOnServer = false; if (moment.syncStatus === 'synced') { - deletedOnServer = Boolean(await momentLogApi.deleteMomentLog(moment.id)); + deletedOnServer = Boolean( + await momentLogApi.deleteMomentLog(moment.id), + ); if (!deletedOnServer) { - throw new Error('Moment delete was not accepted by the server.'); + throw new Error( + 'Recap capture delete was not accepted by the server.', + ); } } removeLog(moment.id); await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); setRecapMessage( deletedOnServer - ? '서버와 여행 로그에서 Moment를 삭제했어요.' - : '로컬 여행 로그에서 Moment를 삭제했어요.', + ? '서버와 여행 로그에서 리캡을 삭제했어요.' + : '로컬 여행 로그에서 리캡을 삭제했어요.', ); } catch { if (moment.syncStatus === 'synced') { queueDelete(moment); - setRecapMessage('서버 Moment 삭제에 실패해서 동기화 대기열에 저장했어요.'); + setRecapMessage( + '서버 리캡 삭제에 실패해서 동기화 대기열에 저장했어요.', + ); } else { - setRecapMessage('Moment 삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); + setRecapMessage('리캡 삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); } } finally { setPendingMomentActionId(undefined); } }; - const handleEditMoment = async (moment: MomentLog, draft: MomentLogEditDraft) => { + const handleEditMoment = async ( + moment: MomentLog, + draft: MomentLogEditDraft, + ) => { if (pendingMomentActionId) { return; } @@ -850,7 +948,10 @@ export function TravelScreen() { if (draft.removePhoto) { await momentLogApi.deleteMomentLogPhoto(moment.id); } else if (draft.replacePhotoUri) { - await momentLogApi.updateMomentLogPhoto(moment.id, draft.replacePhotoUri); + await momentLogApi.updateMomentLogPhoto( + moment.id, + draft.replacePhotoUri, + ); } const serverLog = await momentLogApi.updateMomentLog(moment.id, { @@ -863,23 +964,28 @@ export function TravelScreen() { if (serverLog) { updateLog(moment.id, serverLog); } else { - throw new Error('Moment edit was not accepted by the server.'); + throw new Error('Recap capture edit was not accepted by the server.'); } } else { updateLog(moment.id, nextPatch); - queueCreate(moment.id, momentLogCreatePayloadFromLog({ ...moment, ...nextPatch })); + queueCreate( + moment.id, + momentLogCreatePayloadFromLog({ ...moment, ...nextPatch }), + ); } await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); - setRecapMessage('Moment 정보를 수정했어요.'); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); + setRecapMessage('리캡 정보를 수정했어요.'); } catch { if (moment.syncStatus === 'synced') { updateLog(moment.id, nextPatch); queueEdit(moment.id, editPayload); - setRecapMessage('서버 Moment 수정에 실패해서 동기화 대기열에 저장했어요.'); + setRecapMessage( + '서버 리캡 수정에 실패해서 동기화 대기열에 저장했어요.', + ); } else { - setRecapMessage('Moment 수정에 실패했어요. 잠시 후 다시 시도해주세요.'); + setRecapMessage('리캡 수정에 실패했어요. 잠시 후 다시 시도해주세요.'); } } finally { setPendingMomentActionId(undefined); @@ -887,7 +993,9 @@ export function TravelScreen() { }; const syncPendingMomentAction = async (action: MomentLogPendingAction) => { if (action.type === 'create') { - const localMoment = momentLogs.find((moment) => moment.id === action.momentLogId); + const localMoment = momentLogs.find( + (moment) => moment.id === action.momentLogId, + ); if (!localMoment) { removePendingAction(action.id); @@ -903,7 +1011,9 @@ export function TravelScreen() { if (!serverLog) { updateLog(action.momentLogId, { syncStatus: 'local' }); - throw new Error('Queued Moment create was not accepted by the server.'); + throw new Error( + 'Queued recap capture create was not accepted by the server.', + ); } resolveLocalLog(action.momentLogId, serverLog); @@ -915,7 +1025,9 @@ export function TravelScreen() { const accepted = await momentLogApi.deleteMomentLog(action.momentLogId); if (!accepted) { - throw new Error('Queued Moment delete was not accepted by the server.'); + throw new Error( + 'Queued recap capture delete was not accepted by the server.', + ); } } catch (error) { if (error instanceof ApiError && error.status === 404) { @@ -930,7 +1042,9 @@ export function TravelScreen() { return; } - const localMoment = momentLogs.find((moment) => moment.id === action.momentLogId); + const localMoment = momentLogs.find( + (moment) => moment.id === action.momentLogId, + ); if (!localMoment) { removePendingAction(action.id); @@ -938,10 +1052,14 @@ export function TravelScreen() { } if (action.payload.removePhoto) { - const photoDeletedLog = await momentLogApi.deleteMomentLogPhoto(action.momentLogId); + const photoDeletedLog = await momentLogApi.deleteMomentLogPhoto( + action.momentLogId, + ); if (!photoDeletedLog) { - throw new Error('Queued Moment photo delete was not accepted by the server.'); + throw new Error( + 'Queued recap capture photo delete was not accepted by the server.', + ); } } else if (action.payload.replacePhotoUri) { const photoUpdatedLog = await momentLogApi.updateMomentLogPhoto( @@ -950,7 +1068,9 @@ export function TravelScreen() { ); if (!photoUpdatedLog) { - throw new Error('Queued Moment photo update was not accepted by the server.'); + throw new Error( + 'Queued recap capture photo update was not accepted by the server.', + ); } } @@ -962,7 +1082,9 @@ export function TravelScreen() { }); if (!serverLog) { - throw new Error('Queued Moment edit was not accepted by the server.'); + throw new Error( + 'Queued recap capture edit was not accepted by the server.', + ); } updateLog(action.momentLogId, serverLog); @@ -987,11 +1109,11 @@ export function TravelScreen() { } await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); setRecapMessage( failureCount === 0 - ? '대기 중인 Moment 변경을 모두 동기화했어요.' - : `Moment 변경 ${failureCount}개는 아직 동기화하지 못했어요.`, + ? '대기 중인 리캡 변경을 모두 동기화했어요.' + : `리캡 변경 ${failureCount}개는 아직 동기화하지 못했어요.`, ); } finally { setIsSyncingPendingActions(false); @@ -999,27 +1121,49 @@ export function TravelScreen() { }; const handleCommunityRecapCreated = async (recap: { id: string }) => { setSessionRecapId(recap.id); - await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); router.push(`/recap-share/${recap.id}`); }; + useEffect(() => { + if ( + focusTarget !== LIVE_SOUND_MAP_FOCUS || + soundMapSectionY === undefined + ) { + return; + } + + const timeoutId = setTimeout(() => { + scrollRef.current?.scrollTo({ + animated: true, + y: Math.max(0, soundMapSectionY - LIVE_SOUND_MAP_SCROLL_OFFSET), + }); + }, 80); + + return () => clearTimeout(timeoutId); + }, [focusAt, focusTarget, soundMapSectionY]); + return ( - 음악으로 기록하는 당신의 여정 + 장소에 남기는 사운드 로그 - Travel + 여행모드 @@ -1030,6 +1174,7 @@ export function TravelScreen() { isCreatingRecap={isCreatingRecap} momentCount={momentCount} onEndTravel={() => setIsEndConfirmVisible(true)} + onEditTravelState={openModeSheet} onOpenRecap={openCurrentRecap} onSaveMoment={() => router.push('/camera')} onStartTravel={openModeSheet} @@ -1041,21 +1186,26 @@ export function TravelScreen() { {recapMessage ? ( - {recapMessage} + + {recapMessage} + ) : null} {authStatus === 'authenticated' && isMomentLogSyncError ? ( - 서버 여행 로그를 불러오지 못했어요. 로컬에 저장된 Moment를 먼저 보여드릴게요. + 서버 여행 로그를 불러오지 못했어요. 로컬에 저장된 리캡을 먼저 + 보여드릴게요. void refetchMomentLogs()} > - 다시 동기화 + + 다시 동기화 + ) : authStatus === 'authenticated' && isMomentLogSyncing ? ( @@ -1070,8 +1220,8 @@ export function TravelScreen() { {pendingMomentUploadCount > 0 - ? `Moment ${pendingMomentUploadCount}개를 서버에 올리는 중이에요.` - : `서버에 아직 올라가지 않은 Moment ${unsyncedMomentUploads.length}개가 있어요.`} + ? `리캡 ${pendingMomentUploadCount}개를 서버에 올리는 중이에요.` + : `서버에 아직 올라가지 않은 리캡 ${unsyncedMomentUploads.length}개가 있어요.`} {unsyncedMomentUploads.length > 0 ? ( authStatus === 'authenticated' ? ( @@ -1087,7 +1237,7 @@ export function TravelScreen() { ) : ( - 로그인하면 로컬에 남긴 Moment를 서버에 동기화할 수 있어요. + 로그인하면 로컬에 남긴 리캡을 서버에 동기화할 수 있어요. ) ) : null} @@ -1097,7 +1247,8 @@ export function TravelScreen() { {pendingActionCount > 0 ? ( - 서버에 아직 반영되지 않은 Moment 변경 {pendingActionCount}개가 있어요. + 서버에 아직 반영되지 않은 리캡 변경 {pendingActionCount}개가 + 있어요. - + setSoundMapSectionY(event.nativeEvent.layout.y)} + > + router.push('/camera')} + onOpenRecap={(recapId) => router.push(`/recap-share/${recapId}`)} + onStartTravel={openModeSheet} + sessionStatus={session.status} + /> + - 최근 Moment - 여행 중 직접 저장한 순간 + + 최근 리캡 + + + 여행 중 직접 저장한 리캡 + - router.push('/library')}> - 더보기 + router.push('/library')} + > + + 더보기 + @@ -1159,7 +1323,8 @@ export function TravelScreen() { {moments.length === 0 ? ( - 아직 저장한 Moment가 없어요. 여행 중 카메라 버튼으로 첫 순간을 남겨보세요. + 아직 저장한 리캡이 없어요. 여행 중 카메라 버튼으로 첫 리캡을 + 남겨보세요. ) : ( @@ -1178,9 +1343,11 @@ export function TravelScreen() { - Travel Log + + 여행 로그 + - 여행별 음악과 Moment 요약 + 여행별 리캡과 음악 요약 @@ -1189,7 +1356,7 @@ export function TravelScreen() { {localRecaps.length === 0 ? ( - 여행이 끝나면 저장한 Moment가 Recap으로 묶여요. + 여행이 끝나면 저장한 리캡들이 하나의 로그로 묶여요. ) : ( @@ -1211,8 +1378,11 @@ export function TravelScreen() { setIsModeSheetVisible(false)} onSelectMode={handleSelectMode} - onStart={() => void handleStartTravel()} + onStart={() => void handleSubmitTravelMode()} selectedMode={selectedMode} + submitLabel={ + session.status === 'active' ? '여행 상태 저장' : '여행 시작' + } visible={isModeSheetVisible} /> void; + onEditTravelState?: () => void; onOpenRecap: () => void; onSaveMoment: () => void; onStartTravel: () => void; @@ -54,6 +55,7 @@ export function TravelStatusCard({ isCreatingRecap = false, momentCount, onEndTravel, + onEditTravelState, onOpenRecap, onSaveMoment, onStartTravel, @@ -77,11 +79,17 @@ export function TravelStatusCard({ 여행 진행 중 - + - {modeIcon} {modeLabel} + {modeIcon} {modeLabel} {onEditTravelState ? '수정' : ''} - + @@ -93,7 +101,7 @@ export function TravelStatusCard({ - + @@ -119,7 +127,7 @@ export function TravelStatusCard({ className="h-11 flex-1 items-center justify-center rounded-full border border-white/15 bg-white/10" onPress={onSaveMoment} > - 순간 저장 + 리캡 저장 @@ -131,7 +139,7 @@ export function TravelStatusCard({ 여행이 종료됐어요 - 저장한 순간은 Recap에서 다시 확인할 수 있어요. + 저장한 리캡들은 여행 로그에서 다시 확인할 수 있어요. {formatShortEndedAt(endedAt)} @@ -140,7 +148,7 @@ export function TravelStatusCard({ - + @@ -153,7 +161,7 @@ export function TravelStatusCard({ style={{ opacity: isCreatingRecap || momentCount === 0 ? 0.62 : 1 }} > - {isCreatingRecap ? 'Recap 생성 중' : 'Recap 보기'} + {isCreatingRecap ? '로그 생성 중' : '로그 보기'} - 현재 위치에 맞는 음악을 추천받고, 여행 중 남긴 순간을 Recap으로 기록할 수 있어요. + 현재 위치에 맞는 음악을 추천받고, 여행 중 남긴 리캡들을 하나의 로그로 기록할 수 있어요. - OpenLayers 지도를 Soundlog 다크 테마로 조정하고, 여행 모드 중인 내 음악과 - 동행자/주변 익명 핀을 함께 보여줘요. + 여행 모드 중인 내 음악과 동행자/주변 익명 핀을 실제 지도 위에서 함께 + 확인할 수 있어요. - = { - 'companion-su': { left: '62%', top: '28%' }, - me: { left: '36%', top: '43%' }, - 'nearby-night': { left: '22%', top: '31%' }, - 'nearby-rainy': { left: '52%', top: '66%' }, -}; - -const fallbackPinPositions: Array<{ left: `${number}%`; top: `${number}%` }> = [ - { left: '36%', top: '43%' }, - { left: '62%', top: '28%' }, - { left: '52%', top: '66%' }, - { left: '22%', top: '31%' }, - { left: '70%', top: '58%' }, -]; - -const kindClassName = { - companion: 'border-soundlog-blue bg-[#132244]', - me: 'border-soundlog-lime bg-[#1E2B16]', - nearby: 'border-soundlog-warning bg-[#2A1A15]', -} as const; - -function getPinPosition(pinId: string, index: number) { - return pinPositions[pinId] ?? fallbackPinPositions[index % fallbackPinPositions.length]; -} - -export function OpenLayersSoundMap({ pins, sessionStatus, visibility }: SoundMapViewport) { - const isLive = sessionStatus === 'active' && visibility !== 'private'; - - return ( - - - - - - - - - - {isLive ? 'Live preview' : 'Map preview'} - - - - {pins.map((pin, index) => { - const position = getPinPosition(pin.id, index); - - return ( - - - {pin.label} · {pin.trackTitle} - - - {pin.subtitle} - - - ); - })} - - ); -} diff --git a/src/components/travel/live-sound-map/OpenLayersSoundMap.web.tsx b/src/components/travel/live-sound-map/OpenLayersSoundMap.web.tsx deleted file mode 100644 index 529c7d9..0000000 --- a/src/components/travel/live-sound-map/OpenLayersSoundMap.web.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useEffect, useMemo, useRef } from 'react'; -import { View } from 'react-native'; -import Feature from 'ol/Feature'; -import OlMap from 'ol/Map'; -import OlView from 'ol/View'; -import Point from 'ol/geom/Point'; -import TileLayer from 'ol/layer/Tile'; -import VectorLayer from 'ol/layer/Vector'; -import { fromLonLat } from 'ol/proj'; -import OSM from 'ol/source/OSM'; -import VectorSource from 'ol/source/Vector'; -import { Circle as CircleStyle, Fill, Stroke, Style, Text } from 'ol/style'; - -import type { SoundMapPin, SoundMapPinKind, SoundMapViewport } from './types'; - -const pinColors: Record = { - companion: { - fill: '#132244', - stroke: '#6EA8FF', - text: '#E4E8FF', - }, - me: { - fill: '#B7E628', - stroke: '#090515', - text: '#090515', - }, - nearby: { - fill: '#2A1A15', - stroke: '#FF8A3D', - text: '#FFD0B0', - }, -}; - -const openLayersThemeCss = ` - .soundlog-openlayers-map, - .soundlog-openlayers-map .ol-viewport { - border-radius: 22px; - overflow: hidden; - background: #10172A; - } - - .soundlog-openlayers-map .ol-layer canvas { - filter: invert(1) hue-rotate(180deg) saturate(0.48) brightness(0.58) contrast(1.18); - } - - .soundlog-openlayers-map .ol-control, - .soundlog-openlayers-map .ol-attribution { - display: none; - } - - .soundlog-map-attribution { - position: absolute; - right: 10px; - bottom: 8px; - padding: 3px 6px; - border-radius: 999px; - background: rgba(8,13,24,0.72); - color: rgba(255,255,255,0.62); - font: 600 10px -apple-system, BlinkMacSystemFont, Apple SD Gothic Neo, sans-serif; - pointer-events: none; - } -`; - -function toMapCenter(location: { lat: number; lng: number }) { - return fromLonLat([location.lng, location.lat]); -} - -function createPinStyle(pin: SoundMapPin) { - const colors = pinColors[pin.kind]; - const label = pin.kind === 'nearby' && pin.matchScore ? `${pin.matchScore}%` : pin.label; - const isMe = pin.kind === 'me'; - - return new Style({ - image: new CircleStyle({ - fill: new Fill({ color: colors.fill }), - radius: pin.kind === 'me' ? 13 : 11, - stroke: new Stroke({ - color: colors.stroke, - width: pin.kind === 'me' ? 4 : 3, - }), - }), - text: new Text({ - backgroundFill: new Fill({ color: isMe ? '#B7E628' : 'rgba(8,13,24,0.9)' }), - backgroundStroke: new Stroke({ color: colors.stroke, width: 1 }), - fill: new Fill({ color: colors.text }), - font: '700 12px -apple-system, BlinkMacSystemFont, Apple SD Gothic Neo, sans-serif', - offsetY: -30, - padding: [6, 9, 6, 9], - text: `${label} · ${pin.trackTitle}`, - }), - }); -} - -function createFeature(pin: SoundMapPin) { - const feature = new Feature({ - geometry: new Point(toMapCenter(pin.location)), - soundlogPinId: pin.id, - }); - - feature.setStyle(createPinStyle(pin)); - - return feature; -} - -export function OpenLayersSoundMap({ center, pins }: SoundMapViewport) { - const mapElementRef = useRef(null); - const mapRef = useRef(null); - const vectorSourceRef = useRef(null); - const initialCenter = useMemo(() => toMapCenter(center), [center]); - - useEffect(() => { - if (!mapElementRef.current || mapRef.current) { - return; - } - - const vectorSource = new VectorSource(); - const vectorLayer = new VectorLayer({ - source: vectorSource, - zIndex: 2, - }); - const tileLayer = new TileLayer({ - source: new OSM({ - crossOrigin: 'anonymous', - }), - zIndex: 1, - }); - - vectorSourceRef.current = vectorSource; - mapRef.current = new OlMap({ - controls: [], - layers: [tileLayer, vectorLayer], - target: mapElementRef.current, - view: new OlView({ - center: initialCenter, - maxZoom: 17, - minZoom: 11, - zoom: 14, - }), - }); - - return () => { - mapRef.current?.setTarget(undefined); - mapRef.current = null; - vectorSourceRef.current = null; - }; - }, [initialCenter]); - - useEffect(() => { - const vectorSource = vectorSourceRef.current; - const map = mapRef.current; - - if (!vectorSource || !map) { - return; - } - - vectorSource.clear(); - vectorSource.addFeatures(pins.map(createFeature)); - map.getView().animate({ - center: toMapCenter(center), - duration: 220, - zoom: pins.length > 2 ? 13 : 14, - }); - }, [center, pins]); - - return ( - - -
-
© OpenStreetMap contributors
- - ); -} diff --git a/src/components/travel/live-sound-map/SoundMapView.tsx b/src/components/travel/live-sound-map/SoundMapView.tsx new file mode 100644 index 0000000..910e6e6 --- /dev/null +++ b/src/components/travel/live-sound-map/SoundMapView.tsx @@ -0,0 +1,721 @@ +import { Feather } from '@expo/vector-icons'; +import type { ComponentProps } from 'react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react'; +import { Platform, StyleSheet, View } from 'react-native'; +import MapView, { + Callout, + Marker, + type MapStyleElement, + type Region, +} from 'react-native-maps'; + +import { AppText } from '@/components/AppText'; + +import type { + SoundMapPin, + SoundMapPinKind, + SoundMapViewHandle, + SoundMapViewport, +} from './types'; + +type FeatherIconName = ComponentProps['name']; + +const DEFAULT_LATITUDE_DELTA = 0.018; +const DEFAULT_LONGITUDE_DELTA = 0.018; +const MIN_OVERVIEW_DELTA = 0.16; +const OVERVIEW_PADDING_RATIO = 1.35; + +const markerIconByKind: Record = { + cluster: 'layers', + companion: 'users', + me: 'music', + nearby: 'radio', + place: 'map-pin', +}; + +const markerPalette: Record< + SoundMapPinKind, + { + background: string; + border: string; + icon: string; + labelBackground: string; + labelText: string; + } +> = { + cluster: { + background: '#251A4A', + border: '#8B72FF', + icon: '#F2EEFF', + labelBackground: 'rgba(139,114,255,0.22)', + labelText: '#F2EEFF', + }, + companion: { + background: '#132244', + border: '#6EA8FF', + icon: '#DDE8FF', + labelBackground: 'rgba(110,168,255,0.18)', + labelText: '#DDE8FF', + }, + me: { + background: '#B7E628', + border: '#090515', + icon: '#090515', + labelBackground: 'rgba(9,5,21,0.18)', + labelText: '#090515', + }, + nearby: { + background: '#2A1A15', + border: '#FF8A3D', + icon: '#FFD0B0', + labelBackground: 'rgba(255,138,61,0.18)', + labelText: '#FFD0B0', + }, + place: { + background: '#14271A', + border: '#B7E628', + icon: '#DDFE73', + labelBackground: 'rgba(183,230,40,0.18)', + labelText: '#E8FF9D', + }, +}; + +export const googleDarkMapStyle: MapStyleElement[] = [ + { + elementType: 'geometry', + stylers: [{ color: '#10172A' }], + }, + { + elementType: 'labels.text.fill', + stylers: [{ color: '#B8C0D8' }], + }, + { + elementType: 'labels.text.stroke', + stylers: [{ color: '#0B1020' }], + }, + { + featureType: 'administrative', + elementType: 'geometry', + stylers: [{ color: '#2B3550' }], + }, + { + featureType: 'poi', + elementType: 'geometry', + stylers: [{ color: '#151F35' }], + }, + { + featureType: 'poi.park', + elementType: 'geometry', + stylers: [{ color: '#163020' }], + }, + { + featureType: 'road', + elementType: 'geometry', + stylers: [{ color: '#273147' }], + }, + { + featureType: 'road', + elementType: 'geometry.stroke', + stylers: [{ color: '#111827' }], + }, + { + featureType: 'road.highway', + elementType: 'geometry', + stylers: [{ color: '#38435C' }], + }, + { + featureType: 'transit', + elementType: 'geometry', + stylers: [{ color: '#1B2940' }], + }, + { + featureType: 'water', + elementType: 'geometry', + stylers: [{ color: '#0A2238' }], + }, +]; + +function createRegion(center: SoundMapViewport['center']): Region { + return { + latitude: center.lat, + latitudeDelta: DEFAULT_LATITUDE_DELTA, + longitude: center.lng, + longitudeDelta: DEFAULT_LONGITUDE_DELTA, + }; +} + +function normalizeLongitude(longitude: number) { + if (longitude > 180) { + return longitude - 360; + } + + if (longitude < -180) { + return longitude + 360; + } + + return longitude; +} + +function createOverviewRegion( + center: SoundMapViewport['center'], + pins: SoundMapPin[], +): Region { + if (pins.length === 0) { + return { + latitude: center.lat, + latitudeDelta: MIN_OVERVIEW_DELTA, + longitude: center.lng, + longitudeDelta: MIN_OVERVIEW_DELTA, + }; + } + + const latitudes = pins.map((pin) => pin.location.lat); + const rawLongitudes = pins.map((pin) => pin.location.lng); + const rawLongitudeSpan = + Math.max(...rawLongitudes) - Math.min(...rawLongitudes); + const longitudes = + rawLongitudeSpan > 180 + ? rawLongitudes.map((longitude) => + longitude < 0 ? longitude + 360 : longitude, + ) + : rawLongitudes; + const minLatitude = Math.min(...latitudes); + const maxLatitude = Math.max(...latitudes); + const minLongitude = Math.min(...longitudes); + const maxLongitude = Math.max(...longitudes); + + return { + latitude: (minLatitude + maxLatitude) / 2, + latitudeDelta: Math.min( + 160, + Math.max( + (maxLatitude - minLatitude) * OVERVIEW_PADDING_RATIO, + MIN_OVERVIEW_DELTA, + ), + ), + longitude: normalizeLongitude((minLongitude + maxLongitude) / 2), + longitudeDelta: Math.min( + 320, + Math.max( + (maxLongitude - minLongitude) * OVERVIEW_PADDING_RATIO, + MIN_OVERVIEW_DELTA, + ), + ), + }; +} + +function toCoordinate(location: SoundMapPin['location']) { + return { + latitude: location.lat, + longitude: location.lng, + }; +} + +function getPinLabel(pin: SoundMapPin) { + if (pin.kind === 'nearby' && pin.matchScore) { + return `${pin.matchScore}%`; + } + + return pin.label; +} + +function getCalloutTitle(pin: SoundMapPin) { + return `${pin.label} · ${pin.trackTitle}`; +} + +function getCalloutDescription(pin: SoundMapPin) { + return [pin.artistName, pin.subtitle].filter(Boolean).join(' · '); +} + +function getMapStatusLabel( + sessionStatus: SoundMapViewport['sessionStatus'], + visibility: SoundMapViewport['visibility'], +) { + if (sessionStatus !== 'active') { + return 'PREVIEW'; + } + + if (visibility === 'private') { + return 'PRIVATE'; + } + + return 'LIVE'; +} + +export const SoundMapView = forwardRef( + function SoundMapView( + { + center, + currentLocation, + fullBleed = false, + height, + legendItems, + onPinPress, + onRegionChangeComplete, + onViewportLayoutChange, + pins, + selectedPinId, + sessionStatus, + showChrome = true, + showPinCallouts = true, + statusLabel: customStatusLabel, + visibility, + viewportKey, + viewportMode = 'auto', + }, + ref, + ) { + const mapRef = useRef(null); + const pinsRef = useRef(pins); + pinsRef.current = pins; + const initialRegion = useMemo(() => createRegion(center), [center]); + const defaultViewportKey = useMemo( + () => + pins + .map( + (pin) => + `${pin.id}:${pin.location.lat.toFixed(6)}:${pin.location.lng.toFixed(6)}`, + ) + .join('|'), + [pins], + ); + const synchronizedViewportKey = viewportKey ?? defaultViewportKey; + const statusLabel = + customStatusLabel ?? getMapStatusLabel(sessionStatus, visibility); + const isLive = statusLabel === 'LIVE'; + const mapLegendItems = legendItems ?? [ + { color: markerPalette.me.border, label: '나' }, + { color: markerPalette.companion.border, label: '동행' }, + { color: markerPalette.nearby.border, label: '주변' }, + ]; + useImperativeHandle( + ref, + () => ({ + focusCurrentLocation() { + if (currentLocation) { + mapRef.current?.animateToRegion(createRegion(currentLocation), 320); + } + }, + }), + [currentLocation], + ); + + useEffect( + function synchronizeMapViewport() { + if (viewportMode === 'preserve') { + return; + } + + const frame = requestAnimationFrame(() => { + if (!mapRef.current) { + return; + } + + const viewportPins = pinsRef.current; + + if (viewportMode === 'overview') { + mapRef.current.animateToRegion( + createOverviewRegion(center, viewportPins), + 320, + ); + return; + } + + if (viewportPins.length < 2) { + mapRef.current.animateToRegion(createRegion(center), 260); + return; + } + + mapRef.current.fitToCoordinates( + viewportPins.map((pin) => toCoordinate(pin.location)), + { + animated: true, + edgePadding: { + bottom: 68, + left: 44, + right: 44, + top: 70, + }, + }, + ); + }); + + return function cancelViewportAnimation() { + cancelAnimationFrame(frame); + }; + }, + [center, synchronizedViewportKey, viewportMode], + ); + + return ( + + onViewportLayoutChange?.({ + height: nativeEvent.layout.height, + width: nativeEvent.layout.width, + }) + } + style={[ + styles.container, + fullBleed ? styles.fullBleedContainer : undefined, + height ? { height } : undefined, + ]} + > + + onRegionChangeComplete?.({ + latitude: region.latitude, + latitudeDelta: region.latitudeDelta, + longitude: region.longitude, + longitudeDelta: region.longitudeDelta, + }) + } + pitchEnabled={false} + ref={mapRef} + rotateEnabled={false} + showsBuildings={false} + showsCompass={false} + showsMyLocationButton={false} + showsPointsOfInterests={false} + showsScale={false} + showsTraffic={false} + showsUserLocation={Boolean(currentLocation)} + style={styles.map} + toolbarEnabled={false} + userInterfaceStyle="dark" + > + {pins.map((pin) => { + const palette = markerPalette[pin.kind]; + const isSelected = selectedPinId === pin.id; + + return ( + onPinPress?.(pin)} + title={showPinCallouts ? getCalloutTitle(pin) : undefined} + zIndex={pin.kind === 'cluster' ? 30 : 20} + > + {pin.kind === 'cluster' ? ( + + + + {getPinLabel(pin)} + + + + ) : ( + + + + + + + {getPinLabel(pin)} + + + + )} + + {showPinCallouts ? ( + + + + {getCalloutTitle(pin)} + + + {getCalloutDescription(pin)} + + + + ) : null} + + ); + })} + + + {showChrome ? ( + <> + + + + {statusLabel} + + + + + {mapLegendItems.map((item) => ( + + ))} + + + ) : null} + + ); + }, +); + +function LegendItem({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ); +} + +const styles = StyleSheet.create({ + callout: { + backgroundColor: 'rgba(8,13,24,0.96)', + borderColor: 'rgba(255,255,255,0.14)', + borderRadius: 14, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + width: 216, + }, + calloutContainer: { + width: 216, + }, + calloutDescription: { + color: 'rgba(255,255,255,0.62)', + fontSize: 11, + lineHeight: 16, + marginTop: 4, + }, + calloutTitle: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '700', + lineHeight: 17, + }, + container: { + backgroundColor: '#070B1F', + borderColor: 'rgba(255,255,255,0.12)', + borderRadius: 22, + borderWidth: 1, + height: 260, + overflow: 'hidden', + }, + clusterCount: { + fontSize: 15, + fontWeight: '800', + textAlign: 'center', + }, + clusterHalo: { + alignItems: 'center', + borderRadius: 999, + height: 58, + justifyContent: 'center', + width: 58, + }, + clusterMarker: { + alignItems: 'center', + borderRadius: 999, + height: 46, + justifyContent: 'center', + shadowColor: '#000000', + shadowOffset: { + height: 4, + width: 0, + }, + shadowOpacity: 0.28, + shadowRadius: 9, + width: 46, + }, + fullBleedContainer: { + borderRadius: 0, + borderWidth: 0, + flex: 1, + height: '100%', + }, + legend: { + alignItems: 'center', + backgroundColor: 'rgba(8,13,24,0.72)', + borderColor: 'rgba(255,255,255,0.12)', + borderRadius: 999, + borderWidth: 1, + bottom: 10, + flexDirection: 'row', + gap: 9, + paddingHorizontal: 10, + paddingVertical: 7, + position: 'absolute', + right: 10, + }, + legendDot: { + borderRadius: 999, + height: 7, + width: 7, + }, + legendItem: { + alignItems: 'center', + flexDirection: 'row', + gap: 4, + }, + legendText: { + color: 'rgba(255,255,255,0.68)', + fontSize: 10, + fontWeight: '700', + }, + map: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + marker: { + alignItems: 'center', + borderRadius: 999, + borderWidth: 2, + height: 34, + justifyContent: 'center', + shadowColor: '#000000', + shadowOffset: { + height: 4, + width: 0, + }, + shadowOpacity: 0.22, + shadowRadius: 8, + width: 34, + }, + markerHalo: { + alignItems: 'center', + borderRadius: 999, + padding: 5, + }, + markerLabel: { + borderRadius: 999, + borderWidth: 1, + marginTop: 3, + maxWidth: 62, + minWidth: 28, + paddingHorizontal: 6, + paddingVertical: 2, + }, + markerLabelText: { + fontSize: 9, + fontWeight: '800', + textAlign: 'center', + }, + statusPill: { + alignItems: 'center', + backgroundColor: 'rgba(8,13,24,0.72)', + borderColor: 'rgba(255,255,255,0.12)', + borderRadius: 999, + borderWidth: 1, + flexDirection: 'row', + gap: 6, + left: 12, + paddingHorizontal: 10, + paddingVertical: 7, + position: 'absolute', + top: 12, + }, + statusText: { + fontSize: 10, + fontWeight: '800', + letterSpacing: 0, + }, +}); diff --git a/src/components/travel/live-sound-map/types.ts b/src/components/travel/live-sound-map/types.ts index bb41120..182992f 100644 --- a/src/components/travel/live-sound-map/types.ts +++ b/src/components/travel/live-sound-map/types.ts @@ -2,7 +2,21 @@ import type { GeoPoint, Track } from '@/types/domain'; export type SoundMapVisibility = 'companions' | 'nearby' | 'private'; -export type SoundMapPinKind = 'companion' | 'me' | 'nearby'; +export type SoundMapPinKind = + | 'cluster' + | 'companion' + | 'me' + | 'nearby' + | 'place'; + +export type SoundMapViewportMode = 'auto' | 'overview' | 'preserve'; + +export type SoundMapRegion = { + latitude: number; + latitudeDelta: number; + longitude: number; + longitudeDelta: number; +}; export type SoundMapPin = { artistName?: string; @@ -15,10 +29,35 @@ export type SoundMapPin = { trackTitle: string; }; +export type SoundMapViewportSize = { + height: number; + width: number; +}; + export type SoundMapViewport = { center: GeoPoint; + currentLocation?: GeoPoint; + fullBleed?: boolean; + height?: number; + legendItems?: Array<{ + color: string; + label: string; + }>; + onPinPress?: (pin: SoundMapPin) => void; + onRegionChangeComplete?: (region: SoundMapRegion) => void; + onViewportLayoutChange?: (size: SoundMapViewportSize) => void; pins: SoundMapPin[]; + selectedPinId?: string; selectedTrack?: Track; + showChrome?: boolean; + showPinCallouts?: boolean; sessionStatus: 'active' | 'ended' | 'idle'; + statusLabel?: string; visibility: SoundMapVisibility; + viewportKey?: string; + viewportMode?: SoundMapViewportMode; +}; + +export type SoundMapViewHandle = { + focusCurrentLocation: () => void; }; diff --git a/src/components/travel/recap-map/RecapMapSection.tsx b/src/components/travel/recap-map/RecapMapSection.tsx new file mode 100644 index 0000000..f56d6d5 --- /dev/null +++ b/src/components/travel/recap-map/RecapMapSection.tsx @@ -0,0 +1,800 @@ +import { Feather } from '@expo/vector-icons'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Pressable, View } from 'react-native'; + +import { recapApi } from '@/api/recapApi'; +import { AppText } from '@/components/AppText'; +import { useAuthStore } from '@/store/authStore'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import type { + GeoPoint, + MomentLog, + PlaceContext, + RecapMapMarker, + RecapMapScope, +} from '@/types/domain'; +import { + clusterRecapMarkers, + type RecapMapClusteringViewport, +} from '@/utils/recapMapClustering'; + +import { SoundMapView } from '../live-sound-map/SoundMapView'; +import { createSoundMapCenter } from '../live-sound-map/soundMapData'; +import type { + SoundMapPin, + SoundMapRegion, + SoundMapViewHandle, + SoundMapViewportSize, +} from '../live-sound-map/types'; +import { SelectedRecapPinPanel } from './SelectedRecapPinPanel'; + +type RecapMapFilter = 'mine' | 'place' | 'public'; +type RecapMapPinGroup = { + markers: RecapMapMarker[]; + pin: SoundMapPin; +}; +type TourPlaceStatus = + | 'disabled' + | 'empty' + | 'error' + | 'loading' + | 'ready' + | 'unavailable'; + +type RecapMapSectionProps = { + currentLocation?: GeoPoint; + currentPlace?: PlaceContext; + isEndingTravel?: boolean; + onCreateMoment: () => void; + onEndTravel?: () => void; + onOpenRecap: (recapId: string) => void; + onStartTravel: () => void; + overlayBottomInset?: number; + overlayTopInset?: number; + sessionStatus: 'active' | 'ended' | 'idle'; + tourPlaceStatus?: TourPlaceStatus; + variant?: 'page' | 'section'; +}; + +const DISCOVERY_RADIUS_METERS = 300; + +const filterOptions: Array<{ + description: string; + label: string; + value: RecapMapFilter; +}> = [ + { + description: '여행 시작 CTA와 현재 장소를 중심으로 봐요.', + label: '장소 보기', + value: 'place', + }, + { + description: '현재 위치 300m 안의 공개 리캡을 봐요.', + label: '전체 리캡', + value: 'public', + }, + { + description: '내 공개/비공개 리캡을 방문 좌표별 묶음으로 봐요.', + label: '내 리캡', + value: 'mine', + }, +]; + +function createPlacePin(place: PlaceContext): SoundMapPin | undefined { + if (!place.location) { + return undefined; + } + + return { + artistName: '한국관광공사', + id: `tour-place-${place.id}`, + kind: 'place', + label: '관광지', + location: place.location, + subtitle: place.address ?? place.category ?? '현재 위치 주변 관광지', + trackTitle: place.title, + }; +} + +function getTourPlaceLabel( + currentPlace: PlaceContext | undefined, + status: TourPlaceStatus, +) { + if (currentPlace?.title) { + return currentPlace.title; + } + + if (status === 'loading') { + return '주변 관광지를 찾는 중'; + } + + if (status === 'error') { + return '관광지 정보를 불러오지 못했어요'; + } + + if (status === 'empty') { + return '가까운 관광지가 없어요'; + } + + if (status === 'disabled') { + return '위치 기반 관광지 찾기가 꺼져 있어요'; + } + + return '현재 위치를 확인할 수 없어요'; +} + +function toMarkerFromLocalMoment(log: MomentLog): RecapMapMarker | undefined { + if (!log.location) { + return undefined; + } + + return { + artistName: log.track?.artist ?? '음악 없음', + createdAt: log.createdAt, + id: `local-marker-${log.id}`, + imageUrl: log.photoUri, + location: log.location, + ownerAlias: '나', + placeName: log.placeName ?? '위치 없음', + recapId: log.id, + templateId: 'album', + title: log.note?.trim() || log.placeName || '내 리캡', + trackTitle: log.track?.title ?? '저장된 리캡', + visibility: 'private', + }; +} + +function toMapPin(marker: RecapMapMarker): SoundMapPin { + const isMine = marker.ownerAlias === '나' || marker.visibility === 'private'; + + return { + artistName: marker.artistName, + id: marker.id, + kind: isMine ? 'me' : 'nearby', + label: isMine ? '내 리캡' : '공개', + location: marker.location, + subtitle: marker.placeName, + trackTitle: marker.trackTitle, + }; +} + +function getClusterPlaceSummary(markers: RecapMapMarker[]) { + const placeNames = Array.from( + new Set(markers.map((marker) => marker.placeName).filter(Boolean)), + ); + const visiblePlaceNames = placeNames.slice(0, 2).join(' · '); + const hiddenPlaceCount = Math.max(placeNames.length - 2, 0); + + if (hiddenPlaceCount > 0) { + return `${visiblePlaceNames} 외 ${hiddenPlaceCount}곳`; + } + + return visiblePlaceNames || '같은 지역에 남긴 리캡'; +} + +function toRecapMapPinGroups( + markers: RecapMapMarker[], + filter: Exclude, + viewport?: RecapMapClusteringViewport, +): RecapMapPinGroup[] { + return clusterRecapMarkers(markers, viewport).map((cluster) => { + if (cluster.markers.length === 1) { + return { + markers: cluster.markers, + pin: toMapPin(cluster.markers[0]), + }; + } + + return { + markers: cluster.markers, + pin: { + artistName: + filter === 'mine' ? '내 여행 지도' : '주변 공개 사운드 지도', + id: cluster.id, + kind: 'cluster', + label: `${cluster.markers.length}`, + location: cluster.location, + subtitle: getClusterPlaceSummary(cluster.markers), + trackTitle: + filter === 'mine' ? '이 지역의 내 리캡' : '이 지역의 공개 리캡', + }, + }; + }); +} + +function getMapLegendItems(filter: RecapMapFilter) { + if (filter === 'place') { + return [{ color: '#B7E628', label: '관광지' }]; + } + + if (filter === 'mine') { + return [ + { color: '#B7E628', label: '내 리캡' }, + { color: '#8B72FF', label: '리캡 묶음' }, + ]; + } + + return [ + { color: '#FF8A3D', label: '공개 리캡' }, + { color: '#8B72FF', label: '리캡 묶음' }, + ]; +} + +function getScope(filter: RecapMapFilter): RecapMapScope | undefined { + if (filter === 'place') { + return undefined; + } + + return filter; +} + +function getEmptyCopy(filter: RecapMapFilter) { + if (filter === 'public') { + return '현재 300m 안에 공개된 리캡이 없어요. 이 장소의 첫 사운드 로그를 남겨보세요.'; + } + + if (filter === 'mine') { + return '지도에 표시할 내 리캡이 아직 없어요. 리캡을 남기면 방문 좌표가 여기에 쌓여요.'; + } + + return '필터를 전체 리캡이나 내 리캡으로 바꾸면 지도에 남겨진 사운드 로그를 볼 수 있어요.'; +} + +export function RecapMapSection({ + currentLocation, + currentPlace, + isEndingTravel = false, + onCreateMoment, + onEndTravel, + onOpenRecap, + onStartTravel, + overlayBottomInset = 112, + overlayTopInset = 12, + sessionStatus, + tourPlaceStatus = currentPlace ? 'ready' : 'unavailable', + variant = 'section', +}: RecapMapSectionProps) { + const [filter, setFilter] = useState('place'); + const [isLoadingMarkers, setIsLoadingMarkers] = useState(false); + const [mapRegion, setMapRegion] = useState(); + const [mapMessage, setMapMessage] = useState(); + const [mapViewportSize, setMapViewportSize] = useState({ + height: 0, + width: 0, + }); + const [selectedPinId, setSelectedPinId] = useState(); + const [serverMarkers, setServerMarkers] = useState([]); + const mapViewRef = useRef(null); + const authStatus = useAuthStore((state) => state.status); + const momentLogs = useMomentLogStore((state) => state.logs); + const currentLocationCenter = useMemo( + () => createSoundMapCenter(currentLocation, currentPlace), + [currentLocation, currentPlace], + ); + const placeCenter = currentPlace?.location ?? currentLocationCenter; + const center = filter === 'place' ? placeCenter : currentLocationCenter; + const placeName = getTourPlaceLabel(currentPlace, tourPlaceStatus); + const scope = getScope(filter); + const localMineMarkers = useMemo( + () => + momentLogs + .map(toMarkerFromLocalMoment) + .filter((marker): marker is RecapMapMarker => Boolean(marker)), + [momentLogs], + ); + const pendingLocalMineMarkers = useMemo( + () => + momentLogs + .filter((log) => log.syncStatus !== 'synced') + .map(toMarkerFromLocalMoment) + .filter((marker): marker is RecapMapMarker => Boolean(marker)), + [momentLogs], + ); + const visibleMarkers = useMemo(() => { + if (filter !== 'mine') { + return serverMarkers; + } + + if (serverMarkers.length === 0) { + return localMineMarkers; + } + + return [...pendingLocalMineMarkers, ...serverMarkers]; + }, [filter, localMineMarkers, pendingLocalMineMarkers, serverMarkers]); + const placePin = useMemo( + () => (currentPlace ? createPlacePin(currentPlace) : undefined), + [currentPlace], + ); + const clusteringViewport = useMemo( + () => + mapRegion + ? { + height: mapViewportSize.height, + region: mapRegion, + width: mapViewportSize.width, + } + : undefined, + [mapRegion, mapViewportSize.height, mapViewportSize.width], + ); + const mapPinGroups = useMemo(() => { + if (filter === 'place') { + return placePin ? [{ markers: [], pin: placePin }] : []; + } + + return toRecapMapPinGroups(visibleMarkers, filter, clusteringViewport); + }, [clusteringViewport, filter, placePin, visibleMarkers]); + const mapPins = useMemo( + () => mapPinGroups.map((group) => group.pin), + [mapPinGroups], + ); + const selectedPinGroup = useMemo( + () => mapPinGroups.find((group) => group.pin.id === selectedPinId), + [mapPinGroups, selectedPinId], + ); + const viewportKey = useMemo(() => { + if (filter === 'place') { + return `${filter}:${placePin?.id ?? 'empty'}`; + } + + const markerKey = visibleMarkers + .map( + (marker) => + `${marker.id}:${marker.location.lat.toFixed(6)}:${marker.location.lng.toFixed(6)}`, + ) + .sort((left, right) => left.localeCompare(right)) + .join('|'); + + return `${filter}:${markerKey}`; + }, [filter, placePin?.id, visibleMarkers]); + const showTravelCta = filter === 'place'; + const mapTitle = + filter === 'mine' + ? '내가 다녀온 사운드 지도' + : filter === 'public' + ? '현재 위치 주변 공개 리캡' + : placeName; + const selectedFilter = filterOptions.find( + (option) => option.value === filter, + ); + const statusLabel = + filter === 'public' ? 'PUBLIC' : filter === 'mine' ? 'MINE' : 'PLACE'; + const markerQueryLat = + scope === 'public' ? currentLocationCenter.lat : undefined; + const markerQueryLng = + scope === 'public' ? currentLocationCenter.lng : undefined; + const mapPinStatus = + isLoadingMarkers || (filter === 'place' && tourPlaceStatus === 'loading') + ? 'SYNC' + : filter !== 'place' + ? `${mapPins.length} PIN · ${visibleMarkers.length} LOG` + : `${mapPins.length} PIN`; + const handleRegionChangeComplete = useCallback((region: SoundMapRegion) => { + setMapRegion(region); + }, []); + const handleViewportLayoutChange = useCallback( + (size: SoundMapViewportSize) => { + setMapViewportSize((currentSize) => + currentSize.height === size.height && currentSize.width === size.width + ? currentSize + : size, + ); + }, + [], + ); + + useEffect( + function fetchRecapMarkersForScope() { + if (!scope) { + setServerMarkers([]); + setMapMessage(undefined); + return; + } + + if (authStatus !== 'authenticated') { + setServerMarkers([]); + setMapMessage( + '로그인하면 주변 공개 리캡과 내 리캡을 지도에서 볼 수 있어요.', + ); + return; + } + + let ignore = false; + + setIsLoadingMarkers(true); + setMapMessage(undefined); + recapApi + .getRecapMarkers( + scope === 'mine' + ? { scope } + : { + lat: markerQueryLat, + lng: markerQueryLng, + radiusMeters: DISCOVERY_RADIUS_METERS, + scope, + }, + ) + .then((markers) => { + if (!ignore) { + setServerMarkers(markers); + } + }) + .catch(() => { + if (!ignore) { + setServerMarkers([]); + setMapMessage( + scope === 'mine' + ? '서버 내 리캡을 불러오지 못해 로컬 리캡을 먼저 보여드려요.' + : '주변 공개 리캡을 불러오지 못했어요.', + ); + } + }) + .finally(() => { + if (!ignore) { + setIsLoadingMarkers(false); + } + }); + + return function ignoreStaleRecapMarkerResponse() { + ignore = true; + }; + }, + [authStatus, markerQueryLat, markerQueryLng, scope], + ); + + useEffect( + function clearUnavailablePinSelection() { + if ( + selectedPinId && + !mapPinGroups.some((group) => group.pin.id === selectedPinId) + ) { + setSelectedPinId(undefined); + } + }, + [mapPinGroups, selectedPinId], + ); + + const isPageVariant = variant === 'page'; + const renderFilterChips = () => ( + + {filterOptions.map((option) => { + const selected = filter === option.value; + + return ( + { + setSelectedPinId(undefined); + setFilter(option.value); + }} + > + + + {option.label} + + + + ); + })} + {isPageVariant ? ( + mapViewRef.current?.focusCurrentLocation()} + style={{ opacity: currentLocation ? 1 : 0.48 }} + > + + + + + ) : null} + + ); + + if (isPageVariant) { + return ( + + setSelectedPinId(pin.id) + } + pins={mapPins} + ref={mapViewRef} + selectedPinId={selectedPinId} + sessionStatus={sessionStatus} + showChrome={false} + showPinCallouts={false} + statusLabel={statusLabel} + visibility={filter === 'mine' ? 'private' : 'nearby'} + viewportKey={viewportKey} + viewportMode={filter === 'mine' ? 'overview' : 'auto'} + /> + + + + + + + 지도 / 여행모드 + + + {mapTitle} + + + + + {mapPinStatus} + + + + + + {renderFilterChips()} + + {mapMessage ? ( + + + {mapMessage} + + + ) : null} + + + + {showTravelCta ? ( + + + + + + {sessionStatus === 'active' + ? '기록 남기기' + : '여행모드 시작'} + + + {sessionStatus === 'active' + ? '사진, 음악과 촬영 시간을 현재 여행 로그에 저장해요.' + : '여행을 시작하면 새 기록들이 하나의 로그로 묶여요.'} + + + + + + + + {sessionStatus === 'active' && onEndTravel ? ( + + + {isEndingTravel ? '여행 로그 정리 중' : '여행 종료'} + + + ) : null} + + ) : selectedPinGroup && selectedPinGroup.markers.length > 0 ? ( + setSelectedPinId(undefined)} + onOpenRecap={onOpenRecap} + pin={selectedPinGroup.pin} + /> + ) : visibleMarkers.length === 0 ? ( + + + {getEmptyCopy(filter)} + + + ) : null} + + + ); + } + + return ( + + <> + + + + + + Travel Mode Map + + + + 장소에 남겨진 사운드 로그 + + + {filter === 'mine' + ? '내가 방문했던 좌표를 지역별 묶음으로 한눈에 확인해요.' + : `현재 위치 기준 ${DISCOVERY_RADIUS_METERS}m 안의 공개 리캡을 지도 핀으로 확인해요.`} + + + + + {mapPinStatus} + + + + + {renderFilterChips()} + + {selectedFilter ? ( + + {selectedFilter.description} + + ) : null} + + + + + + + {mapMessage ? ( + + + {mapMessage} + + + ) : null} + + {showTravelCta ? ( + + + + + + {sessionStatus === 'active' ? '기록 남기기' : '여행모드 시작'} + + + {sessionStatus === 'active' + ? '사진, 음악과 촬영 시간을 현재 여행 로그에 저장해요.' + : '여행을 시작하면 새 기록들이 하나의 로그로 묶여요.'} + + + + + + + + {sessionStatus === 'active' && onEndTravel ? ( + + + {isEndingTravel ? '여행 로그 정리 중' : '여행 종료'} + + + ) : null} + + ) : visibleMarkers.length > 0 ? ( + + {visibleMarkers.slice(0, 3).map((marker) => ( + onOpenRecap(marker.recapId)} + > + + + + {marker.title} + + + {marker.placeName} · {marker.trackTitle} -{' '} + {marker.artistName} + + + + + {marker.visibility === 'public' ? '전체공개' : '나만보기'} + + + + + ))} + + ) : ( + + + {getEmptyCopy(filter)} + + + )} + + ); +} diff --git a/src/components/travel/recap-map/SelectedRecapPinPanel.tsx b/src/components/travel/recap-map/SelectedRecapPinPanel.tsx new file mode 100644 index 0000000..fae3cc5 --- /dev/null +++ b/src/components/travel/recap-map/SelectedRecapPinPanel.tsx @@ -0,0 +1,121 @@ +import { Feather } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import { Pressable, ScrollView, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; +import type { RecapMapMarker } from '@/types/domain'; +import { formatRecapRecordedAt } from '@/utils/dateFormat'; + +import type { SoundMapPin } from '../live-sound-map/types'; + +type SelectedRecapPinPanelProps = { + markers: RecapMapMarker[]; + onClose: () => void; + onOpenRecap: (recapId: string) => void; + pin: SoundMapPin; +}; + +export function SelectedRecapPinPanel({ + markers, + onClose, + onOpenRecap, + pin, +}: SelectedRecapPinPanelProps) { + return ( + + + + + + + 선택한 핀의 로그 · {markers.length}개 + + + + {pin.subtitle} + + + + + + + + 3} + > + {markers.map((marker, index) => ( + 0 ? 'border-t border-white/10' : '' + }`} + key={marker.id} + onPress={() => onOpenRecap(marker.recapId)} + > + + {marker.imageUrl ? ( + + ) : ( + + + + )} + + + + + + {marker.title} + + + {marker.visibility === 'public' ? '전체공개' : '나만보기'} + + + + {marker.trackTitle} · {marker.artistName} + + + {marker.ownerAlias} · {formatRecapRecordedAt(marker.createdAt)} + + + + + + 상세 + + + + + ))} + + + ); +} diff --git a/src/components/travel/recap-map/index.ts b/src/components/travel/recap-map/index.ts new file mode 100644 index 0000000..7a74c98 --- /dev/null +++ b/src/components/travel/recap-map/index.ts @@ -0,0 +1 @@ +export { RecapMapSection } from './RecapMapSection'; diff --git a/src/components/travel/travelData.ts b/src/components/travel/travelData.ts index 3f9a142..579547a 100644 --- a/src/components/travel/travelData.ts +++ b/src/components/travel/travelData.ts @@ -95,7 +95,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/cms/resource_photo/96/4033396_image2_1.jpg', + photoUri: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', placeName: '남산 산책로', source: 'camera', syncStatus: 'synced', diff --git a/src/design-system/index.ts b/src/design-system/index.ts index 1e3f95d..f892cd1 100644 --- a/src/design-system/index.ts +++ b/src/design-system/index.ts @@ -20,14 +20,12 @@ export { Chip } from '@/components/Chip'; export { EmptyState } from '@/components/EmptyState'; export { IconButton } from '@/components/IconButton'; export { MiniPlayer } from '@/components/MiniPlayer'; +export { PageHeader } from '@/components/PageHeader'; export { Screen } from '@/components/Screen'; +export { SectionTitle } from '@/components/SectionTitle'; +export { SettingsRow } from '@/components/SettingsRow'; export { FeaturedPlaylistCard } from '@/components/home/FeaturedPlaylistCard'; -export { - HomeHeader, - HomeNavigationBar, - HomeTopFilterBar, - isHomeTopFilter, -} from '@/components/home/HomeHeader'; +export { HomeHeader, HomeNavigationBar } from '@/components/home/HomeHeader'; export { LocationContextCard } from '@/components/home/LocationContextCard'; export { MoodRecommendationCard } from '@/components/home/MoodRecommendationCard'; export { @@ -43,6 +41,7 @@ export { RecapListCard } from '@/components/recap/RecapListCard'; export { RecapCaptureFrame } from '@/components/recap-share/RecapCaptureFrame'; export { RecapMusicSummary } from '@/components/recap-share/RecapMusicSummary'; export { RecapPreviewCard } from '@/components/recap-share/RecapPreviewCard'; -export { RecapTemplateSelector } from '@/components/recap-share/RecapTemplateSelector'; +export { RecapSoundLogList } from '@/components/recap-share/RecapSoundLogList'; +export { RecapTravelSummaryCard } from '@/components/recap-share/RecapTravelSummaryCard'; export { ShareActionButton } from '@/components/recap-share/ShareActionButton'; export { ShareActionList } from '@/components/recap-share/ShareActionList'; diff --git a/src/hooks/useRecapShareActions.ts b/src/hooks/useRecapShareActions.ts index 9800ae1..4850fad 100644 --- a/src/hooks/useRecapShareActions.ts +++ b/src/hooks/useRecapShareActions.ts @@ -140,11 +140,11 @@ export function useRecapShareActions({ capture, recapId }: UseRecapShareActionsP return; } - let MediaLibrary: typeof import('expo-media-library'); + let MediaLibrary: typeof import('expo-media-library/legacy'); let permission: MediaPermissionResponse; try { - MediaLibrary = await import('expo-media-library'); + MediaLibrary = await import('expo-media-library/legacy'); permission = await MediaLibrary.requestPermissionsAsync(true); } catch { throw new RecapShareActionError('media_save_failed'); diff --git a/src/hooks/useTravelRouteTracking.ts b/src/hooks/useTravelRouteTracking.ts new file mode 100644 index 0000000..65b9790 --- /dev/null +++ b/src/hooks/useTravelRouteTracking.ts @@ -0,0 +1,185 @@ +import * as Location from 'expo-location'; +import { useCallback, useEffect, useRef } from 'react'; +import { AppState, Platform } from 'react-native'; + +import { travelSessionApi } from '@/api/travelSessionApi'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; +import type { GeoPoint, RoutePoint } from '@/types/domain'; + +const EARTH_RADIUS_METERS = 6371000; +const MIN_ROUTE_POINT_DISTANCE_METERS = 20; +const LOCATION_WATCH_INTERVAL_MS = 15000; +const ROUTE_SYNC_DEBOUNCE_MS = 30000; +const SERVER_SESSION_ID_PREFIX = 'session_'; + +function toRadians(value: number) { + return (value * Math.PI) / 180; +} + +function getDistanceMeters(first: GeoPoint, second: GeoPoint) { + const latDistance = toRadians(second.lat - first.lat); + const lngDistance = toRadians(second.lng - first.lng); + const firstLat = toRadians(first.lat); + const secondLat = toRadians(second.lat); + const haversine = + Math.sin(latDistance / 2) ** 2 + + Math.cos(firstLat) * Math.cos(secondLat) * Math.sin(lngDistance / 2) ** 2; + + return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); +} + +function shouldAppendRoutePoint(previousPoint: RoutePoint | undefined, nextPoint: RoutePoint) { + if (!previousPoint) { + return true; + } + + return getDistanceMeters(previousPoint, nextPoint) >= MIN_ROUTE_POINT_DISTANCE_METERS; +} + +function isServerTravelSession(sessionId: string) { + return sessionId.startsWith(SERVER_SESSION_ID_PREFIX); +} + +export function createRoutePoint(location: GeoPoint, recordedAt = new Date()): RoutePoint { + return { + lat: location.lat, + lng: location.lng, + recordedAt: recordedAt.toISOString(), + }; +} + +export function useTravelRouteTracking() { + const appendRoutePoint = useTravelSessionStore((state) => state.appendRoutePoint); + const currentLocation = useTravelSessionStore((state) => state.currentLocation); + const setLocation = useTravelSessionStore((state) => state.setLocation); + const setLocationStatus = useTravelSessionStore((state) => state.setLocationStatus); + const sessionId = useTravelSessionStore((state) => state.session.id); + const sessionStatus = useTravelSessionStore((state) => state.session.status); + const routePoints = useTravelSessionStore((state) => state.session.routePoints ?? []); + const lastPointRef = useRef(routePoints.at(-1)); + const syncTimeoutRef = useRef | undefined>(undefined); + + useEffect(() => { + lastPointRef.current = routePoints.at(-1); + }, [routePoints]); + + const syncRouteNow = useCallback(async () => { + if ( + Platform.OS === 'web' || + sessionStatus !== 'active' || + routePoints.length === 0 || + !isServerTravelSession(sessionId) + ) { + return; + } + + try { + await travelSessionApi.syncTravelSessionRoute(sessionId, { + location: currentLocation ?? routePoints.at(-1), + routePoints, + }); + } catch { + // Route points stay persisted locally and will be retried on the next foreground sync. + } + }, [currentLocation, routePoints, sessionId, sessionStatus]); + + useEffect(() => { + if ( + Platform.OS === 'web' || + sessionStatus !== 'active' || + routePoints.length < 2 || + !isServerTravelSession(sessionId) + ) { + return; + } + + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current); + } + + syncTimeoutRef.current = setTimeout(() => { + void syncRouteNow(); + }, ROUTE_SYNC_DEBOUNCE_MS); + + return () => { + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current); + } + }; + }, [routePoints.length, sessionId, sessionStatus, syncRouteNow]); + + useEffect(() => { + if (Platform.OS === 'web') { + return; + } + + const subscription = AppState.addEventListener('change', (nextState) => { + if (nextState !== 'active') { + void syncRouteNow(); + } + }); + + return () => { + subscription.remove(); + }; + }, [syncRouteNow]); + + useEffect(() => { + if (Platform.OS === 'web' || sessionStatus !== 'active') { + return; + } + + let isMounted = true; + let subscription: Location.LocationSubscription | undefined; + + async function startRouteTracking() { + const permission = await Location.requestForegroundPermissionsAsync(); + + if (!isMounted) { + return; + } + + if (!permission.granted) { + setLocationStatus('denied'); + return; + } + + setLocationStatus('granted'); + + subscription = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.Balanced, + distanceInterval: MIN_ROUTE_POINT_DISTANCE_METERS, + timeInterval: LOCATION_WATCH_INTERVAL_MS, + }, + (position) => { + const nextLocation = { + lat: position.coords.latitude, + lng: position.coords.longitude, + }; + const nextPoint: RoutePoint = { + ...nextLocation, + accuracyMeters: position.coords.accuracy ?? undefined, + recordedAt: new Date(position.timestamp).toISOString(), + }; + + setLocation(nextLocation); + + if (!shouldAppendRoutePoint(lastPointRef.current, nextPoint)) { + return; + } + + lastPointRef.current = nextPoint; + appendRoutePoint(nextPoint); + }, + ); + } + + void startRouteTracking(); + + return () => { + isMounted = false; + subscription?.remove(); + }; + }, [appendRoutePoint, sessionId, sessionStatus, setLocation, setLocationStatus]); +} diff --git a/src/mock-server/homeHandlers.ts b/src/mock-server/homeHandlers.ts index 105aafa..0d6306f 100644 --- a/src/mock-server/homeHandlers.ts +++ b/src/mock-server/homeHandlers.ts @@ -14,6 +14,22 @@ import { PlaceContext, } from '@/types/domain'; +function normalizeMoodLabel(value?: string) { + return value === '청량한' ? '시원한' : value; +} + +function matchesMoodFilter(item: MoodRecommendation, moodFilter?: string) { + const normalizedFilter = normalizeMoodLabel(moodFilter); + + if (!normalizedFilter || normalizedFilter === '전체') { + return true; + } + + return (item.moods ?? []).some( + (mood) => normalizeMoodLabel(mood) === normalizedFilter, + ); +} + function getMatchScore( item: MoodRecommendation, params?: MoodRecommendationMockParams, @@ -38,19 +54,15 @@ function getMatchScore( const travelModeWeight = params.recommendationMode === 'travel' ? 2.4 : 1; const tasteWeight = params.recommendationMode === 'travel' ? 0.7 : 1.4; - if (params.topFilter !== '전체' && itemMoods.includes(params.topFilter)) { - score += 8; - } - - if (params.moodFilter !== '전체' && itemMoods.includes(params.moodFilter)) { - score += 8; - } - score += (params.preferredGenres ?? []).filter((genre) => itemGenres.includes(genre)) .length * 3 * tasteWeight; score += - (params.preferredMoods ?? []).filter((mood) => itemMoods.includes(mood)) + (params.preferredMoods ?? []).filter((mood) => + itemMoods.some( + (itemMood) => normalizeMoodLabel(itemMood) === normalizeMoodLabel(mood), + ), + ) .length * 2 * tasteWeight; score += (params.travelStyles ?? []).filter((style) => @@ -65,7 +77,7 @@ function getMatchScore( } if (/해변|바다|해수욕장|ocean|beach/.test(placeContext)) { - score += itemMoods.some((mood) => /청량한|잔잔한|신나는/.test(mood)) ? 18 : 0; + score += itemMoods.some((mood) => /시원한|청량한|잔잔한|신나는/.test(mood)) ? 18 : 0; } if (/야경|타워|전망|city|night/.test(placeContext)) { @@ -155,10 +167,12 @@ export const homeMockHandlers = { getMoodRecommendations: (params?: MoodRecommendationMockParams) => mockServerDelay( 'home.moodRecommendations', - [...moodRecommendations].sort( - (first, second) => - getMatchScore(second, params) - getMatchScore(first, params), - ), + [...moodRecommendations] + .filter((item) => matchesMoodFilter(item, params?.moodFilter)) + .sort( + (first, second) => + getMatchScore(second, params) - getMatchScore(first, params), + ), ), getRecentMusicLogs: () => mockServerDelay('home.recentMusicLogs', recentMusicLogs), diff --git a/src/mock-server/recapHandlers.ts b/src/mock-server/recapHandlers.ts index 3dca277..e4165ac 100644 --- a/src/mock-server/recapHandlers.ts +++ b/src/mock-server/recapHandlers.ts @@ -24,10 +24,10 @@ export const recapMockHandlers = { artist: 'Soundlog', fallbackColor: '#B7E628', id: input.representativeTrackId ?? 'seoul-city', - title: '저장된 순간', + title: '저장된 리캡', }, sessionId: input.sessionId, - title: input.title ?? '여행 Recap', + title: input.title ?? '여행 로그', }; createdRecapItems.unshift(recap); diff --git a/src/mock-server/types.ts b/src/mock-server/types.ts index d15aea6..f396007 100644 --- a/src/mock-server/types.ts +++ b/src/mock-server/types.ts @@ -40,7 +40,6 @@ export type MoodRecommendationMockParams = { recommendationMode: MusicRecommendationMode; preferredGenres?: string[]; preferredMoods?: string[]; - topFilter: string; travelStyles?: string[]; }; diff --git a/src/mocks/homeMocks.ts b/src/mocks/homeMocks.ts index 9d84693..47c6b4c 100644 --- a/src/mocks/homeMocks.ts +++ b/src/mocks/homeMocks.ts @@ -28,8 +28,10 @@ export const moodRecommendations: MoodRecommendation[] = [ { id: 'calm-walk', title: '잔잔한\n산책', + subtitle: '천천히 걷는 시간', color: '#2B176C', genres: ['인디', '발라드'], + imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg', moods: ['잔잔한', '감성적인'], playlistId: 'calm-walk', track: { artist: 'JENNIE', fallbackColor: '#192554', id: 'seoul-city', title: 'Seoul City' }, @@ -38,9 +40,11 @@ export const moodRecommendations: MoodRecommendation[] = [ { id: 'drive', title: '드라이브\n팝', + subtitle: '창문을 열고 달리는 순간', color: '#B1913A', genres: ['팝', 'K-POP'], - moods: ['신나는', '청량한', '활기찬'], + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + moods: ['신나는', '시원한', '활기찬'], playlistId: 'drive', track: { artist: '김건모', fallbackColor: '#48A5B4', id: 'moon-seoul', title: '서울의 달' }, travelStyles: ['드라이브', '바다 보기'], @@ -48,9 +52,11 @@ export const moodRecommendations: MoodRecommendation[] = [ { id: 'city-night', title: '도시의\n야경', + subtitle: '불빛이 번지는 밤', color: '#1F2937', genres: ['R&B', 'OST'], - moods: ['감성적인', '잔잔한'], + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + moods: ['감성적인', '잔잔한', '설레는'], playlistId: 'city-night', track: { artist: '폴킴', fallbackColor: '#D70D31', id: 'hangang', title: '한강에서' }, travelStyles: ['야경 감상', '카페 투어'], @@ -58,23 +64,15 @@ export const moodRecommendations: MoodRecommendation[] = [ { id: 'cafe-indie', title: '카페\n인디', + subtitle: '잠시 머물고 싶은 오후', color: '#3F2C6B', genres: ['인디', 'R&B'], - moods: ['잔잔한', '청량한'], + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + moods: ['잔잔한', '시원한'], playlistId: 'cafe-indie', - track: { artist: '10cm', fallbackColor: '#814D2B', id: 'cafe-night', title: '카페의 밤' }, + track: { artist: '10cm', fallbackColor: '#DA6C51', id: 'seoul-night-track', title: '서울의 밤' }, travelStyles: ['카페 투어', '산책'], }, - { - id: 'festival-kpop', - title: '페스티벌\nK-POP', - color: '#9A3E62', - genres: ['K-POP', '힙합'], - moods: ['신나는', '활기찬'], - playlistId: 'festival-kpop', - track: { artist: 'NewJeans', fallbackColor: '#29376B', id: 'festival-air', title: 'Festival Air' }, - travelStyles: ['축제'], - }, ]; export const recentMusicLogs: MusicLogItem[] = [ @@ -82,7 +80,7 @@ export const recentMusicLogs: MusicLogItem[] = [ artistName: 'JENNIE', createdAt: '2026-05-25T00:00:00.000Z', id: 'log-1', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', placeName: '광안리', recapShareId: 'log-1', trackTitle: 'Seoul City', diff --git a/src/mocks/playlistMocks.ts b/src/mocks/playlistMocks.ts index 690c06d..ed80f09 100644 --- a/src/mocks/playlistMocks.ts +++ b/src/mocks/playlistMocks.ts @@ -222,8 +222,8 @@ const festivalKpopTracks = createMoodPlaylistTracks('festival-kpop', [ ], ['#9A3E62', '#D70D31', '#E66A73', '#29376B']); export const playlistDetail: PlaylistCuration = { - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', durationText: '36:00분', id: 'seoul-night', placeName: '서울 야경', @@ -258,7 +258,7 @@ export const playlistCurationById: Record = { }, 'city-night': { accentColor: '#1F2937', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', durationText: '31:00분', id: 'city-night', placeName: '도시 야경 산책', diff --git a/src/mocks/recapMocks.ts b/src/mocks/recapMocks.ts index fa93d3b..d78982b 100644 --- a/src/mocks/recapMocks.ts +++ b/src/mocks/recapMocks.ts @@ -12,30 +12,62 @@ export const recapItems: RecapItem[] = [ title: 'Seoul City', }, title: '서울의 밤', + visibility: 'public', }, ]; export const recapShare: RecapShare = { artistName: 'JENNIE', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', - discImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + discImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', id: 'seoul-night', + moments: [ + { + artistName: 'JENNIE', + id: 'log-1', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + location: { lat: 35.1532, lng: 129.1187 }, + placeName: '광안리', + recordedAt: '2026-05-25T00:00:00.000Z', + trackTitle: 'Seoul City', + }, + { + artistName: '아이유', + id: 'log-2', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + location: { lat: 37.5294, lng: 126.9348 }, + placeName: '한강', + recordedAt: '2026-05-25T00:10:00.000Z', + trackTitle: '밤편지', + }, + { + artistName: '10cm', + id: 'log-3', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + location: { lat: 35.1796, lng: 129.0756 }, + placeName: '부산', + recordedAt: '2026-05-25T00:20:00.000Z', + trackTitle: '서울의 밤', + }, + ], placeName: 'Seoul', recordedAt: '2024-04-24T18:20:00.000+09:00', trackTitle: 'Seoul City', + visibility: 'public', }; export const recapShareById: Record = { 'log-1': { artistName: 'JENNIE', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', - discImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + discImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', id: 'log-1', moments: [ { artistName: 'JENNIE', id: 'log-1', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + location: { lat: 35.1532, lng: 129.1187 }, placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', trackTitle: 'Seoul City', @@ -44,6 +76,7 @@ export const recapShareById: Record = { placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', trackTitle: 'Seoul City', + visibility: 'public', }, 'log-2': { artistName: '아이유', @@ -55,6 +88,7 @@ export const recapShareById: Record = { artistName: '아이유', id: 'log-2', imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + location: { lat: 37.5294, lng: 126.9348 }, placeName: '한강', recordedAt: '2026-05-25T00:10:00.000Z', trackTitle: '밤편지', @@ -63,6 +97,7 @@ export const recapShareById: Record = { placeName: '한강', recordedAt: '2026-05-25T00:10:00.000Z', trackTitle: '밤편지', + visibility: 'public', }, 'log-3': { artistName: '10cm', @@ -74,6 +109,7 @@ export const recapShareById: Record = { artistName: '10cm', id: 'log-3', imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + location: { lat: 35.1796, lng: 129.0756 }, placeName: '부산', recordedAt: '2026-05-25T00:20:00.000Z', trackTitle: '서울의 밤', @@ -82,6 +118,7 @@ export const recapShareById: Record = { placeName: '부산', recordedAt: '2026-05-25T00:20:00.000Z', trackTitle: '서울의 밤', + visibility: 'public', }, 'seoul-night': recapShare, }; diff --git a/src/mocks/tourMocks.ts b/src/mocks/tourMocks.ts index 697afca..46c51d7 100644 --- a/src/mocks/tourMocks.ts +++ b/src/mocks/tourMocks.ts @@ -7,7 +7,7 @@ const seoulPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 620, id: 'seed-namsan', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', location: { lat: 37.5512, lng: 126.9882 }, overview: '서울의 야경과 도심 산책을 함께 즐길 수 있는 대표 관광지입니다.', source: 'seed', @@ -19,7 +19,7 @@ const seoulPlaces: PlaceContext[] = [ contentType: '관광지', distanceMeters: 940, id: 'seed-gwanghwamun', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', location: { lat: 37.5759, lng: 126.9768 }, overview: '도시 산책과 역사 관광 맥락을 함께 제공하는 서울 중심 관광지입니다.', source: 'seed', diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index 3fa1f65..652d172 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -3,6 +3,7 @@ import { PropsWithChildren, useEffect, useRef } from 'react'; import { Platform } from 'react-native'; import { queryClient } from '@/providers/queryClient'; +import { MomentLogSyncWorker } from '@/providers/MomentLogSyncWorker'; import { useAuthStore } from '@/store/authStore'; const DevTestManager = __DEV__ && Platform.OS !== 'web' @@ -38,6 +39,7 @@ export function AppProviders({ children }: PropsWithChildren) { {children} + {DevTestManager ? : null} diff --git a/src/providers/MomentLogSyncWorker.tsx b/src/providers/MomentLogSyncWorker.tsx new file mode 100644 index 0000000..4f17c59 --- /dev/null +++ b/src/providers/MomentLogSyncWorker.tsx @@ -0,0 +1,70 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect } from 'react'; +import { AppState } from 'react-native'; + +import { momentLogQueryKeys } from '@/api/momentLogQueries'; +import { recapQueryKeys } from '@/api/recapQueries'; +import { useAuthStore } from '@/store/authStore'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; +import { flushPendingMomentActions } from '@/utils/momentLogSync'; +import { flushPendingTravelLogFinalizations } from '@/utils/travelLogSync'; + +const RETRY_INTERVAL_MS = 30_000; + +export function MomentLogSyncWorker() { + const queryClient = useQueryClient(); + const authStatus = useAuthStore((state) => state.status); + const pendingActionCount = useMomentLogStore((state) => state.pendingActions.length); + const pendingFinalizationCount = useTravelLogSyncStore( + (state) => state.pendingFinalizations.length, + ); + + const flush = useCallback(async () => { + if ( + authStatus !== 'authenticated' || + (pendingActionCount === 0 && pendingFinalizationCount === 0) + ) { + return; + } + + const momentResult = await flushPendingMomentActions(); + const logResult = await flushPendingTravelLogFinalizations(); + + if (momentResult.successCount > 0 || logResult.successCount > 0) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }), + queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }), + ]); + } + }, [authStatus, pendingActionCount, pendingFinalizationCount, queryClient]); + + useEffect(() => { + void flush(); + }, [flush]); + + useEffect(() => { + if ( + authStatus !== 'authenticated' || + (pendingActionCount === 0 && pendingFinalizationCount === 0) + ) { + return; + } + + const intervalId = setInterval(() => { + void flush(); + }, RETRY_INTERVAL_MS); + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + void flush(); + } + }); + + return () => { + clearInterval(intervalId); + subscription.remove(); + }; + }, [authStatus, flush, pendingActionCount, pendingFinalizationCount]); + + return null; +} diff --git a/src/store/homeFilterStore.ts b/src/store/homeFilterStore.ts index 4591754..b6de19e 100644 --- a/src/store/homeFilterStore.ts +++ b/src/store/homeFilterStore.ts @@ -4,18 +4,14 @@ import { createJSONStorage, persist } from 'zustand/middleware'; type HomeFilterState = { selectedMoodFilter: string; - selectedTopFilter: string; setSelectedMoodFilter: (filter: string) => void; - setSelectedTopFilter: (filter: string) => void; }; export const useHomeFilterStore = create()( persist( (set) => ({ selectedMoodFilter: '전체', - selectedTopFilter: '전체', setSelectedMoodFilter: (selectedMoodFilter) => set({ selectedMoodFilter }), - setSelectedTopFilter: (selectedTopFilter) => set({ selectedTopFilter }), }), { name: 'soundlog-home-filters', diff --git a/src/store/momentLogStore.ts b/src/store/momentLogStore.ts index 60675f3..830309a 100644 --- a/src/store/momentLogStore.ts +++ b/src/store/momentLogStore.ts @@ -2,7 +2,16 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; -import { GeoPoint, MomentLog, MoodTag, MusicLogItem, Track, TravelMode } from '@/types/domain'; +import { + GeoPoint, + MomentLog, + MoodTag, + MusicLogItem, + RecapTemplateId, + RecapVisibility, + Track, + TravelMode, +} from '@/types/domain'; export type MomentLogCreateQueuePayload = { createdAt: string; @@ -13,7 +22,9 @@ export type MomentLogCreateQueuePayload = { placeCategory?: string; placeId?: string; placeName?: string; + recapVisibility?: RecapVisibility; sessionId?: string; + templateId?: RecapTemplateId; track?: Track; travelMode?: TravelMode; }; @@ -55,7 +66,10 @@ type MomentLogState = { addLog: (log: MomentLog) => void; getRecentLogs: (limit?: number) => MomentLog[]; mergeServerLogs: (logs: MomentLog[]) => void; - queueCreate: (momentLogId: string, payload: MomentLogCreateQueuePayload) => void; + queueCreate: ( + momentLogId: string, + payload: MomentLogCreateQueuePayload, + ) => void; queueEdit: (momentLogId: string, payload: MomentLogEditQueuePayload) => void; queueDelete: (log: MomentLog) => void; removePendingAction: (id: string) => void; @@ -85,12 +99,17 @@ function sortByNewest(logs: MomentLog[]) { }); } -function getPendingActionId(type: MomentLogPendingAction['type'], momentLogId: string) { +function getPendingActionId( + type: MomentLogPendingAction['type'], + momentLogId: string, +) { return `${type}:${momentLogId}`; } function dedupePendingActions(actions: MomentLogPendingAction[]) { - return Array.from(new Map(actions.map((action) => [action.id, action])).values()); + return Array.from( + new Map(actions.map((action) => [action.id, action])).values(), + ); } function remapPendingAction( @@ -129,7 +148,11 @@ export const useMomentLogStore = create()( const localLogsById = new Map(state.logs.map((log) => [log.id, log])); const visibleServerLogs = serverLogs .filter((log) => !pendingDeleteIds.has(log.id)) - .map((log) => (pendingEditIds.has(log.id) ? localLogsById.get(log.id) ?? log : log)); + .map((log) => + pendingEditIds.has(log.id) + ? (localLogsById.get(log.id) ?? log) + : log, + ); const serverLogIds = new Set(visibleServerLogs.map((log) => log.id)); const localOnlyLogs = state.logs.filter( (log) => !serverLogIds.has(log.id) && !pendingDeleteIds.has(log.id), @@ -143,7 +166,8 @@ export const useMomentLogStore = create()( set((state) => { const createActionId = getPendingActionId('create', momentLogId); const hasPendingDelete = state.pendingActions.some( - (action) => action.type === 'delete' && action.momentLogId === momentLogId, + (action) => + action.type === 'delete' && action.momentLogId === momentLogId, ); if (hasPendingDelete) { @@ -152,7 +176,9 @@ export const useMomentLogStore = create()( return { pendingActions: [ - ...state.pendingActions.filter((action) => action.id !== createActionId), + ...state.pendingActions.filter( + (action) => action.id !== createActionId, + ), { id: createActionId, momentLogId, @@ -167,7 +193,8 @@ export const useMomentLogStore = create()( set((state) => { const editActionId = getPendingActionId('edit', momentLogId); const hasPendingDelete = state.pendingActions.some( - (action) => action.type === 'delete' && action.momentLogId === momentLogId, + (action) => + action.type === 'delete' && action.momentLogId === momentLogId, ); if (hasPendingDelete) { @@ -176,7 +203,9 @@ export const useMomentLogStore = create()( return { pendingActions: [ - ...state.pendingActions.filter((action) => action.id !== editActionId), + ...state.pendingActions.filter( + (action) => action.id !== editActionId, + ), { id: editActionId, momentLogId, @@ -194,7 +223,9 @@ export const useMomentLogStore = create()( return { logs: state.logs.filter((item) => item.id !== log.id), pendingActions: [ - ...state.pendingActions.filter((action) => action.momentLogId !== log.id), + ...state.pendingActions.filter( + (action) => action.momentLogId !== log.id, + ), { id: deleteActionId, momentLogId: log.id, @@ -206,16 +237,22 @@ export const useMomentLogStore = create()( }), removePendingAction: (id) => set((state) => ({ - pendingActions: state.pendingActions.filter((action) => action.id !== id), + pendingActions: state.pendingActions.filter( + (action) => action.id !== id, + ), })), removeLog: (id) => set((state) => ({ logs: state.logs.filter((item) => item.id !== id), - pendingActions: state.pendingActions.filter((action) => action.momentLogId !== id), + pendingActions: state.pendingActions.filter( + (action) => action.momentLogId !== id, + ), })), resolveLocalLog: (localMomentLogId, serverLog) => set((state) => { - const hasLocalLog = state.logs.some((item) => item.id === localMomentLogId); + const hasLocalLog = state.logs.some( + (item) => item.id === localMomentLogId, + ); if (!hasLocalLog) { return state; @@ -224,7 +261,10 @@ export const useMomentLogStore = create()( const remappedActions = state.pendingActions .filter( (action) => - !(action.type === 'create' && action.momentLogId === localMomentLogId), + !( + action.type === 'create' && + action.momentLogId === localMomentLogId + ), ) .map((action) => action.momentLogId === localMomentLogId @@ -236,7 +276,8 @@ export const useMomentLogStore = create()( logs: sortByNewest([ serverLog, ...state.logs.filter( - (item) => item.id !== localMomentLogId && item.id !== serverLog.id, + (item) => + item.id !== localMomentLogId && item.id !== serverLog.id, ), ]), pendingActions: dedupePendingActions(remappedActions), @@ -244,7 +285,9 @@ export const useMomentLogStore = create()( }), updateLog: (id, patch) => set((state) => ({ - logs: state.logs.map((item) => (item.id === id ? { ...item, ...patch } : item)), + logs: state.logs.map((item) => + item.id === id ? { ...item, ...patch } : item, + ), })), }), { diff --git a/src/store/recommendationCacheStore.ts b/src/store/recommendationCacheStore.ts index 53b929f..9246d3a 100644 --- a/src/store/recommendationCacheStore.ts +++ b/src/store/recommendationCacheStore.ts @@ -23,7 +23,6 @@ type MoodRecommendationCacheParams = { recommendationMode?: MusicRecommendationMode; preferredGenres?: string[]; preferredMoods?: string[]; - topFilter?: string; travelStyles?: string[]; }; @@ -88,7 +87,6 @@ export function createMoodRecommendationsCacheKey(params?: MoodRecommendationCac preferredGenres: normalizeList(params?.preferredGenres), preferredMoods: normalizeList(params?.preferredMoods), recommendationMode: params?.recommendationMode ?? 'everyday', - topFilter: params?.topFilter ?? '전체', travelStyles: normalizeList(params?.travelStyles), }); } diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts index 91a412c..bc281ff 100644 --- a/src/store/recommendationEventStore.ts +++ b/src/store/recommendationEventStore.ts @@ -20,7 +20,6 @@ export type RecommendationEventType = | 'live_track_shared' | 'nearby_sound_opened' | 'recommendation_mode_change' - | 'top_filter_change' | 'recap_representative_track_select'; export type RecommendationEventContext = { @@ -30,7 +29,6 @@ export type RecommendationEventContext = { placeId?: string; placeName?: string; source?: string; - topFilter?: string; travelMode?: TravelMode; }; diff --git a/src/store/travelLogSyncStore.ts b/src/store/travelLogSyncStore.ts new file mode 100644 index 0000000..c0360c8 --- /dev/null +++ b/src/store/travelLogSyncStore.ts @@ -0,0 +1,61 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import type { GeoPoint, RecapTemplateId, RoutePoint } from '@/types/domain'; + +export type PendingTravelLogFinalization = { + endedAt: string; + id: string; + location?: GeoPoint; + queuedAt: string; + routePoints: RoutePoint[]; + sessionId: string; + templateId: RecapTemplateId; + title: string; +}; + +type TravelLogSyncState = { + pendingFinalizations: PendingTravelLogFinalization[]; + queueFinalization: ( + input: Omit, + ) => void; + removeFinalization: (id: string) => void; +}; + +function getFinalizationId(sessionId: string) { + return `travel-log:${sessionId}`; +} + +export const useTravelLogSyncStore = create()( + persist( + (set) => ({ + pendingFinalizations: [], + queueFinalization: (input) => + set((state) => { + const id = getFinalizationId(input.sessionId); + + return { + pendingFinalizations: [ + ...state.pendingFinalizations.filter((item) => item.id !== id), + { + ...input, + id, + queuedAt: new Date().toISOString(), + }, + ], + }; + }), + removeFinalization: (id) => + set((state) => ({ + pendingFinalizations: state.pendingFinalizations.filter( + (item) => item.id !== id, + ), + })), + }), + { + name: 'soundlog-travel-log-finalizations', + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); diff --git a/src/store/travelSessionStore.ts b/src/store/travelSessionStore.ts index 875ce8a..e6687f8 100644 --- a/src/store/travelSessionStore.ts +++ b/src/store/travelSessionStore.ts @@ -6,6 +6,7 @@ import { GeoPoint, MusicRecommendationMode, PlaceContext, + RoutePoint, TravelMode, } from '@/types/domain'; @@ -15,6 +16,7 @@ type TravelSession = { endedAt?: string; id: string; recapId?: string; + routePoints: RoutePoint[]; startedAt?: string; status: 'idle' | 'active' | 'ended'; }; @@ -27,6 +29,7 @@ type TravelSessionState = { recommendationMode: MusicRecommendationMode; selectedMode?: TravelMode; session: TravelSession; + appendRoutePoint: (point: RoutePoint) => void; clearLocation: () => void; endSession: () => void; resetSession: () => void; @@ -36,20 +39,48 @@ type TravelSessionState = { setMode: (mode: TravelMode) => void; setRecommendationMode: (mode: MusicRecommendationMode) => void; setSessionRecapId: (recapId?: string) => void; - startSession: (session?: Partial>) => void; + startSession: (session?: Partial>) => void; }; const idleSession: TravelSession = { id: 'local-session', + routePoints: [], status: 'idle', }; +const MAX_ROUTE_POINTS = 500; + export const useTravelSessionStore = create()( persist( (set, get) => ({ session: idleSession, locationStatus: 'idle', recommendationMode: 'everyday', + appendRoutePoint: (point) => + set((state) => { + if (state.session.status !== 'active') { + return {}; + } + + const routePoints = state.session.routePoints ?? []; + const lastPoint = routePoints.at(-1); + + if ( + lastPoint && + lastPoint.lat === point.lat && + lastPoint.lng === point.lng && + lastPoint.recordedAt === point.recordedAt + ) { + return {}; + } + + return { + session: { + ...state.session, + routePoints: [...routePoints, point].slice(-MAX_ROUTE_POINTS), + }, + }; + }), clearLocation: () => set({ currentLocation: undefined, @@ -97,12 +128,27 @@ export const useTravelSessionStore = create()( set({ session: { id: session?.id ?? `session-${Date.now()}`, + routePoints: session?.routePoints ?? [], startedAt: session?.startedAt ?? new Date().toISOString(), status: 'active', }, }), }), { + merge: (persistedState, currentState) => { + const persisted = persistedState as Partial | undefined; + const persistedSession = persisted?.session; + + return { + ...currentState, + ...persisted, + session: { + ...idleSession, + ...persistedSession, + routePoints: persistedSession?.routePoints ?? [], + }, + }; + }, name: 'soundlog-travel-session', partialize: (state) => ({ currentLocation: state.currentLocation, diff --git a/src/types/domain.ts b/src/types/domain.ts index 26fe1c6..bb935d2 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -3,8 +3,14 @@ export type GeoPoint = { lng: number; }; +export type RoutePoint = GeoPoint & { + accuracyMeters?: number; + recordedAt: string; +}; + export type PlaceContext = { address?: string; + attribution?: string; category?: string; contentType?: string; distanceMeters?: number; @@ -12,11 +18,17 @@ export type PlaceContext = { imageUrl?: string; location?: GeoPoint; overview?: string; - source: 'seed' | 'tour-api'; + source: 'reverse-geocode' | 'seed' | 'tour-api' | 'user'; title: string; }; -export type TravelMode = 'walk' | 'drive' | 'cafe' | 'ocean' | 'festival' | 'night'; +export type TravelMode = + | 'walk' + | 'drive' + | 'cafe' + | 'ocean' + | 'festival' + | 'night'; export type MusicRecommendationMode = 'everyday' | 'travel'; @@ -24,7 +36,11 @@ export type MoodTag = 'calm' | 'fresh' | 'emotional' | 'active' | 'local'; export type MusicPlatformId = 'none' | 'spotify' | 'youtubeMusic' | 'youtube'; -export type ExternalMusicPlatformId = 'melon' | 'spotify' | 'youtube' | 'youtubeMusic'; +export type ExternalMusicPlatformId = + | 'melon' + | 'spotify' + | 'youtube' + | 'youtubeMusic'; export type PlaylistRecommendationSource = | 'ml-recommendation' @@ -68,6 +84,7 @@ export type MoodRecommendation = { subtitle?: string; color: string; genres?: string[]; + imageUrl?: string; moods?: string[]; playlistId?: string; track: Track; @@ -119,12 +136,16 @@ export type MomentLog = { placeCategory?: string; placeId?: string; placeName?: string; + recapId?: string; + recapVisibility?: RecapVisibility; note?: string; track?: Track; travelMode?: TravelMode; moodTags: MoodTag[]; source: 'camera'; + syncError?: string; syncStatus: 'failed' | 'local' | 'pending' | 'synced'; + templateId?: RecapTemplateId; }; export type RecapItem = { @@ -133,23 +154,63 @@ export type RecapItem = { placeName: string; representativeTrack: Track; createdAt: string; + backgroundImageUrl?: string; momentCount?: number; sessionId?: string; + thumbnailMomentId?: string; + visibility?: RecapVisibility; }; export type RecapTemplateId = 'album' | 'film' | 'lp' | 'map'; +export type RecapVisibility = 'private' | 'public'; + +export type RecapMapScope = 'mine' | 'public'; + +export type RecapMapMarker = { + artistName: string; + createdAt: string; + distanceMeters?: number; + id: string; + imageUrl?: string; + location: GeoPoint; + ownerAlias: string; + placeName: string; + recapId: string; + templateId: RecapTemplateId; + title: string; + trackTitle: string; + visibility: RecapVisibility; +}; + export type RecapShareMoment = { id: string; imageUrl?: string; + location?: GeoPoint; placeName: string; trackTitle: string; artistName: string; recordedAt: string; + templateId?: RecapTemplateId; + track?: Track; + visibility?: RecapVisibility; +}; + +export type RecapTravelSummary = { + distanceMeters: number; + durationMinutes: number; + endedAt?: string; + endPlaceName: string; + placeNames: string[]; + recordedLocationCount: number; + routePointCount?: number; + startedAt?: string; + startPlaceName: string; }; export type RecapShare = { id: string; + isMine?: boolean; placeName: string; trackTitle: string; artistName: string; @@ -157,7 +218,13 @@ export type RecapShare = { discImageUrl?: string; moments?: RecapShareMoment[]; recordedAt: string; + routePoints?: RoutePoint[]; + sessionId?: string; shareImageUrl?: string; + templateId?: RecapTemplateId; + thumbnailMomentId?: string; + travelSummary?: RecapTravelSummary; + visibility?: RecapVisibility; }; export type CommunityVisibility = 'companions' | 'nearby' | 'private'; @@ -232,7 +299,9 @@ export type MusicMatch = { matchScore: number; safety: { exactLocationHidden: boolean; - firstMessageTemplates: Array<'cafe_together' | 'liked_track' | 'walk_together'>; + firstMessageTemplates: Array< + 'cafe_together' | 'liked_track' | 'walk_together' + >; contactHiddenUntilAccepted: boolean; }; }; diff --git a/src/utils/accountSession.ts b/src/utils/accountSession.ts new file mode 100644 index 0000000..2e33d0b --- /dev/null +++ b/src/utils/accountSession.ts @@ -0,0 +1,50 @@ +import { queryClient } from '@/providers/queryClient'; +import { useAuthStore } from '@/store/authStore'; +import { useHomeFilterStore } from '@/store/homeFilterStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { usePlayerStore } from '@/store/playerStore'; +import { useRecommendationCacheStore } from '@/store/recommendationCacheStore'; +import { useRecommendationEventStore } from '@/store/recommendationEventStore'; +import { useTravelRoomStore } from '@/store/travelRoomStore'; +import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; +import { useUserProfileStore } from '@/store/userProfileStore'; + +export function clearAccountSession() { + queryClient.clear(); + useMomentLogStore.setState({ logs: [], pendingActions: [] }); + useLibraryStore.setState({ + likedTracks: [], + savedTracks: [], + seededPlaylistIds: [], + }); + useTravelRoomStore.setState({ roomsById: {}, roomsBySessionId: {} }); + useTravelLogSyncStore.setState({ pendingFinalizations: [] }); + useTravelSessionStore.setState({ + currentLocation: undefined, + currentPlace: undefined, + locationStatus: 'idle', + locationUpdatedAt: undefined, + recommendationMode: 'everyday', + selectedMode: undefined, + session: { + id: 'local-session', + routePoints: [], + status: 'idle', + }, + }); + useRecommendationCacheStore.setState({ + featuredFallback: undefined, + featuredPlaylists: {}, + moodFallback: undefined, + moodRecommendations: {}, + }); + useRecommendationEventStore.getState().clearEvents(); + useHomeFilterStore.setState({ + selectedMoodFilter: '전체', + }); + usePlayerStore.getState().clearTrack(); + useUserProfileStore.getState().resetOnboarding(); + useAuthStore.getState().logoutLocal(); +} diff --git a/src/utils/localDataMigration.ts b/src/utils/localDataMigration.ts index 22cb9dd..190a8b2 100644 --- a/src/utils/localDataMigration.ts +++ b/src/utils/localDataMigration.ts @@ -27,7 +27,9 @@ export type LocalDataMigrationSyncResult = { summary: LocalDataMigrationSummary; }; -function momentLogCreatePayloadFromLog(log: MomentLog): MomentLogCreateQueuePayload { +function momentLogCreatePayloadFromLog( + log: MomentLog, +): MomentLogCreateQueuePayload { return { createdAt: log.createdAt, location: log.location, @@ -37,7 +39,9 @@ function momentLogCreatePayloadFromLog(log: MomentLog): MomentLogCreateQueuePayl placeCategory: log.placeCategory, placeId: log.placeId, placeName: log.placeName, + recapVisibility: 'private', sessionId: log.sessionId, + templateId: log.templateId, track: log.track, travelMode: log.travelMode, }; @@ -71,13 +75,8 @@ async function syncCompletedLocalProfile() { } async function syncLocalMomentLogs() { - const { - logs, - pendingActions, - queueCreate, - resolveLocalLog, - updateLog, - } = useMomentLogStore.getState(); + const { logs, pendingActions, queueCreate, resolveLocalLog, updateLog } = + useMomentLogStore.getState(); let syncedCount = 0; let failedCount = 0; @@ -89,7 +88,8 @@ async function syncLocalMomentLogs() { const queuedCreateAction = pendingActions .filter(isCreatePendingAction) .find((action) => action.momentLogId === log.id); - const payload = queuedCreateAction?.payload ?? momentLogCreatePayloadFromLog(log); + const payload = + queuedCreateAction?.payload ?? momentLogCreatePayloadFromLog(log); queueCreate(log.id, payload); updateLog(log.id, { syncStatus: 'pending' }); diff --git a/src/utils/momentLogSync.ts b/src/utils/momentLogSync.ts new file mode 100644 index 0000000..0984680 --- /dev/null +++ b/src/utils/momentLogSync.ts @@ -0,0 +1,189 @@ +import { ApiError, shouldAttemptAuthenticatedApi } from "@/api/client"; +import { momentLogApi } from "@/api/momentLogApi"; +import { recapApi } from "@/api/recapApi"; +import { + useMomentLogStore, + type MomentLogPendingAction, +} from "@/store/momentLogStore"; + +export type MomentLogSyncResult = { + failureCount: number; + successCount: number; +}; + +let activeFlushPromise: Promise | undefined; + +async function syncCreateAction( + action: Extract, +) { + const store = useMomentLogStore.getState(); + const localMoment = store.logs.find( + (moment) => moment.id === action.momentLogId, + ); + + if (!localMoment) { + store.removePendingAction(action.id); + return; + } + + store.updateLog(action.momentLogId, { + syncError: undefined, + syncStatus: "pending", + }); + + const serverLog = await momentLogApi.createMomentLog({ + ...action.payload, + idempotencyKey: action.momentLogId, + }); + + if (!serverLog) { + throw new Error("Moment create was not accepted by the server."); + } + + let recapId: string | undefined; + + if (!action.payload.sessionId) { + const recap = await recapApi.createRecap( + { + momentLogIds: [serverLog.id], + templateId: action.payload.templateId ?? "film", + visibility: action.payload.recapVisibility ?? "private", + }, + `standalone-recap:${action.momentLogId}`, + ); + + if (!recap) { + throw new Error( + "Standalone recap create was not accepted by the server.", + ); + } + + recapId = recap.id; + } + + useMomentLogStore.getState().resolveLocalLog(action.momentLogId, { + ...serverLog, + recapId, + recapVisibility: action.payload.recapVisibility, + templateId: action.payload.templateId, + }); +} + +async function syncDeleteAction( + action: Extract, +) { + try { + const accepted = await momentLogApi.deleteMomentLog(action.momentLogId); + + if (!accepted) { + throw new Error("Moment delete was not accepted by the server."); + } + } catch (error) { + if (!(error instanceof ApiError && error.status === 404)) { + throw error; + } + } + + useMomentLogStore.getState().removePendingAction(action.id); +} + +async function syncEditAction( + action: Extract, +) { + const store = useMomentLogStore.getState(); + const localMoment = store.logs.find( + (moment) => moment.id === action.momentLogId, + ); + + if (!localMoment) { + store.removePendingAction(action.id); + return; + } + + if (action.payload.removePhoto) { + const updatedLog = await momentLogApi.deleteMomentLogPhoto( + action.momentLogId, + ); + + if (!updatedLog) { + throw new Error("Moment photo delete was not accepted by the server."); + } + } else if (action.payload.replacePhotoUri) { + const updatedLog = await momentLogApi.updateMomentLogPhoto( + action.momentLogId, + action.payload.replacePhotoUri, + ); + + if (!updatedLog) { + throw new Error("Moment photo update was not accepted by the server."); + } + } + + const serverLog = await momentLogApi.updateMomentLog(action.momentLogId, { + moodTags: action.payload.moodTags, + note: action.payload.note, + placeName: action.payload.placeName, + track: action.payload.track, + }); + + if (!serverLog) { + throw new Error("Moment edit was not accepted by the server."); + } + + const nextStore = useMomentLogStore.getState(); + nextStore.updateLog(action.momentLogId, serverLog); + nextStore.removePendingAction(action.id); +} + +async function syncAction(action: MomentLogPendingAction) { + if (action.type === "create") { + await syncCreateAction(action); + return; + } + + if (action.type === "delete") { + await syncDeleteAction(action); + return; + } + + await syncEditAction(action); +} + +async function performFlush(): Promise { + if (!shouldAttemptAuthenticatedApi()) { + return { failureCount: 0, successCount: 0 }; + } + + const actions = [...useMomentLogStore.getState().pendingActions]; + let failureCount = 0; + let successCount = 0; + + for (const action of actions) { + try { + await syncAction(action); + successCount += 1; + } catch (error) { + failureCount += 1; + + if (action.type === "create") { + useMomentLogStore.getState().updateLog(action.momentLogId, { + syncError: + error instanceof Error + ? error.message + : "리캡을 서버와 동기화하지 못했어요.", + syncStatus: "failed", + }); + } + } + } + + return { failureCount, successCount }; +} + +export function flushPendingMomentActions() { + activeFlushPromise ??= performFlush().finally(() => { + activeFlushPromise = undefined; + }); + + return activeFlushPromise; +} diff --git a/src/utils/momentPhotoPicker.ts b/src/utils/momentPhotoPicker.ts index 789434b..c4fcb6d 100644 --- a/src/utils/momentPhotoPicker.ts +++ b/src/utils/momentPhotoPicker.ts @@ -9,6 +9,35 @@ export type PickMomentPhotoResult = status: 'cancelled' | 'permission-denied' | 'unavailable'; }; +export async function pickMomentPhotoFromLibrary(): Promise { + try { + const ImagePicker = await import('expo-image-picker'); + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(false); + + if (!permission.granted) { + return { status: 'permission-denied' }; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: false, + allowsMultipleSelection: false, + mediaTypes: ['images'], + quality: 0.92, + }); + + if (result.canceled || !result.assets[0]?.uri) { + return { status: 'cancelled' }; + } + + return { + status: 'selected', + uri: result.assets[0].uri, + }; + } catch { + return { status: 'unavailable' }; + } +} + export async function pickMomentReplacementPhoto(momentLogId: string): Promise { try { const ImagePicker = await import('expo-image-picker'); diff --git a/src/utils/placeLabel.ts b/src/utils/placeLabel.ts index 8a7965d..ab334bd 100644 --- a/src/utils/placeLabel.ts +++ b/src/utils/placeLabel.ts @@ -1,9 +1,25 @@ -import { GeoPoint } from '@/types/domain'; +import { GeoPoint, PlaceContext } from '@/types/domain'; + +const COORDINATE_PLACE_LABEL = + /(?:현재\s*위치|위치)?\s*-?\d{1,3}(?:\.\d+)?\s*[,/]\s*-?\d{1,3}(?:\.\d+)?/; + +export function getPlaceDisplayTitle( + place?: PlaceContext, + fallback = '선택한 지역', +) { + const title = place?.title.trim(); + + if (!title || COORDINATE_PLACE_LABEL.test(title)) { + return fallback; + } + + return title; +} export function formatPlaceLabel(location?: GeoPoint) { if (!location) { return '위치 없음'; } - return `현재 위치 ${location.lat.toFixed(3)}, ${location.lng.toFixed(3)}`; + return '위치가 저장된 지역'; } diff --git a/src/utils/recapMapClustering.ts b/src/utils/recapMapClustering.ts new file mode 100644 index 0000000..fcec73f --- /dev/null +++ b/src/utils/recapMapClustering.ts @@ -0,0 +1,225 @@ +import type { GeoPoint, RecapMapMarker } from '@/types/domain'; + +export type RecapMarkerCluster = { + id: string; + location: GeoPoint; + markers: RecapMapMarker[]; +}; + +export type RecapMapRegion = { + latitude: number; + latitudeDelta: number; + longitude: number; + longitudeDelta: number; +}; + +export type RecapMapClusteringViewport = { + height: number; + region: RecapMapRegion; + width: number; +}; + +export const RECAP_MAP_PIN_DIAMETER_PX = 44; + +const MAX_MERCATOR_LATITUDE = 85.051_128_78; + +function clampLatitude(latitude: number) { + return Math.max( + -MAX_MERCATOR_LATITUDE, + Math.min(MAX_MERCATOR_LATITUDE, latitude), + ); +} + +function toMercatorY(latitude: number) { + const radians = (clampLatitude(latitude) * Math.PI) / 180; + + return Math.log(Math.tan(Math.PI / 4 + radians / 2)); +} + +function getWrappedLongitudeDelta(from: number, to: number) { + const rawDelta = Math.abs(from - to) % 360; + + return Math.min(rawDelta, 360 - rawDelta); +} + +function normalizeLongitude(longitude: number) { + return ((((longitude + 180) % 360) + 360) % 360) - 180; +} + +function isValidViewport( + viewport: RecapMapClusteringViewport | undefined, +): viewport is RecapMapClusteringViewport { + if (!viewport) { + return false; + } + + const { height, region, width } = viewport; + + return ( + Number.isFinite(height) && + Number.isFinite(width) && + Number.isFinite(region.latitude) && + Number.isFinite(region.latitudeDelta) && + Number.isFinite(region.longitude) && + Number.isFinite(region.longitudeDelta) && + height > 0 && + width > 0 && + region.latitudeDelta > 0 && + region.longitudeDelta > 0 + ); +} + +function getVerticalPixelDistance( + from: GeoPoint, + to: GeoPoint, + viewport: RecapMapClusteringViewport, +) { + const northLatitude = + viewport.region.latitude + viewport.region.latitudeDelta / 2; + const southLatitude = + viewport.region.latitude - viewport.region.latitudeDelta / 2; + const mercatorSpan = Math.abs( + toMercatorY(northLatitude) - toMercatorY(southLatitude), + ); + + if (mercatorSpan === 0) { + return Number.POSITIVE_INFINITY; + } + + return ( + (Math.abs(toMercatorY(from.lat) - toMercatorY(to.lat)) / mercatorSpan) * + viewport.height + ); +} + +function doMarkerPinsOverlap( + from: RecapMapMarker, + to: RecapMapMarker, + viewport: RecapMapClusteringViewport, + pinDiameterPx: number, +) { + const horizontalPixelDistance = + (getWrappedLongitudeDelta(from.location.lng, to.location.lng) / + viewport.region.longitudeDelta) * + viewport.width; + const verticalPixelDistance = getVerticalPixelDistance( + from.location, + to.location, + viewport, + ); + + return ( + Math.hypot(horizontalPixelDistance, verticalPixelDistance) <= pinDiameterPx + ); +} + +function getClusterCenter(markers: RecapMapMarker[]): GeoPoint { + const referenceLongitude = markers[0].location.lng; + const total = markers.reduce( + (sum, marker) => ({ + lat: sum.lat + marker.location.lat, + lng: + sum.lng + + referenceLongitude + + normalizeLongitude(marker.location.lng - referenceLongitude), + }), + { lat: 0, lng: 0 }, + ); + + return { + lat: total.lat / markers.length, + lng: normalizeLongitude(total.lng / markers.length), + }; +} + +function createStableClusterId(markers: RecapMapMarker[]) { + const value = markers + .map((marker) => marker.id) + .sort((left, right) => left.localeCompare(right)) + .join('|'); + let hash = 2_166_136_261; + + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + + return `recap-cluster-${(hash >>> 0).toString(36)}`; +} + +export function clusterRecapMarkers( + markers: RecapMapMarker[], + viewport?: RecapMapClusteringViewport, + pinDiameterPx = RECAP_MAP_PIN_DIAMETER_PX, +): RecapMarkerCluster[] { + if ( + markers.length <= 1 || + !isValidViewport(viewport) || + !Number.isFinite(pinDiameterPx) || + pinDiameterPx <= 0 + ) { + return markers.map((marker) => ({ + id: marker.id, + location: marker.location, + markers: [marker], + })); + } + + const parents = markers.map((_, index) => index); + + function findRoot(index: number): number { + if (parents[index] !== index) { + parents[index] = findRoot(parents[index]); + } + + return parents[index]; + } + + function merge(leftIndex: number, rightIndex: number) { + const leftRoot = findRoot(leftIndex); + const rightRoot = findRoot(rightIndex); + + if (leftRoot !== rightRoot) { + parents[rightRoot] = leftRoot; + } + } + + for (let leftIndex = 0; leftIndex < markers.length; leftIndex += 1) { + for ( + let rightIndex = leftIndex + 1; + rightIndex < markers.length; + rightIndex += 1 + ) { + if ( + doMarkerPinsOverlap( + markers[leftIndex], + markers[rightIndex], + viewport, + pinDiameterPx, + ) + ) { + merge(leftIndex, rightIndex); + } + } + } + + const buckets = new Map(); + + markers.forEach((marker, index) => { + const root = findRoot(index); + const bucket = buckets.get(root); + + if (bucket) { + bucket.push(marker); + return; + } + + buckets.set(root, [marker]); + }); + + return Array.from(buckets.values()).map((bucket) => ({ + id: createStableClusterId(bucket), + location: getClusterCenter(bucket), + markers: bucket, + })); +} diff --git a/src/utils/recapMappers.ts b/src/utils/recapMappers.ts index 980b68f..1caf11f 100644 --- a/src/utils/recapMappers.ts +++ b/src/utils/recapMappers.ts @@ -1,9 +1,16 @@ -import { MomentLog, RecapItem, RecapShare, RecapShareMoment } from '@/types/domain'; - -const FALLBACK_ARTIST = 'Soundlog'; -const FALLBACK_PLACE = '위치 없음'; -const FALLBACK_TITLE = '저장된 순간'; -export const SESSION_RECAP_ID_PREFIX = 'session-recap__'; +import { + MomentLog, + RecapItem, + RecapShare, + RecapShareMoment, + RoutePoint, +} from "@/types/domain"; +import { createRecapTravelSummary } from "@/utils/recapTravelSummary"; + +const FALLBACK_ARTIST = "Soundlog"; +const FALLBACK_PLACE = "위치 없음"; +const FALLBACK_TITLE = "저장된 리캡"; +export const SESSION_RECAP_ID_PREFIX = "session-recap__"; export type MomentLogGroup = { id: string; @@ -28,9 +35,13 @@ function momentLogToRecapShareMoment(log: MomentLog): RecapShareMoment { artistName: log.track?.artist ?? FALLBACK_ARTIST, id: log.id, imageUrl: log.photoUri, + location: log.location, placeName: log.placeName ?? FALLBACK_PLACE, recordedAt: log.createdAt, + templateId: log.templateId, + track: log.track, trackTitle: log.track?.title ?? FALLBACK_TITLE, + visibility: log.recapVisibility, }; } @@ -50,7 +61,9 @@ export function createMomentLogGroups(logs: MomentLog[]): MomentLogGroup[] { const groupMap = new Map(); logs.forEach((log) => { - const groupKey = log.sessionId ? `session:${log.sessionId}` : `log:${log.id}`; + const groupKey = log.sessionId + ? `session:${log.sessionId}` + : `log:${log.id}`; const existingGroup = groupMap.get(groupKey); if (existingGroup) { @@ -59,7 +72,9 @@ export function createMomentLogGroups(logs: MomentLog[]): MomentLogGroup[] { } groupMap.set(groupKey, { - id: log.sessionId ? createSessionRecapId(log.sessionId) : log.id, + id: log.sessionId + ? createSessionRecapId(log.sessionId) + : (log.recapId ?? log.id), logs: [log], sessionId: log.sessionId, }); @@ -69,7 +84,8 @@ export function createMomentLogGroups(logs: MomentLog[]): MomentLogGroup[] { .map((group) => ({ ...group, logs: [...group.logs].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ), })) .sort((a, b) => { @@ -94,12 +110,13 @@ export function momentLogGroupToRecapItem(group: MomentLogGroup): RecapItem { placeName: FALLBACK_PLACE, representativeTrack: { artist: FALLBACK_ARTIST, - fallbackColor: '#2B176C', + fallbackColor: "#2B176C", id: `${group.id}-fallback-track`, title: FALLBACK_TITLE, }, sessionId: group.sessionId, - title: '여행 Recap', + title: "여행 로그", + visibility: "private", }; } @@ -112,21 +129,42 @@ export function momentLogGroupToRecapItem(group: MomentLogGroup): RecapItem { id: group.id, momentCount, sessionId: group.sessionId, - title: momentCount > 1 ? `${placeName} 여행 기록` : baseItem.title, + title: group.sessionId ? `${placeName} 여행 로그` : baseItem.title, + visibility: representativeLog.recapVisibility ?? "private", }; } -export function momentLogGroupToRecapShare(group: MomentLogGroup): RecapShare | undefined { +export function momentLogGroupToRecapShare( + group: MomentLogGroup, + timing: { + endedAt?: string; + routePoints?: RoutePoint[]; + startedAt?: string; + } = {}, +): RecapShare | undefined { const representativeLog = getNewestLog(group.logs); if (!representativeLog) { return undefined; } + const moments = getOldestFirstLogs(group.logs).map( + momentLogToRecapShareMoment, + ); + return { ...momentLogToRecapShare(representativeLog), id: group.id, - moments: getOldestFirstLogs(group.logs).map(momentLogToRecapShareMoment), + moments, + routePoints: timing.routePoints, + sessionId: group.sessionId, + travelSummary: createRecapTravelSummary({ + endedAt: timing.endedAt, + fallbackPlaceName: representativeLog.placeName ?? FALLBACK_PLACE, + moments, + routePoints: timing.routePoints, + startedAt: timing.startedAt, + }), }; } @@ -137,23 +175,33 @@ export function momentLogToRecapItem(log: MomentLog): RecapItem { placeName: log.placeName ?? FALLBACK_PLACE, representativeTrack: log.track ?? { artist: FALLBACK_ARTIST, - fallbackColor: '#2B176C', + fallbackColor: "#2B176C", id: `${log.id}-fallback-track`, title: FALLBACK_TITLE, }, title: log.track?.title ?? FALLBACK_TITLE, + visibility: log.recapVisibility ?? "private", }; } export function momentLogToRecapShare(log: MomentLog): RecapShare { + const moments = [momentLogToRecapShareMoment(log)]; + return { artistName: log.track?.artist ?? FALLBACK_ARTIST, backgroundImageUrl: log.photoUri, discImageUrl: log.photoUri, id: log.id, - moments: [momentLogToRecapShareMoment(log)], + isMine: true, + moments, placeName: log.placeName ?? FALLBACK_PLACE, recordedAt: log.createdAt, + templateId: log.templateId ?? "album", trackTitle: log.track?.title ?? FALLBACK_TITLE, + travelSummary: createRecapTravelSummary({ + fallbackPlaceName: log.placeName ?? FALLBACK_PLACE, + moments, + }), + visibility: log.recapVisibility ?? "private", }; } diff --git a/src/utils/recapTravelSummary.ts b/src/utils/recapTravelSummary.ts new file mode 100644 index 0000000..2af98d0 --- /dev/null +++ b/src/utils/recapTravelSummary.ts @@ -0,0 +1,147 @@ +import type { + GeoPoint, + RecapShare, + RecapShareMoment, + RecapTravelSummary, + RoutePoint, +} from '@/types/domain'; + +const EARTH_RADIUS_METERS = 6371000; +const FALLBACK_ARTIST = 'Soundlog'; +const FALLBACK_PLACE = '위치 없음'; +const FALLBACK_TITLE = '저장된 리캡'; + +type TravelSummaryInput = { + endedAt?: string; + fallbackPlaceName?: string; + moments: RecapShareMoment[]; + routePoints?: RoutePoint[]; + startedAt?: string; +}; + +function toTime(value?: string) { + if (!value) { + return undefined; + } + + const time = new Date(value).getTime(); + + return Number.isNaN(time) ? undefined : time; +} + +function toRadians(value: number) { + return (value * Math.PI) / 180; +} + +export function getDistanceMeters(first: GeoPoint, second: GeoPoint) { + const latDistance = toRadians(second.lat - first.lat); + const lngDistance = toRadians(second.lng - first.lng); + const firstLat = toRadians(first.lat); + const secondLat = toRadians(second.lat); + const haversine = + Math.sin(latDistance / 2) ** 2 + + Math.cos(firstLat) * Math.cos(secondLat) * Math.sin(lngDistance / 2) ** 2; + + return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); +} + +function getUniquePlaces(moments: RecapShareMoment[]) { + const places: string[] = []; + + moments.forEach((moment) => { + const placeName = moment.placeName.trim(); + + if (placeName && places[places.length - 1] !== placeName) { + places.push(placeName); + } + }); + + return places; +} + +export function createFallbackRecapMoment(recap: RecapShare): RecapShareMoment { + return { + artistName: recap.artistName || FALLBACK_ARTIST, + id: recap.id, + imageUrl: recap.backgroundImageUrl, + placeName: recap.placeName || FALLBACK_PLACE, + recordedAt: recap.recordedAt, + trackTitle: recap.trackTitle || FALLBACK_TITLE, + }; +} + +export function getRecapSoundLogs(recap: RecapShare) { + const moments = recap.moments?.length ? recap.moments : [createFallbackRecapMoment(recap)]; + + return [...moments].sort((first, second) => { + const firstTime = toTime(first.recordedAt) ?? 0; + const secondTime = toTime(second.recordedAt) ?? 0; + + return firstTime - secondTime; + }); +} + +export function createRecapTravelSummary({ + endedAt, + fallbackPlaceName = FALLBACK_PLACE, + moments, + routePoints = [], + startedAt, +}: TravelSummaryInput): RecapTravelSummary { + const orderedMoments = [...moments].sort((first, second) => { + const firstTime = toTime(first.recordedAt) ?? 0; + const secondTime = toTime(second.recordedAt) ?? 0; + + return firstTime - secondTime; + }); + const firstMoment = orderedMoments[0]; + const lastMoment = orderedMoments[orderedMoments.length - 1]; + const summaryStartedAt = startedAt ?? firstMoment?.recordedAt; + const summaryEndedAt = endedAt ?? lastMoment?.recordedAt ?? summaryStartedAt; + const startTime = toTime(summaryStartedAt); + const endTime = toTime(summaryEndedAt); + const durationMinutes = + startTime !== undefined && endTime !== undefined + ? Math.max(0, Math.round((endTime - startTime) / 60000)) + : 0; + const locatedMoments = orderedMoments.filter( + (moment): moment is RecapShareMoment & { location: GeoPoint } => Boolean(moment.location), + ); + const routeLocations = routePoints.length > 1 ? routePoints : locatedMoments.map((moment) => moment.location); + const distanceMeters = routeLocations.reduce((distance, location, index) => { + const previousLocation = routeLocations[index - 1]; + + if (!previousLocation) { + return distance; + } + + return distance + getDistanceMeters(previousLocation, location); + }, 0); + const placeNames = getUniquePlaces(orderedMoments); + const startPlaceName = firstMoment?.placeName ?? placeNames[0] ?? fallbackPlaceName; + const endPlaceName = + lastMoment?.placeName ?? placeNames[placeNames.length - 1] ?? startPlaceName; + + return { + distanceMeters: Math.round(distanceMeters), + durationMinutes, + endedAt: summaryEndedAt, + endPlaceName, + placeNames: placeNames.length ? placeNames : [fallbackPlaceName], + recordedLocationCount: locatedMoments.length, + routePointCount: routePoints.length || undefined, + startedAt: summaryStartedAt, + startPlaceName, + }; +} + +export function getRecapTravelSummary(recap: RecapShare) { + return ( + recap.travelSummary ?? + createRecapTravelSummary({ + fallbackPlaceName: recap.placeName, + moments: getRecapSoundLogs(recap), + routePoints: recap.routePoints, + }) + ); +} diff --git a/src/utils/recommendationEventContext.ts b/src/utils/recommendationEventContext.ts index 146705c..b5e1227 100644 --- a/src/utils/recommendationEventContext.ts +++ b/src/utils/recommendationEventContext.ts @@ -5,7 +5,7 @@ import { useTravelSessionStore } from '@/store/travelSessionStore'; export function createRecommendationEventContext( overrides: RecommendationEventContext = {}, ): RecommendationEventContext { - const { selectedMoodFilter, selectedTopFilter } = useHomeFilterStore.getState(); + const { selectedMoodFilter } = useHomeFilterStore.getState(); const { currentPlace, recommendationMode, selectedMode } = useTravelSessionStore.getState(); @@ -15,7 +15,6 @@ export function createRecommendationEventContext( placeCategory: currentPlace?.category, placeId: currentPlace?.id, placeName: currentPlace?.title, - topFilter: selectedTopFilter, travelMode: selectedMode, ...overrides, }; diff --git a/src/utils/trackSanitizer.ts b/src/utils/trackSanitizer.ts index 8d1a0f6..f789121 100644 --- a/src/utils/trackSanitizer.ts +++ b/src/utils/trackSanitizer.ts @@ -27,6 +27,7 @@ export function sanitizeTrack(track: Track): Track { export function sanitizeMoodRecommendation(item: MoodRecommendation): MoodRecommendation { return { ...item, + imageUrl: sanitizeUrl(item.imageUrl), track: sanitizeTrack(item.track), }; } diff --git a/src/utils/travelLogSync.ts b/src/utils/travelLogSync.ts new file mode 100644 index 0000000..8cf7cd2 --- /dev/null +++ b/src/utils/travelLogSync.ts @@ -0,0 +1,111 @@ +import { ApiError, shouldAttemptAuthenticatedApi } from '@/api/client'; +import { recapApi } from '@/api/recapApi'; +import { travelSessionApi } from '@/api/travelSessionApi'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; + +export type TravelLogSyncResult = { + createdRecapIds: Record; + deferredCount: number; + failureCount: number; + successCount: number; +}; + +let activeFlushPromise: Promise | undefined; + +async function performFlush(): Promise { + const result: TravelLogSyncResult = { + createdRecapIds: {}, + deferredCount: 0, + failureCount: 0, + successCount: 0, + }; + + if (!shouldAttemptAuthenticatedApi()) { + return result; + } + + const pendingFinalizations = [ + ...useTravelLogSyncStore.getState().pendingFinalizations, + ]; + + for (const finalization of pendingFinalizations) { + const momentState = useMomentLogStore.getState(); + + if (momentState.pendingActions.length > 0) { + result.deferredCount += 1; + continue; + } + + const sessionLogs = momentState.logs.filter( + (log) => log.sessionId === finalization.sessionId, + ); + + if (sessionLogs.length === 0) { + useTravelLogSyncStore.getState().removeFinalization(finalization.id); + result.successCount += 1; + continue; + } + + if (sessionLogs.some((log) => log.syncStatus !== 'synced')) { + result.deferredCount += 1; + continue; + } + + try { + try { + await travelSessionApi.endTravelSession(finalization.sessionId, { + endedAt: finalization.endedAt, + location: finalization.location, + routePoints: finalization.routePoints, + }); + } catch (error) { + if (!(error instanceof ApiError && error.status === 404)) { + throw error; + } + } + + const representativeTrackId = sessionLogs.find((log) => log.track?.id) + ?.track?.id; + const recap = await recapApi.createRecap( + { + momentLogIds: sessionLogs.map((log) => log.id), + representativeTrackId, + routePoints: finalization.routePoints, + sessionId: finalization.sessionId, + templateId: finalization.templateId, + title: finalization.title, + visibility: 'private', + }, + finalization.id, + ); + + if (!recap) { + throw new Error('Travel Log create was not accepted by the server.'); + } + + result.createdRecapIds[finalization.sessionId] = recap.id; + result.successCount += 1; + useTravelLogSyncStore.getState().removeFinalization(finalization.id); + + const travelSessionState = useTravelSessionStore.getState(); + + if (travelSessionState.session.id === finalization.sessionId) { + travelSessionState.setSessionRecapId(recap.id); + } + } catch { + result.failureCount += 1; + } + } + + return result; +} + +export function flushPendingTravelLogFinalizations() { + activeFlushPromise ??= performFlush().finally(() => { + activeFlushPromise = undefined; + }); + + return activeFlushPromise; +}