diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..209760a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# 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. diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6366e20..1d37a7a 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -8,15 +8,20 @@ import { useMoodRecommendationsQuery, useRecentMusicLogsQuery, } from '@/api/homeQueries'; +import { libraryApi } from '@/api/libraryApi'; import { meApi } from '@/api/meApi'; -import { playlistApi, PlaylistMlMood, PlaylistMlState } from '@/api/playlistApi'; -import { playlistQueryKeys } from '@/api/playlistQueries'; +import { PlaylistMlMood, PlaylistMlState } from '@/api/playlistApi'; +import { + playlistQueryKeys, + useRecommendedPlaylistQuery, +} 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 { HomeSoundtrackBottomSheet } from '@/components/home/HomeSoundtrackBottomSheet'; import { HomeHeader, HomeNavigationBar, @@ -37,6 +42,7 @@ import { momentLogToMusicLogItem, useMomentLogStore, } from '@/store/momentLogStore'; +import { useLibraryStore } from '@/store/libraryStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import { @@ -48,7 +54,15 @@ 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 { + FeaturedPlaylist, + MoodRecommendation, + MusicLogItem, + PlaylistCuration, + Track, + TravelMode, +} from '@/types/domain'; +import { toLibraryPlaylistSummary } from '@/utils/libraryPlaylistSummary'; import { requestForegroundLocationWithStatus } from '@/utils/location'; import { getMoodTagsFromFilter } from '@/utils/moodTags'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; @@ -117,11 +131,21 @@ function resolveCurrentMoodLabel(filter: string, preferredMoods: string[]) { return preferredMoods[0] ?? '잔잔한'; } +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 [creatingPlaylistId, setCreatingPlaylistId] = useState(); + const [isSoundtrackSheetVisible, setIsSoundtrackSheetVisible] = useState(false); const { selectedMoodFilter, selectedTopFilter, @@ -129,6 +153,15 @@ function HomeContent() { setSelectedTopFilter, } = useHomeFilterStore(); const { currentTrack, setTrack } = usePlayerStore(); + const { + isLiked, + isSaved, + likedTracks, + savedTracks, + seedFromPlaylist, + setLikeState, + setSaveState, + } = useLibraryStore(); const addRecommendationEvent = useRecommendationEventStore( (state) => state.addEvent, ); @@ -182,6 +215,27 @@ function HomeContent() { selectedTopFilter, ], ); + const recommendedPlaylistInput = useMemo( + () => ({ + 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, + }), + [ + currentLocation, + currentPlace?.id, + currentPlace?.location, + profile.preferredGenres, + profile.preferredMoods, + selectedMode, + selectedMoodFilter, + ], + ); const featuredCacheKey = useMemo( () => createFeaturedPlaylistsCacheKey(featuredPlaylistParams), [featuredPlaylistParams], @@ -204,10 +258,58 @@ function HomeContent() { 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 moodRecommendationsQuery = useMoodRecommendationsQuery(moodRecommendationParams, { enabled: isAuthenticated, }); const recentMusicLogsQuery = useRecentMusicLogsQuery({ 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 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 musicLogs = [ ...momentLogs.slice(0, 6).map(momentLogToMusicLogItem), ...(recentMusicLogsQuery.data ?? []), @@ -246,6 +348,29 @@ function HomeContent() { } }, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]); + 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]); + const handleSelectRecommendation = async (item: MoodRecommendation) => { setActionMessage(undefined); @@ -275,62 +400,33 @@ function HomeContent() { setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); }; const handleSelectFeaturedPlaylist = useCallback( - async (playlist: FeaturedPlaylist) => { - if (creatingPlaylistId) { - return; - } + (playlist: FeaturedPlaylist) => { + const isCurrentRecommendation = playlist.id === recommendedPlaylist?.id; 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; - - if (contextualPlaylist) { - queryClient.setQueryData( - playlistQueryKeys.detail(nextPlaylistId), - contextualPlaylist, - ); - } - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext({ - moodFilter: selectedMoodFilter, - source: contextualPlaylist.context?.source, - }), - playlistId: nextPlaylistId, - type: 'playlist_open', - value: nextPlaylistId, - }), + if (isCurrentRecommendation && recommendedPlaylist) { + queryClient.setQueryData( + playlistQueryKeys.detail(recommendedPlaylist.id), + recommendedPlaylist, ); - router.push(`/playlist/${nextPlaylistId}`); - } catch { - setActionMessage('맞춤 플레이리스트를 만들지 못했어요. 잠시 후 다시 시도해주세요.'); - } finally { - setCreatingPlaylistId(undefined); } + + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext({ + source: isCurrentRecommendation + ? recommendedPlaylist?.context?.source + : 'featured-playlist', + }), + playlistId: playlist.id, + type: 'playlist_open', + value: playlist.id, + }), + ); + router.push(`/playlist/${playlist.id}`); }, - [ - addRecommendationEvent, - creatingPlaylistId, - currentLocation, - currentPlace, - profile.preferredGenres, - profile.preferredMoods, - selectedMode, - selectedMoodFilter, - ], + [addRecommendationEvent, recommendedPlaylist], ); const handleSelectMusicLog = useCallback((item: MusicLogItem) => { router.push(`/recap-share/${item.recapShareId ?? item.id}`); @@ -442,6 +538,175 @@ function HomeContent() { 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; + } + + 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('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); + }, + [ + 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, + ], + ); + return ( router.push('/camera' as never)} - onOpenPlaylist={handleSelectFeaturedPlaylist} - onRetry={() => void featuredPlaylistsQuery.refetch()} - playlist={featuredPlaylistsQuery.data?.[0]} + onOpenPlaylist={handleOpenCurrentSoundtrack} + onRetry={handleRefreshCurrentSoundtrack} + playlist={currentSoundtrackPlaylist} + recommendationSource={recommendedPlaylist?.context?.source} travelLabel={resolveCurrentTravelLabel(selectedMode, profile.travelStyles)} /> @@ -516,7 +789,7 @@ function HomeContent() { + {currentTrack ? : null} ); diff --git a/src/api/playlistQueries.ts b/src/api/playlistQueries.ts index 45e47e8..d941cbb 100644 --- a/src/api/playlistQueries.ts +++ b/src/api/playlistQueries.ts @@ -1,9 +1,18 @@ 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) { @@ -13,3 +22,15 @@ export function usePlaylistCurationQuery(id?: string) { 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/components/home/CarouselProgress.tsx b/src/components/home/CarouselProgress.tsx index 7097ce0..3e51fe2 100644 --- a/src/components/home/CarouselProgress.tsx +++ b/src/components/home/CarouselProgress.tsx @@ -1,15 +1,33 @@ import { View } from 'react-native'; type CarouselProgressProps = { + contentVisibleRatio?: number; progress?: number; }; -export function CarouselProgress({ progress = 0.55 }: CarouselProgressProps) { - const width = Math.max(0, Math.min(progress, 1)) * 176; +const TRACK_WIDTH = 176; +const MIN_THUMB_RATIO = 0.24; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +export function CarouselProgress({ + contentVisibleRatio = 0.5, + progress = 0, +}: CarouselProgressProps) { + const thumbWidth = clamp(contentVisibleRatio, MIN_THUMB_RATIO, 1) * TRACK_WIDTH; + const translateX = clamp(progress, 0, 1) * (TRACK_WIDTH - thumbWidth); return ( - + ); } diff --git a/src/components/home/CurrentSoundtrackCard.tsx b/src/components/home/CurrentSoundtrackCard.tsx index be8824c..113529c 100644 --- a/src/components/home/CurrentSoundtrackCard.tsx +++ b/src/components/home/CurrentSoundtrackCard.tsx @@ -10,25 +10,51 @@ type CurrentSoundtrackCardProps = { isCached?: boolean; isError?: boolean; isLoading?: boolean; + isOpeningPlaylist?: boolean; moodLabel: string; + needsLocation?: boolean; onCaptureMoment: () => void; onOpenPlaylist: (playlist: FeaturedPlaylist) => void; onRetry?: () => void; playlist?: FeaturedPlaylist; + recommendationSource?: string; travelLabel: string; }; +function getRecommendationSourceBadge(source?: string) { + if (source === 'ml-recommendation') { + return { + className: 'bg-[#B7E628]/15', + label: 'ML 추천', + textClassName: 'text-[#B7E628]', + }; + } + + if (source === 'seed-fallback') { + return { + className: 'bg-amber-300/15', + label: '기본 추천', + textClassName: 'text-amber-100', + }; + } + + return undefined; +} + export function CurrentSoundtrackCard({ cachedAt, currentPlace, isCached = false, isError = false, isLoading = false, + isOpeningPlaylist = false, moodLabel, + needsLocation = false, onCaptureMoment, onOpenPlaylist, onRetry, playlist, + recommendationSource, travelLabel, }: CurrentSoundtrackCardProps) { const placeTitle = currentPlace?.title ?? '지금 위치 주변'; @@ -36,18 +62,24 @@ export function CurrentSoundtrackCard({ const playlistTitle = playlist?.regionName ?? `${travelLabel} 사운드트랙`; const playlistDescription = playlist?.description ?? - '현재 장소와 무드를 기준으로 오늘 들을 곡 목록을 준비하고 있어요.'; + (needsLocation + ? '위치를 확인하면 지금 상황에 맞춘 곡 목록을 준비할게요.' + : '현재 장소와 무드를 기준으로 오늘 들을 곡 목록을 준비하고 있어요.'); const trackMeta = playlist ? `${playlist.trackCount}곡 · ${playlist.durationText}` : isLoading ? '추천 준비 중' - : '샘플 추천 사용 가능'; + : needsLocation + ? '위치 확인 필요' + : '추천을 다시 시도해보세요'; const cacheCaption = cachedAt ? `최근 추천 · ${new Date(cachedAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', })}` : '최근 추천'; + const isPlaylistButtonDisabled = isLoading || isOpeningPlaylist; + const sourceBadge = getRecommendationSourceBadge(recommendationSource); const handleOpenPlaylist = () => { if (playlist) { @@ -84,6 +116,13 @@ export function CurrentSoundtrackCard({ ) : null} + {sourceBadge ? ( + + + {sourceBadge.label} + + + ) : null} 오늘의 사운드트랙 @@ -124,18 +163,22 @@ export function CurrentSoundtrackCard({ - + - 곡 보기 + {isOpeningPlaylist ? '여는 중' : needsLocation && !playlist ? '위치로 추천' : '곡 보기'} diff --git a/src/components/home/FeaturedPlaylistSection.tsx b/src/components/home/FeaturedPlaylistSection.tsx index 9b04625..3b60bff 100644 --- a/src/components/home/FeaturedPlaylistSection.tsx +++ b/src/components/home/FeaturedPlaylistSection.tsx @@ -1,4 +1,11 @@ -import { Pressable, ScrollView, View } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { + NativeScrollEvent, + NativeSyntheticEvent, + Pressable, + ScrollView, + View, +} from 'react-native'; import { AppText } from '@/components/AppText'; import { CarouselProgress } from '@/components/home/CarouselProgress'; @@ -37,12 +44,38 @@ 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 ( @@ -72,7 +105,15 @@ export function FeaturedPlaylistSection({ ) : ( - + 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/HomeSoundtrackBottomSheet.tsx b/src/components/home/HomeSoundtrackBottomSheet.tsx new file mode 100644 index 0000000..e6a9eb0 --- /dev/null +++ b/src/components/home/HomeSoundtrackBottomSheet.tsx @@ -0,0 +1,213 @@ +import { Feather } from '@expo/vector-icons'; +import { useEffect, useMemo, useRef } from 'react'; +import { + ActivityIndicator, + Animated, + 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; + isLoading?: boolean; + likedTrackIds: Set; + onClose: () => void; + onSelectTrack: (track: Track) => void; + onToggleLike: (track: Track) => void; + onToggleSave: (track: Track) => void; + playlist?: PlaylistCuration; + savedTrackIds: Set; + visible: boolean; +}; + +const CLOSE_DRAG_DISTANCE = 86; +const CLOSE_DRAG_VELOCITY = 0.75; + +export function HomeSoundtrackBottomSheet({ + actionMessage, + currentTrackId, + isLoading = false, + likedTrackIds, + onClose, + onSelectTrack, + onToggleLike, + onToggleSave, + playlist, + savedTrackIds, + visible, +}: HomeSoundtrackBottomSheetProps) { + const insets = useSafeAreaInsets(); + const { height } = useWindowDimensions(); + const translateY = useRef(new Animated.Value(0)).current; + const sheetMaxHeight = Math.min(height * 0.82, 680); + const listBottomPadding = insets.bottom + 32; + + useEffect(() => { + if (!visible) { + translateY.setValue(0); + return; + } + + translateY.setValue(32); + Animated.spring(translateY, { + damping: 22, + mass: 0.7, + stiffness: 210, + toValue: 0, + useNativeDriver: true, + }).start(); + }, [translateY, visible]); + + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gesture) => + gesture.dy > 8 && Math.abs(gesture.dy) > Math.abs(gesture.dx), + onPanResponderMove: (_, gesture) => { + translateY.setValue(Math.max(gesture.dy, 0)); + }, + onPanResponderRelease: (_, gesture) => { + if (gesture.dy > CLOSE_DRAG_DISTANCE || gesture.vy > CLOSE_DRAG_VELOCITY) { + onClose(); + return; + } + + Animated.spring(translateY, { + damping: 20, + stiffness: 220, + toValue: 0, + useNativeDriver: true, + }).start(); + }, + }), + [onClose, translateY], + ); + + return ( + + + + + + + + + + + 오늘의 사운드트랙 + + + {playlist?.regionName ?? '추천 곡'} + + {playlist ? ( + + {playlist.reason} + + ) : null} + + + + + + + {playlist ? ( + + + + {playlist.trackCount}곡 + + + + + {playlist.durationText} + + + + ) : null} + + + {isLoading ? ( + + + + 추천 곡을 불러오고 있어요 + + + ) : playlist ? ( + + + + ) : ( + + + 추천 곡을 열지 못했어요 + + + )} + + {actionMessage ? ( + + + {actionMessage} + + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + sheet: { + backgroundColor: '#08101D', + borderColor: 'rgba(255,255,255,0.14)', + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + borderWidth: 1, + overflow: 'hidden', + }, +}); diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx index 003a51c..3660dc7 100644 --- a/src/components/moment-capture/MomentCaptureScreen.tsx +++ b/src/components/moment-capture/MomentCaptureScreen.tsx @@ -8,7 +8,10 @@ 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 { + MomentReviewPanel, + type MomentReviewPanelHandle, +} from '@/components/moment-capture/MomentReviewPanel'; import { Screen } from '@/components/Screen'; import { useHomeFilterStore } from '@/store/homeFilterStore'; import { @@ -29,11 +32,15 @@ type LocationStatus = 'denied' | 'granted' | 'idle' | 'loading' | 'unavailable'; export function MomentCaptureScreen() { 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 [reviewMoodTags, setReviewMoodTags] = useState( + [], + ); const [reviewNote, setReviewNote] = useState(''); const [reviewPlaceName, setReviewPlaceName] = useState(''); const [shouldSaveMusic, setShouldSaveMusic] = useState(true); @@ -45,17 +52,31 @@ export function MomentCaptureScreen() { 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 } = - useTravelSessionStore(); + const { + currentLocation, + currentPlace, + selectedMode, + session, + setLocation, + startSession, + } = useTravelSessionStore(); - const moodTags = useMemo(() => getMoodTagsFromFilter(selectedMoodFilter), [selectedMoodFilter]); + const moodTags = useMemo( + () => getMoodTagsFromFilter(selectedMoodFilter), + [selectedMoodFilter], + ); const prepareReview = (photoUri?: string) => { + setCapturedAt(new Date().toISOString()); setCapturedPhotoUri(photoUri); - setReviewPlaceName(currentPlace?.title ?? formatPlaceLabel(currentLocation)); + setReviewPlaceName( + currentPlace?.title ?? formatPlaceLabel(currentLocation), + ); setReviewMoodTags(moodTags); setReviewNote(''); setShouldSaveMusic(Boolean(currentTrack)); @@ -84,12 +105,14 @@ export function MomentCaptureScreen() { setLocation(location); setLocationStatus('granted'); } else { - const fallbackLocation = useTravelSessionStore.getState().currentLocation; + const fallbackLocation = + useTravelSessionStore.getState().currentLocation; setLocationStatus(fallbackLocation ? 'granted' : 'unavailable'); } } catch { if (isMounted) { - const fallbackLocation = useTravelSessionStore.getState().currentLocation; + const fallbackLocation = + useTravelSessionStore.getState().currentLocation; setLocationStatus(fallbackLocation ? 'granted' : 'unavailable'); } } @@ -139,11 +162,23 @@ export function MomentCaptureScreen() { try { const id = `moment-${Date.now()}`; const locationSnapshot: GeoPoint | undefined = currentLocation; - const photoUri = capturedPhotoUri - ? await persistMomentPhoto(capturedPhotoUri, id) + 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 createdAt = capturedAt ?? new Date().toISOString(); + const placeName = + reviewPlaceName.trim() || formatPlaceLabel(locationSnapshot); const note = reviewNote.trim() || undefined; const trackSnapshot = shouldSaveMusic ? currentTrack : undefined; const recommendationContext = createRecommendationEventContext({ @@ -236,7 +271,8 @@ export function MomentCaptureScreen() { 여행을 먼저 시작해요 - 순간 저장은 현재 여행에 연결돼요. 여행을 시작하면 사진, 음악, 장소가 하나의 기록으로 묶입니다. + 순간 저장은 현재 여행에 연결돼요. 여행을 시작하면 사진, 음악, 장소가 + 하나의 기록으로 묶입니다. router.back()} > - 돌아가기 + + 돌아가기 + @@ -262,6 +300,8 @@ export function MomentCaptureScreen() { if (isReviewing) { return ( { + setCapturedAt(undefined); setCapturedPhotoUri(undefined); setIsReviewing(false); setErrorMessage(undefined); @@ -294,7 +335,8 @@ export function MomentCaptureScreen() { 앱에서 사용할 수 있어요 - 카메라 촬영은 모바일 앱에서 사용할 수 있어요. 대신 사진 없이 음악과 장소만 먼저 기록할 수 있습니다. + 카메라 촬영은 모바일 앱에서 사용할 수 있어요. 대신 사진 없이 음악과 + 장소만 먼저 기록할 수 있습니다. router.back()} > - 돌아가기 + + 돌아가기 + diff --git a/src/components/moment-capture/MomentPhotoCanvas.tsx b/src/components/moment-capture/MomentPhotoCanvas.tsx new file mode 100644 index 0000000..baeadde --- /dev/null +++ b/src/components/moment-capture/MomentPhotoCanvas.tsx @@ -0,0 +1,432 @@ +import { Feather } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import { + ComponentRef, + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { + Animated, + LayoutChangeEvent, + PanResponder, + Pressable, + StyleSheet, + View, +} from 'react-native'; +import ViewShot from 'react-native-view-shot'; + +import { AppText } from '@/components/AppText'; + +type TimestampStickerTheme = 'glass' | 'lime' | 'mono'; + +export type MomentPhotoCanvasHandle = { + capturePhoto: () => Promise; +}; + +type MomentPhotoCanvasProps = { + capturedAt?: string; + isSaving: boolean; + photoUri: string; +}; + +type CanvasSize = { + height: number; + width: number; +}; + +const CANVAS_PADDING = 16; +export const MOMENT_PHOTO_CANVAS_ASPECT_RATIO = 4 / 5; +const STICKER_HEIGHT = 86; +const STICKER_WIDTH = 176; + +const timestampStickerThemes: Array<{ + label: string; + value: TimestampStickerTheme; +}> = [ + { label: '글래스', value: 'glass' }, + { label: '라임', value: 'lime' }, + { label: '모노', value: 'mono' }, +]; + +export const MomentPhotoCanvas = forwardRef< + MomentPhotoCanvasHandle, + MomentPhotoCanvasProps +>(function MomentPhotoCanvas({ capturedAt, isSaving, photoUri }, ref) { + const photoCaptureRef = useRef>(null); + const stickerPan = useRef( + new Animated.ValueXY({ x: CANVAS_PADDING, y: CANVAS_PADDING }), + ).current; + const stickerPositionRef = useRef({ x: CANVAS_PADDING, y: CANVAS_PADDING }); + const didPlaceStickerRef = useRef(false); + const [canvasSize, setCanvasSize] = useState({ + height: 0, + width: 0, + }); + const [isDraggingSticker, setIsDraggingSticker] = useState(false); + const [stickerTheme, setStickerTheme] = + useState('glass'); + const [stickerVisible, setStickerVisible] = useState(true); + const stickerDateTime = useMemo( + () => formatStickerDateTime(capturedAt), + [capturedAt], + ); + const stickerThemeStyle = getTimestampStickerThemeStyle(stickerTheme); + const handleCanvasLayout = (event: LayoutChangeEvent) => { + const { height, width } = event.nativeEvent.layout; + + setCanvasSize((current) => + current.height === height && current.width === width + ? current + : { height, width }, + ); + }; + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => + Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2, + onPanResponderGrant: () => { + setIsDraggingSticker(true); + stickerPan.stopAnimation((value) => { + const nextPosition = clampStickerPosition(value, canvasSize); + stickerPositionRef.current = nextPosition; + stickerPan.setValue(nextPosition); + }); + }, + onPanResponderMove: (_, gestureState) => { + const nextPosition = clampStickerPosition( + { + x: stickerPositionRef.current.x + gestureState.dx, + y: stickerPositionRef.current.y + gestureState.dy, + }, + canvasSize, + ); + + stickerPan.setValue(nextPosition); + }, + onPanResponderRelease: () => { + setIsDraggingSticker(false); + stickerPan.stopAnimation((value) => { + stickerPositionRef.current = clampStickerPosition( + value, + canvasSize, + ); + stickerPan.setValue(stickerPositionRef.current); + }); + }, + onPanResponderTerminationRequest: () => false, + onPanResponderTerminate: () => { + setIsDraggingSticker(false); + stickerPan.stopAnimation((value) => { + stickerPositionRef.current = clampStickerPosition( + value, + canvasSize, + ); + stickerPan.setValue(stickerPositionRef.current); + }); + }, + onStartShouldSetPanResponder: () => true, + }), + [canvasSize, stickerPan], + ); + + useImperativeHandle( + ref, + () => ({ + capturePhoto: () => + photoCaptureRef.current?.capture?.() ?? Promise.resolve(undefined), + }), + [], + ); + + useEffect(() => { + didPlaceStickerRef.current = false; + }, [photoUri]); + + useEffect(() => { + if (!canvasSize.height || !canvasSize.width) { + return; + } + + if (!didPlaceStickerRef.current) { + const defaultPosition = getDefaultStickerPosition(canvasSize); + + stickerPositionRef.current = defaultPosition; + stickerPan.setValue(defaultPosition); + didPlaceStickerRef.current = true; + return; + } + + const nextPosition = clampStickerPosition( + stickerPositionRef.current, + canvasSize, + ); + stickerPositionRef.current = nextPosition; + stickerPan.setValue(nextPosition); + }, [canvasSize, stickerPan]); + + return ( + + + + + + {stickerVisible ? ( + + + + Soundlog Moment + + + + + {stickerDateTime.time} + + + {stickerDateTime.date} + + + ) : null} + + + + + + + + + + + 날짜 스티커 + + + setStickerVisible((value) => !value)} + > + + + + + {timestampStickerThemes.map((option) => { + const selected = option.value === stickerTheme; + + return ( + setStickerTheme(option.value)} + style={{ + backgroundColor: selected ? '#fff' : 'transparent', + }} + > + + {option.label} + + + ); + })} + + + + ); +}); + +function clampStickerPosition( + position: { x: number; y: number }, + canvasSize: CanvasSize, +) { + if (!canvasSize.height || !canvasSize.width) { + return { x: CANVAS_PADDING, y: CANVAS_PADDING }; + } + + const maxX = Math.max( + CANVAS_PADDING, + canvasSize.width - STICKER_WIDTH - CANVAS_PADDING, + ); + const maxY = Math.max( + CANVAS_PADDING, + canvasSize.height - STICKER_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 getDefaultStickerPosition(canvasSize: CanvasSize) { + return clampStickerPosition( + { + x: CANVAS_PADDING, + y: canvasSize.height - STICKER_HEIGHT - 26, + }, + canvasSize, + ); +} + +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 getTimestampStickerThemeStyle(theme: TimestampStickerTheme) { + 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)' }, + 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)' }, + 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)' }, + iconColor: 'rgba(255,255,255,0.72)', + metaText: { color: 'rgba(255,255,255,0.54)' }, + timeText: { color: '#fff' }, + }; +} + +const styles = StyleSheet.create({ + photoCanvas: { + aspectRatio: MOMENT_PHOTO_CANVAS_ASPECT_RATIO, + backgroundColor: '#10131f', + borderRadius: 28, + overflow: 'hidden', + width: '100%', + }, + 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: 28, + overflow: 'hidden', + width: '100%', + }, + timestampSticker: { + borderRadius: 18, + borderWidth: 1, + left: 0, + minHeight: STICKER_HEIGHT, + paddingHorizontal: 14, + paddingVertical: 10, + position: 'absolute', + shadowColor: '#000', + shadowOffset: { height: 8, width: 0 }, + shadowOpacity: 0.28, + shadowRadius: 18, + top: 0, + width: STICKER_WIDTH, + }, + timestampStickerDragging: { + shadowOpacity: 0.38, + shadowRadius: 22, + }, +}); diff --git a/src/components/moment-capture/MomentReviewPanel.tsx b/src/components/moment-capture/MomentReviewPanel.tsx index 55dacef..46676e8 100644 --- a/src/components/moment-capture/MomentReviewPanel.tsx +++ b/src/components/moment-capture/MomentReviewPanel.tsx @@ -1,5 +1,5 @@ import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; +import { forwardRef, useImperativeHandle, useRef } from 'react'; import { ActivityIndicator, Pressable, @@ -9,6 +9,11 @@ import { } from 'react-native'; import { AppText } from '@/components/AppText'; +import { + MOMENT_PHOTO_CANVAS_ASPECT_RATIO, + MomentPhotoCanvas, + type MomentPhotoCanvasHandle, +} from '@/components/moment-capture/MomentPhotoCanvas'; import { MomentSaveState } from '@/components/moment-capture/MomentSaveState'; import { Screen } from '@/components/Screen'; import { GeoPoint, MoodTag, Track, TravelMode } from '@/types/domain'; @@ -32,6 +37,7 @@ const moodLabels: Record = { }; type MomentReviewPanelProps = { + capturedAt?: string; errorMessage?: string; includeMusic: boolean; isSaving: boolean; @@ -50,27 +56,40 @@ type MomentReviewPanelProps = { travelMode?: TravelMode; }; +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, + note, + onChangeMoodTags, + onChangeNote, + onChangePlaceName, + onRetake, + onSave, + onToggleMusic, + photoUri, + placeName, + track, + travelMode, + }, + ref, +) { + const photoCanvasRef = useRef(null); + const moodLabel = + moodTags.map((tag) => moodLabels[tag]).join(', ') || '무드 없음'; const canToggleMusic = Boolean(track); const toggleMoodTag = (tag: MoodTag) => { if (moodTags.includes(tag)) { @@ -81,10 +100,23 @@ export function MomentReviewPanel({ onChangeMoodTags([...moodTags, tag]); }; + useImperativeHandle( + ref, + () => ({ + capturePhoto: () => + photoCanvasRef.current?.capturePhoto() ?? Promise.resolve(undefined), + }), + [], + ); + return ( @@ -97,21 +129,23 @@ export function MomentReviewPanel({ > - 저장 확인 + + 저장 확인 + {photoUri ? ( - ) : ( @@ -120,7 +154,8 @@ export function MomentReviewPanel({ 사진 없이 음악만 남겨요 - 장소, 곡, 무드, 메모만으로도 여행 사운드트랙 로그를 만들 수 있어요. + 장소, 곡, 무드, 메모만으로도 여행 사운드트랙 로그를 만들 수 + 있어요. )} @@ -149,22 +184,37 @@ export function MomentReviewPanel({ style={{ opacity: canToggleMusic ? 1 : 0.65 }} > - + 음악 - - {includeMusic && track ? `${track.title} - ${track.artist}` : '음악 없음'} + + {includeMusic && track + ? `${track.title} - ${track.artist}` + : '음악 없음'} {canToggleMusic ? ( ) : null} @@ -173,13 +223,17 @@ export function MomentReviewPanel({ 무드 - {moodLabel} + + {moodLabel} + {editableMoodTags.map(([tag, label]) => { @@ -193,13 +247,19 @@ export function MomentReviewPanel({ disabled={isSaving} onPress={() => toggleMoodTag(tag)} style={{ - backgroundColor: selected ? '#B7E628' : 'rgba(255,255,255,0.08)', - borderColor: selected ? '#B7E628' : 'rgba(255,255,255,0.1)', + backgroundColor: selected + ? '#B7E628' + : 'rgba(255,255,255,0.08)', + borderColor: selected + ? '#B7E628' + : 'rgba(255,255,255,0.1)', }} > {label} @@ -238,7 +298,9 @@ export function MomentReviewPanel({ {isSaving ? ( ) : ( - 이 순간 저장하기 + + 이 순간 저장하기 + )} ); -} +}); function InfoRow({ icon, @@ -272,7 +334,10 @@ function InfoRow({ {label} - + {value} diff --git a/src/components/recap-share/RecapMusicSummary.tsx b/src/components/recap-share/RecapMusicSummary.tsx index 709a292..a2e6f4e 100644 --- a/src/components/recap-share/RecapMusicSummary.tsx +++ b/src/components/recap-share/RecapMusicSummary.tsx @@ -1,5 +1,5 @@ import { Feather } from '@expo/vector-icons'; -import { View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; import { RecapShare, RecapShareMoment } from '@/types/domain'; @@ -38,6 +38,10 @@ 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,13 +58,39 @@ 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 locationFlow = createLocationFlow(moments, recap.placeName); const placeFlow = createPlaceFlow(moments, recap.placeName); const recordedRange = createRecordedRange(moments, recap.recordedAt); + const hasLocations = locationMoments.length > 0; return ( @@ -84,6 +114,34 @@ export function RecapMusicSummary({ recap }: { recap: RecapShare }) { {moments.length}개 Moment · {placeCount || 1}곳 · {placeFlow} · {recordedRange} + + + + + + + + 촬영 위치 + + + {hasLocations + ? `${locationMoments.length}개 위치 · ${locationFlow}` + : '위치가 저장된 Moment가 없어요'} + + + {hasLocations && onOpenMap ? ( + + + 지도 + + + ) : null} + ); } diff --git a/src/components/recap-share/RecapPreviewCard.tsx b/src/components/recap-share/RecapPreviewCard.tsx index 69ca41a..fd51dfd 100644 --- a/src/components/recap-share/RecapPreviewCard.tsx +++ b/src/components/recap-share/RecapPreviewCard.tsx @@ -4,7 +4,12 @@ import { StyleSheet, View } from 'react-native'; import { AppText } from '@/components/AppText'; import { RecordDisc } from '@/components/recap-share/RecordDisc'; -import { RecapShare, RecapShareMoment, RecapTemplateId } from '@/types/domain'; +import { + GeoPoint, + RecapShare, + RecapShareMoment, + RecapTemplateId, +} from '@/types/domain'; type RecapPreviewCardProps = { recap: RecapShare; @@ -44,16 +49,25 @@ function RecapBackground({ function RecapLpTemplate({ recap }: { recap: RecapShare }) { return ( <> - + + @@ -75,10 +89,13 @@ function RecapLpTemplate({ recap }: { recap: RecapShare }) { - + - + moment.location.lat); + const lngValues = moments.map((moment) => moment.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 moments.map((moment, index) => { + if (latRange < 0.0001 && lngRange < 0.0001) { + return fallbackMapPinPositions[index % fallbackMapPinPositions.length]; + } + + const normalizedLat = + latRange < 0.0001 ? 0.5 : (moment.location.lat - minLat) / latRange; + const normalizedLng = + lngRange < 0.0001 ? 0.5 : (moment.location.lng - minLng) / lngRange; + + return { + left: clamp(52 + normalizedLng * 178, 42, 232), + top: clamp(272 - normalizedLat * 178, 86, 276), + }; + }); +} + function RecapFilmTemplate({ recap }: { recap: RecapShare }) { const moments = ( recap.moments?.length ? recap.moments : [createFallbackMoment(recap)] @@ -261,15 +324,18 @@ 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 pinPositions = hasRecordedLocations + ? createLocationPinPositions(moments as LocatedRecapMoment[]) + : fallbackMapPinPositions; return ( <> @@ -322,18 +388,25 @@ function RecapMapTemplate({ recap }: { recap: RecapShare }) { - SOUNDLOG MAP + {hasRecordedLocations ? 'MOMENT MAP' : 'SOUNDLOG MAP'} {recap.placeName} + + + {hasRecordedLocations + ? `${locatedMoments.length}개 촬영 위치 저장됨` + : '장소 흐름 미리보기'} + + {moments.map((moment, index) => ( @@ -350,7 +423,7 @@ function RecapMapTemplate({ recap }: { recap: RecapShare }) { - 대표 사운드 + {hasRecordedLocations ? '촬영 위치와 대표 사운드' : '대표 사운드'} {recap.trackTitle} @@ -376,3 +449,16 @@ export function RecapPreviewCard({ ); } + +const styles = StyleSheet.create({ + lpAccentWash: { + height: 180, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + lpTrackPanel: { + backgroundColor: 'rgba(5,9,22,0.88)', + }, +}); diff --git a/src/components/recap-share/RecapShareScreen.tsx b/src/components/recap-share/RecapShareScreen.tsx index 19bc35c..fe55865 100644 --- a/src/components/recap-share/RecapShareScreen.tsx +++ b/src/components/recap-share/RecapShareScreen.tsx @@ -1,5 +1,6 @@ import { useMemo, useRef, useState } from 'react'; -import { ScrollView, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; +import { Pressable, ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRecapShareQuery } from '@/api/recapQueries'; @@ -34,6 +35,13 @@ type RecapShareScreenProps = { recapId?: string; }; +const recapTemplateLabels: Record = { + album: '앨범', + film: '필름', + lp: 'LP', + map: '지도', +}; + function createRecapTitle(recap?: RecapShare | null) { if (!recap) { return 'Recap'; @@ -42,9 +50,101 @@ function createRecapTitle(recap?: RecapShare | null) { return `${recap.placeName} 사운드`; } +function RecapArchiveSelector({ + onArchive, + onSelect, + selectedTemplate, +}: { + onArchive: () => void; + onSelect: (template: RecapTemplateId) => void; + selectedTemplate: RecapTemplateId; +}) { + return ( + + + + + 템플릿 선택 + + + {recapTemplateLabels[selectedTemplate]}로 남길게요 + + + 4개 템플릿 중 하나를 고른 뒤 Soundlog 아카이브에 저장해요. + + + + + + + + + + + + + + + 사운드로그로 아카이빙 + + + + ); +} + +function RecapArchiveComplete({ + onReselect, + selectedTemplate, +}: { + onReselect: () => void; + selectedTemplate: RecapTemplateId; +}) { + return ( + + + + + + + + 아카이빙 완료 + + + {recapTemplateLabels[selectedTemplate]} 템플릿이 Soundlog에 저장됐어요 + + + 이제 완성된 리캡 이미지를 기기에 저장하거나 공유할 수 있어요. + + + + + + + + 다시 선택 + + + + ); +} + export function RecapShareScreen({ recapId }: RecapShareScreenProps) { const insets = useSafeAreaInsets(); const [selectedTemplate, setSelectedTemplate] = useState('album'); + const [isArchived, setIsArchived] = useState(false); const captureAspectRatio = selectedTemplate === 'album' ? 1 : 3 / 4; const momentLogs = useMomentLogStore((state) => state.logs); const sessionId = extractSessionIdFromRecapId(recapId); @@ -76,6 +176,10 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { capture: () => captureRef.current?.capture() ?? Promise.resolve(undefined), recapId: isLocalRecap ? undefined : recapId, }); + const handleSelectTemplate = (template: RecapTemplateId) => { + setSelectedTemplate(template); + setIsArchived(false); + }; return ( @@ -93,7 +197,9 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { {recap - ? '사운드트랙 앨범 결과물을 저장하거나 공유해요.' + ? isArchived + ? '선택한 리캡을 Soundlog에 아카이빙했어요.' + : '마음에 드는 리캡 템플릿을 고른 뒤 아카이빙해요.' : '여행의 사운드트랙 앨범을 불러오고 있어요.'} @@ -120,36 +226,44 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { ) : null} - - - + {isArchived ? ( + <> + setIsArchived(false)} + /> - - {formatRecapRecordedAt(recap.recordedAt)} - + + {formatRecapRecordedAt(recap.recordedAt)} + - - - + + + - - { - if (action === 'save') { - save(); - } else { - share(); - } - }} + + { + if (action === 'save') { + save(); + } else { + share(); + } + }} + /> + + + ) : ( + setIsArchived(true)} + onSelect={handleSelectTemplate} /> - + )} - {message ? ( + {isArchived && message ? ( + {imageUrl ? ( - ) : null} - + ) : ( + <> + + + + + )} + 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/mocks/recapMocks.ts b/src/mocks/recapMocks.ts index fa93d3b..8fc11ac 100644 --- a/src/mocks/recapMocks.ts +++ b/src/mocks/recapMocks.ts @@ -20,6 +20,35 @@ export const recapShare: RecapShare = { 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', id: 'seoul-night', + moments: [ + { + artistName: 'JENNIE', + id: 'log-1', + imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.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', @@ -36,6 +65,7 @@ export const recapShareById: Record = { artistName: 'JENNIE', id: 'log-1', imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + location: { lat: 35.1532, lng: 129.1187 }, placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', trackTitle: 'Seoul City', @@ -55,6 +85,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: '밤편지', @@ -74,6 +105,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: '서울의 밤', diff --git a/src/types/domain.ts b/src/types/domain.ts index 26fe1c6..eaeae3e 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -142,6 +142,7 @@ export type RecapTemplateId = 'album' | 'film' | 'lp' | 'map'; export type RecapShareMoment = { id: string; imageUrl?: string; + location?: GeoPoint; placeName: string; trackTitle: string; artistName: string; diff --git a/src/utils/recapMappers.ts b/src/utils/recapMappers.ts index 980b68f..07f80e8 100644 --- a/src/utils/recapMappers.ts +++ b/src/utils/recapMappers.ts @@ -28,6 +28,7 @@ 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, trackTitle: log.track?.title ?? FALLBACK_TITLE,