From d23765295fe5a7ed9124040a829f211c19b7f22a Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 00:02:32 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Travel=20=ED=83=AD=EA=B3=BC=20?= =?UTF-8?q?=EC=97=AC=ED=96=89=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/_layout.tsx | 122 +------- app/(tabs)/travel.tsx | 5 + .../navigation/BottomNavigation.tsx | 148 +++++++++ .../travel/EndTravelConfirmModal.tsx | 52 ++++ src/components/travel/MomentCard.tsx | 69 +++++ src/components/travel/RecapCard.tsx | 63 ++++ .../travel/TravelModeBottomSheet.tsx | 102 +++++++ src/components/travel/TravelReportModal.tsx | 289 ++++++++++++++++++ src/components/travel/TravelScreen.tsx | 191 ++++++++++++ src/components/travel/TravelStatusCard.tsx | 184 +++++++++++ src/components/travel/travelData.ts | 173 +++++++++++ src/components/travel/travelFormat.ts | 83 +++++ 12 files changed, 1361 insertions(+), 120 deletions(-) create mode 100644 app/(tabs)/travel.tsx create mode 100644 src/components/navigation/BottomNavigation.tsx create mode 100644 src/components/travel/EndTravelConfirmModal.tsx create mode 100644 src/components/travel/MomentCard.tsx create mode 100644 src/components/travel/RecapCard.tsx create mode 100644 src/components/travel/TravelModeBottomSheet.tsx create mode 100644 src/components/travel/TravelReportModal.tsx create mode 100644 src/components/travel/TravelScreen.tsx create mode 100644 src/components/travel/TravelStatusCard.tsx create mode 100644 src/components/travel/travelData.ts create mode 100644 src/components/travel/travelFormat.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 31bdf30..0e51a79 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,123 +1,5 @@ -import { Feather } from '@expo/vector-icons'; -import { Tabs, router } from 'expo-router'; -import { Alert, 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'; - -type TabIconName = keyof typeof Feather.glyphMap; - -function TabIcon({ name, color }: { name: TabIconName; color: string }) { - return ; -} - -function CameraTabButton() { - const { session, startSession } = useTravelSessionStore(); - const openCamera = () => router.push('/camera'); - const handlePress = () => { - if (session.status === 'active') { - openCamera(); - return; - } - - Alert.alert('여행을 시작할까요?', '순간 저장은 현재 여행에 연결돼요.', [ - { style: 'cancel', text: '취소' }, - { - onPress: () => { - startSession(); - openCamera(); - }, - text: '시작하고 촬영', - }, - ]); - }; - - return ( - - - - ); -} +import { BottomNavigation } from '@/components/navigation/BottomNavigation'; export default function TabsLayout() { - const insets = useSafeAreaInsets(); - - return ( - - , - title: '홈', - }} - /> - , - title: 'Recap', - }} - /> - , - title: '기록', - }} - /> - , - title: '보관함', - }} - /> - - - , - title: '마이', - }} - /> - - ); + return ; } diff --git a/app/(tabs)/travel.tsx b/app/(tabs)/travel.tsx new file mode 100644 index 0000000..96a5fbd --- /dev/null +++ b/app/(tabs)/travel.tsx @@ -0,0 +1,5 @@ +import { TravelScreen } from '@/components/travel/TravelScreen'; + +export default function TravelTabScreen() { + return ; +} diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx new file mode 100644 index 0000000..1254594 --- /dev/null +++ b/src/components/navigation/BottomNavigation.tsx @@ -0,0 +1,148 @@ +import { Feather } from '@expo/vector-icons'; +import { Tabs, router } from 'expo-router'; +import { Alert, 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'; + +type TabIconName = keyof typeof Feather.glyphMap; + +const webGlassTabBarStyle = { + backdropFilter: 'blur(22px) saturate(150%)', + WebkitBackdropFilter: 'blur(22px) saturate(150%)', +}; + +function TabIcon({ name, color }: { name: TabIconName; color: string }) { + return ; +} + +function CameraTabButton() { + const { session, startSession } = useTravelSessionStore(); + const openCamera = () => router.push('/camera'); + const handlePress = () => { + if (session.status === 'active') { + openCamera(); + return; + } + + Alert.alert('여행을 시작할까요?', '순간 저장은 현재 여행에 연결돼요.', [ + { style: 'cancel', text: '취소' }, + { + onPress: () => { + startSession(); + openCamera(); + }, + text: '시작하고 촬영', + }, + ]); + }; + + return ( + + + + + + ); +} + +export function BottomNavigation() { + const insets = useSafeAreaInsets(); + + return ( + + , + title: 'Home', + }} + /> + , + title: 'Travel', + }} + /> + , + title: 'Camera', + }} + /> + , + title: 'Library', + }} + /> + , + title: 'My', + }} + /> + + + + + ); +} diff --git a/src/components/travel/EndTravelConfirmModal.tsx b/src/components/travel/EndTravelConfirmModal.tsx new file mode 100644 index 0000000..54d2954 --- /dev/null +++ b/src/components/travel/EndTravelConfirmModal.tsx @@ -0,0 +1,52 @@ +import { Feather } from '@expo/vector-icons'; +import { Modal, Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +type EndTravelConfirmModalProps = { + momentCount: number; + onCancel: () => void; + onConfirm: () => void; + visible: boolean; +}; + +export function EndTravelConfirmModal({ + momentCount, + onCancel, + onConfirm, + visible, +}: EndTravelConfirmModalProps) { + return ( + + + + + + + + 여행을 종료할까요? + + 지금까지 저장한 Moment {momentCount}개가 Recap 생성을 위한 여행 기록으로 묶여요. + + + + + 취소 + + + 종료 + + + + + + ); +} diff --git a/src/components/travel/MomentCard.tsx b/src/components/travel/MomentCard.tsx new file mode 100644 index 0000000..a0f5617 --- /dev/null +++ b/src/components/travel/MomentCard.tsx @@ -0,0 +1,69 @@ +import { Feather } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import { Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; +import type { MomentLog } from '@/types/domain'; + +import { formatKoreanDateTime } from './travelFormat'; +import { moodLabelByValue } from './travelData'; + +type MomentCardProps = { + item: MomentLog; + onPress?: () => void; +}; + +export function MomentCard({ item, onPress }: MomentCardProps) { + const moodLabel = item.moodTags[0] ? moodLabelByValue[item.moodTags[0]] : '무드 기록'; + + return ( + + + {item.photoUri ? ( + + ) : ( + + + + )} + + + + + + + Moment + + + {moodLabel} + + + + + + + {item.placeName ?? '위치 없음'} + + + + + + + {item.track ? `${item.track.title} - ${item.track.artist}` : '음악 없음'} + + + + + + {formatKoreanDateTime(item.createdAt)} + + + + ); +} diff --git a/src/components/travel/RecapCard.tsx b/src/components/travel/RecapCard.tsx new file mode 100644 index 0000000..d73c8a6 --- /dev/null +++ b/src/components/travel/RecapCard.tsx @@ -0,0 +1,63 @@ +import { Feather } from '@expo/vector-icons'; +import { Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +import { modeIconByValue, modeLabelByValue, type TravelRecap } from './travelData'; + +type RecapCardProps = { + item: TravelRecap; + onPress: () => void; +}; + +export function RecapCard({ item, onPress }: RecapCardProps) { + return ( + + + + {modeIconByValue[item.mode]} + + + + + + {modeLabelByValue[item.mode]} + + {item.date} + + + + {item.durationText} · {item.playTimeText.replace('총 음악 재생 ', '')} + + + + + + {item.playCount}회 + + + + + {item.trackCount}곡 + + + + + Moment {item.momentCount} + + + + + + + + + + + ); +} diff --git a/src/components/travel/TravelModeBottomSheet.tsx b/src/components/travel/TravelModeBottomSheet.tsx new file mode 100644 index 0000000..9721dd9 --- /dev/null +++ b/src/components/travel/TravelModeBottomSheet.tsx @@ -0,0 +1,102 @@ +import { Feather } from '@expo/vector-icons'; +import { Modal, Pressable, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AppText } from '@/components/AppText'; +import type { TravelMode } from '@/types/domain'; + +import { travelModeOptions } from './travelData'; + +type TravelModeBottomSheetProps = { + onClose: () => void; + onSelectMode: (mode: TravelMode) => void; + onStart: () => void; + selectedMode?: TravelMode; + visible: boolean; +}; + +export function TravelModeBottomSheet({ + onClose, + onSelectMode, + onStart, + selectedMode, + visible, +}: TravelModeBottomSheetProps) { + const insets = useSafeAreaInsets(); + + return ( + + + + + + + + + 여행 모드 선택 + + 지금의 여행 맥락을 고르면 음악 추천과 Moment가 같은 세션으로 묶여요. + + + + + + + + + {travelModeOptions.map((mode) => { + const selected = selectedMode === mode.value; + + return ( + onSelectMode(mode.value)} + > + {mode.icon} + + {mode.label} + + + ); + })} + + + + + 여행 시작 + + + + + + ); +} diff --git a/src/components/travel/TravelReportModal.tsx b/src/components/travel/TravelReportModal.tsx new file mode 100644 index 0000000..1d44067 --- /dev/null +++ b/src/components/travel/TravelReportModal.tsx @@ -0,0 +1,289 @@ +import { Feather } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useEffect, useState } from 'react'; +import { Modal, Pressable, ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { AppText } from '@/components/AppText'; + +import { modeIconByValue, modeLabelByValue, type TravelRecap } from './travelData'; + +type TravelReportModalProps = { + item?: TravelRecap; + onClose: () => void; + visible: boolean; +}; + +function ReportShell({ + children, + colors, +}: { + children: React.ReactNode; + colors: [string, string, string]; +}) { + return ( + + {children} + + ); +} + +function ReportStat({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +export function TravelReportModal({ item, onClose, visible }: TravelReportModalProps) { + const insets = useSafeAreaInsets(); + const [pageIndex, setPageIndex] = useState(0); + + useEffect(() => { + if (visible) { + setPageIndex(0); + } + }, [visible, item?.id]); + + if (!item) { + return null; + } + + const modeLabel = modeLabelByValue[item.mode]; + const modeIcon = modeIconByValue[item.mode]; + const mostPlayed = item.topTracks[0]; + const pages = [ + { + key: 'cover', + node: ( + + + + + SOUNDLOG TRAVEL REPORT + + {modeIcon} + + {modeLabel} + + {item.date} + + + 여행 위치 + + {item.locations.join(' · ')} + + + + + ), + }, + { + key: 'summary', + node: ( + + + 이 여행에서{'\n'}음악은 이렇게 흘렀어요 + + + + + + + + ), + }, + { + key: 'most', + node: ( + + + + MOST PLAYED + + 가장 많이 들은 노래 + + + + + + + + {mostPlayed.artist} - {mostPlayed.title} + + + 이 곡만 {mostPlayed.playCount}회 재생했어요. + + + + + ), + }, + { + key: 'ranking', + node: ( + + + 많이 들은 순위 + + + {item.topTracks.map((track, index) => ( + + + {index + 1} + + + + {track.title} + + + {track.artist} + + + + {track.playCount}회 + + + ))} + + + ), + }, + { + key: 'unique', + node: ( + + + 중복 없이 들은{'\n'}음악 목록 + + + {item.trackCount}곡 중 대표 곡을 정리했어요. + + + {item.uniqueTracks.map((track) => ( + + {track} + + ))} + + + ), + }, + { + key: 'share', + node: ( + + + + SHARE CARD + + {modeLabel}의 음악 리포트가 발행됐어요 + + + + + {item.locations[0]} + + + {item.durationText} · {item.playCount}회 재생 · Moment {item.momentCount} + + + + + ), + }, + ]; + const isLastPage = pageIndex === pages.length - 1; + + return ( + + + + + Travel Report + + {modeLabel} · {item.date} + + + + + + + + + {pages[pageIndex].node} + + + {pages.map((page, index) => ( + + ))} + + + + setPageIndex((index) => Math.max(0, index - 1))} + > + + 이전 + + + { + if (isLastPage) { + onClose(); + return; + } + + setPageIndex((index) => Math.min(pages.length - 1, index + 1)); + }} + > + + {isLastPage ? '완료' : '다음'} + + + + + + + ); +} diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx new file mode 100644 index 0000000..e762381 --- /dev/null +++ b/src/components/travel/TravelScreen.tsx @@ -0,0 +1,191 @@ +import { router } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MiniPlayer } from '@/components/MiniPlayer'; +import { Screen } from '@/components/Screen'; +import { AppText } from '@/components/AppText'; +import { getHomeContentBottomPadding } from '@/constants/layout'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { usePlayerStore } from '@/store/playerStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; +import type { TravelMode } from '@/types/domain'; + +import { EndTravelConfirmModal } from './EndTravelConfirmModal'; +import { MomentCard } from './MomentCard'; +import { RecapCard } from './RecapCard'; +import { TravelModeBottomSheet } from './TravelModeBottomSheet'; +import { TravelReportModal } from './TravelReportModal'; +import { TravelStatusCard } from './TravelStatusCard'; +import { sampleMoments, sampleRecaps, type TravelRecap } from './travelData'; + +const sampleMomentMusicLogIds: Record = { + 'sample-gwangalli': 'log-1', + 'sample-night': 'log-3', + 'sample-seongsu': 'log-2', +}; + +function getMomentMusicLogId(momentId: string) { + return sampleMomentMusicLogIds[momentId] ?? momentId; +} + +export function TravelScreen() { + const insets = useSafeAreaInsets(); + const [isModeSheetVisible, setIsModeSheetVisible] = useState(false); + const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false); + const [selectedReport, setSelectedReport] = useState(); + const [, setClockTick] = useState(0); + const { + currentPlace, + selectedMode, + session, + endSession, + resetSession, + setMode, + startSession, + } = useTravelSessionStore(); + const { currentTrack } = usePlayerStore(); + const momentLogs = useMomentLogStore((state) => state.logs); + const moments = useMemo( + () => [...momentLogs, ...sampleMoments].slice(0, 3), + [momentLogs], + ); + const momentCount = Math.max(momentLogs.length, session.status === 'active' ? 8 : 0); + + useEffect(() => { + if (session.status !== 'active') { + return; + } + + const intervalId = setInterval(() => { + setClockTick((tick) => tick + 1); + }, 1000); + + return () => clearInterval(intervalId); + }, [session.status]); + + const openModeSheet = () => { + if (session.status === 'ended') { + resetSession(); + } + + setIsModeSheetVisible(true); + }; + const handleSelectMode = (mode: TravelMode) => { + setMode(mode); + }; + const handleStartTravel = () => { + if (!selectedMode) { + setMode('cafe'); + } + + startSession(); + setIsModeSheetVisible(false); + }; + const handleConfirmEnd = () => { + endSession(); + setIsEndConfirmVisible(false); + }; + + return ( + + + + + 음악으로 기록하는 당신의 여정 + + + Travel + + + + setIsEndConfirmVisible(true)} + onOpenRecap={() => router.push('/recap-share/seoul-night')} + onSaveMoment={() => router.push('/camera')} + onStartTravel={openModeSheet} + selectedMode={selectedMode} + startedAt={session.startedAt} + status={session.status} + /> + + + + + 최근 Moment + 여행 중 직접 저장한 순간 + + router.push('/library')}> + 더보기 + + + + + {moments.map((moment) => ( + router.push(`/recap-share/${getMomentMusicLogId(moment.id)}`)} + /> + ))} + + + + + + + Travel Log + + 여행별 음악과 Moment 요약 + + + + + + {sampleRecaps.slice(0, session.status === 'idle' ? 3 : 2).map((recap) => ( + setSelectedReport(recap)} + /> + ))} + + + + + {currentTrack ? : null} + + setIsModeSheetVisible(false)} + onSelectMode={handleSelectMode} + onStart={handleStartTravel} + selectedMode={selectedMode} + visible={isModeSheetVisible} + /> + setIsEndConfirmVisible(false)} + onConfirm={handleConfirmEnd} + visible={isEndConfirmVisible} + /> + setSelectedReport(undefined)} + visible={Boolean(selectedReport)} + /> + + ); +} diff --git a/src/components/travel/TravelStatusCard.tsx b/src/components/travel/TravelStatusCard.tsx new file mode 100644 index 0000000..f237a80 --- /dev/null +++ b/src/components/travel/TravelStatusCard.tsx @@ -0,0 +1,184 @@ +import { Feather } from '@expo/vector-icons'; +import { Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; +import type { PlaceContext, Track, TravelMode } from '@/types/domain'; + +import { formatDurationText, formatElapsedTime, formatKoreanDateTime, formatShortEndedAt } from './travelFormat'; +import { modeIconByValue, modeLabelByValue } from './travelData'; + +type TravelStatus = 'active' | 'ended' | 'idle'; + +type TravelStatusCardProps = { + currentPlace?: PlaceContext; + currentTrack?: Track; + endedAt?: string; + momentCount: number; + onEndTravel: () => void; + onOpenRecap: () => void; + onSaveMoment: () => void; + onStartTravel: () => void; + selectedMode?: TravelMode; + startedAt?: string; + status: TravelStatus; +}; + +function Metric({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} + +function CompactMetric({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} + +export function TravelStatusCard({ + currentPlace, + currentTrack, + endedAt, + momentCount, + onEndTravel, + onOpenRecap, + onSaveMoment, + onStartTravel, + selectedMode, + startedAt, + status, +}: TravelStatusCardProps) { + const modeLabel = selectedMode ? modeLabelByValue[selectedMode] : '카페 투어'; + const modeIcon = selectedMode ? modeIconByValue[selectedMode] : '☕'; + const placeLabel = currentPlace?.title ?? '성수 카페거리'; + const currentTrackLabel = currentTrack + ? `${currentTrack.artist} - ${currentTrack.title}` + : 'NewJeans - Ditto'; + + if (status === 'active') { + return ( + + + + + 여행 진행 중 + + + + {modeIcon} {modeLabel} + + + + + + {formatElapsedTime(startedAt)} + + + {formatKoreanDateTime(startedAt)} 시작 + + + + + + + + + + + 현재 재생 중 + + {currentTrackLabel} + + + + + + + 여행 종료 + + + 순간 저장 + + + + ); + } + + if (status === 'ended') { + return ( + + 여행이 종료됐어요 + + 저장한 순간은 Recap에서 다시 확인할 수 있어요. + + + {formatShortEndedAt(endedAt)} + + + + + + + + + + + + + Recap 보기 + + + 새 여행 시작 + + + + ); + } + + return ( + + + + + + 여행을 시작해보세요 + + + 현재 위치에 맞는 음악을 추천받고, 여행 중 남긴 순간을 Recap으로 기록할 수 있어요. + + + 새 여행 시작 + + + ); +} diff --git a/src/components/travel/travelData.ts b/src/components/travel/travelData.ts new file mode 100644 index 0000000..6175a65 --- /dev/null +++ b/src/components/travel/travelData.ts @@ -0,0 +1,173 @@ +import type { MomentLog, MoodTag, TravelMode } from '@/types/domain'; + +export type TravelModeOption = { + icon: string; + label: string; + value: TravelMode; +}; + +export type TravelRecap = { + date: string; + durationText: string; + id: string; + locations: string[]; + mode: TravelMode; + momentCount: number; + playCount: number; + playTimeText: string; + representativeTrack: string; + topTracks: Array<{ + artist: string; + playCount: number; + title: string; + }>; + trackCount: number; + uniqueTracks: string[]; +}; + +export const travelModeOptions: TravelModeOption[] = [ + { icon: '🚶', label: '산책', value: 'walk' }, + { icon: '🚗', label: '드라이브', value: 'drive' }, + { icon: '☕', label: '카페 투어', value: 'cafe' }, + { icon: '🌊', label: '바다 보기', value: 'ocean' }, + { icon: '🎪', label: '축제', value: 'festival' }, + { icon: '🌙', label: '야경 감상', value: 'night' }, +]; + +export const modeLabelByValue = travelModeOptions.reduce( + (acc, mode) => ({ + ...acc, + [mode.value]: mode.label, + }), + {} as Record, +); + +export const modeIconByValue = travelModeOptions.reduce( + (acc, mode) => ({ + ...acc, + [mode.value]: mode.icon, + }), + {} as Record, +); + +export const moodLabelByValue: Record = { + active: '활기찬', + calm: '잔잔한', + emotional: '감성적인', + fresh: '청량한', + local: '로컬', +}; + +export const sampleMoments: MomentLog[] = [ + { + createdAt: '2026-06-06T17:21:00.000+09:00', + id: 'sample-gwangalli', + moodTags: ['fresh'], + photoUri: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + placeName: '광안리 해변', + source: 'camera', + syncStatus: 'synced', + track: { + artist: 'NewJeans', + fallbackColor: '#7DD3FC', + id: 'ditto', + title: 'Ditto', + }, + travelMode: 'ocean', + }, + { + createdAt: '2026-06-06T15:42:00.000+09:00', + id: 'sample-seongsu', + moodTags: ['calm', 'local'], + photoUri: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + placeName: '성수 카페거리', + source: 'camera', + syncStatus: 'synced', + track: { + artist: 'IU', + fallbackColor: '#FBBF24', + id: 'love-wins-all', + title: 'Love wins all', + }, + travelMode: 'cafe', + }, + { + 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', + placeName: '남산 산책로', + source: 'camera', + syncStatus: 'synced', + track: { + artist: '10CM', + fallbackColor: '#C084FC', + id: 'spring-snow', + title: '봄눈', + }, + travelMode: 'walk', + }, +]; + +export const sampleRecaps: TravelRecap[] = [ + { + date: '2026.06.06', + durationText: '2시간 14분의 여행', + id: 'seoul-night', + locations: ['성수 카페거리', '서울숲 입구', '뚝섬 전망대'], + mode: 'cafe', + momentCount: 12, + playCount: 48, + playTimeText: '총 음악 재생 1시간 52분', + representativeTrack: 'IU - Love wins all', + topTracks: [ + { artist: 'IU', playCount: 9, title: 'Love wins all' }, + { artist: 'NewJeans', playCount: 7, title: 'Ditto' }, + { artist: 'NewJeans', playCount: 6, title: 'ETA' }, + { artist: 'IU', playCount: 5, title: 'Blueming' }, + { artist: 'NewJeans', playCount: 4, title: 'Attention' }, + ], + trackCount: 31, + uniqueTracks: ['Love wins all', 'Ditto', 'ETA', 'Blueming', 'Attention', '밤편지'], + }, + { + date: '2026.05.25', + durationText: '1시간 48분의 여행', + id: 'log-3', + locations: ['광안리 해변', '민락수변공원', '광안대교 전망'], + mode: 'ocean', + momentCount: 8, + playCount: 36, + playTimeText: '총 음악 재생 1시간 20분', + representativeTrack: 'NewJeans - Ditto', + topTracks: [ + { artist: 'NewJeans', playCount: 8, title: 'Ditto' }, + { artist: 'NewJeans', playCount: 6, title: 'Attention' }, + { artist: 'NewJeans', playCount: 5, title: 'ETA' }, + { artist: 'AKMU', playCount: 4, title: 'DINOSAUR' }, + { artist: '10CM', playCount: 3, title: '봄눈' }, + ], + trackCount: 24, + uniqueTracks: ['Ditto', 'Attention', 'ETA', 'DINOSAUR', '봄눈', 'Seoul City'], + }, + { + date: '2026.05.11', + durationText: '3시간 02분의 여행', + id: 'log-2', + locations: ['남산 산책로', '해방촌', '서울 야경 전망대'], + mode: 'night', + momentCount: 15, + playCount: 64, + playTimeText: '총 음악 재생 2시간 36분', + representativeTrack: 'JENNIE - Seoul City', + topTracks: [ + { artist: 'JENNIE', playCount: 11, title: 'Seoul City' }, + { artist: '10CM', playCount: 8, title: '봄눈' }, + { artist: 'IU', playCount: 7, title: '밤편지' }, + { artist: 'Crush', playCount: 6, title: 'Beautiful' }, + { artist: 'NewJeans', playCount: 5, title: 'Cool With You' }, + ], + trackCount: 42, + uniqueTracks: ['Seoul City', '봄눈', '밤편지', 'Beautiful', 'Cool With You', '그라데이션'], + }, +]; diff --git a/src/components/travel/travelFormat.ts b/src/components/travel/travelFormat.ts new file mode 100644 index 0000000..c133472 --- /dev/null +++ b/src/components/travel/travelFormat.ts @@ -0,0 +1,83 @@ +export function formatKoreanDateTime(value?: string) { + if (!value) { + return '아직 없음'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return '확인 불가'; + } + + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String( + date.getDate(), + ).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String( + date.getMinutes(), + ).padStart(2, '0')}`; +} + +export function formatShortEndedAt(value?: string) { + if (!value) { + return '종료 시간 확인 중'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return '종료 시간 확인 중'; + } + + const hour = date.getHours(); + const minute = String(date.getMinutes()).padStart(2, '0'); + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour % 12 || 12; + + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String( + date.getDate(), + ).padStart(2, '0')} | ${displayHour}:${minute} ${period} 종료`; +} + +export function formatElapsedTime(startedAt?: string, endedAt?: string) { + if (!startedAt) { + return '00:00:00'; + } + + const start = new Date(startedAt).getTime(); + const end = endedAt ? new Date(endedAt).getTime() : Date.now(); + + if (Number.isNaN(start) || Number.isNaN(end) || end < start) { + return '00:00:00'; + } + + const seconds = Math.floor((end - start) / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String( + remainingSeconds, + ).padStart(2, '0')}`; +} + +export function formatDurationText(startedAt?: string, endedAt?: string) { + if (!startedAt || !endedAt) { + return '2시간 00분'; + } + + const start = new Date(startedAt).getTime(); + const end = new Date(endedAt).getTime(); + + if (Number.isNaN(start) || Number.isNaN(end) || end < start) { + return '2시간 00분'; + } + + const minutes = Math.max(1, Math.round((end - start) / 60000)); + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + if (hours <= 0) { + return `${remainingMinutes}분`; + } + + return `${hours}시간 ${String(remainingMinutes).padStart(2, '0')}분`; +} From 54636bfa644c6a18b15495b0c2857cf02d9a0654 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 00:07:23 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=ED=95=98=EB=8B=A8=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/navigation/BottomNavigation.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index 1254594..265fdec 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -91,35 +91,35 @@ export function BottomNavigation() { name="index" options={{ tabBarIcon: ({ color }) => , - title: 'Home', + title: '홈', }} /> , - title: 'Travel', + title: '여행', }} /> , - title: 'Camera', + title: '카메라', }} /> , - title: 'Library', + title: '보관함', }} /> , - title: 'My', + title: '마이', }} /> Date: Sun, 7 Jun 2026 00:10:03 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=ED=95=98=EB=8B=A8=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EA=B0=84=EA=B2=A9=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../navigation/BottomNavigation.tsx | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index 265fdec..4a8bf85 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -1,6 +1,13 @@ import { Feather } from '@expo/vector-icons'; import { Tabs, router } from 'expo-router'; -import { Alert, Platform, Pressable, View } from 'react-native'; +import { + Alert, + Platform, + Pressable, + StyleProp, + View, + ViewStyle, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors } from '@/constants/colors'; @@ -18,7 +25,7 @@ function TabIcon({ name, color }: { name: TabIconName; color: string }) { return ; } -function CameraTabButton() { +function CameraTabButton({ style }: { style?: StyleProp }) { const { session, startSession } = useTravelSessionStore(); const openCamera = () => router.push('/camera'); const handlePress = () => { @@ -40,16 +47,18 @@ function CameraTabButton() { }; return ( - - - - - + + + + + + + ); } @@ -68,6 +77,9 @@ export function BottomNavigation() { marginTop: 2, }, tabBarShowLabel: true, + tabBarItemStyle: { + flex: 1, + }, tabBarStyle: { position: 'absolute', height: getTabBarHeight(insets.bottom), @@ -104,7 +116,7 @@ export function BottomNavigation() { , + tabBarButton: ({ style }) => , title: '카메라', }} /> From bfc144333e0ef04474309b3a628fdf06a71b1bb6 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 01:10:09 +0900 Subject: [PATCH 4/4] feat: polish travel report story UI --- .../navigation/BottomNavigation.tsx | 2 +- src/components/travel/TravelReportModal.tsx | 637 +++++++++++++----- src/components/travel/travelData.ts | 4 + 3 files changed, 468 insertions(+), 175 deletions(-) diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index 4a8bf85..4a04398 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -69,7 +69,7 @@ export function BottomNavigation() { + + + + + + + + + + + ); +} + +function DotPattern({ color = 'rgba(255,255,255,0.16)' }: { color?: string }) { + return ( + + {Array.from({ length: 42 }).map((_, index) => { + const row = Math.floor(index / 7); + const column = index % 7; + + return ( + + ); + })} + + ); +} + +function StoryBackdrop({ + accent, + hideBottomBar = false, + hideGrayCircle = false, + hideInnerCircles = false, + hideShapes = false, + minimal = false, + pattern, +}: { + accent: string; + hideBottomBar?: boolean; + hideGrayCircle?: boolean; + hideInnerCircles?: boolean; + hideShapes?: boolean; + minimal?: boolean; + pattern?: 'dots'; +}) { + return ( + + {pattern === 'dots' ? : null} + {minimal ? ( + + ) : hideShapes ? null : ( + <> + + {hideInnerCircles ? null : ( + + )} + + {hideGrayCircle ? null : ( + + )} + {hideInnerCircles ? null : ( + + )} + {hideBottomBar ? null : ( + + )} + + )} + + ); +} + +function ProgressBars({ currentIndex, total }: { currentIndex: number; total: number }) { + return ( + + {Array.from({ length: total }).map((_, index) => ( + + + + ))} + + ); +} + +function StoryShell({ + accent, children, - colors, + hideBottomBar, + hideGrayCircle, + hideInnerCircles, + hideShapes, + minimalBackdrop, + palette, + pattern, }: { + accent: string; children: React.ReactNode; - colors: [string, string, string]; + hideBottomBar?: boolean; + hideGrayCircle?: boolean; + hideInnerCircles?: boolean; + hideShapes?: boolean; + minimalBackdrop?: boolean; + palette: [string, string, string]; + pattern?: 'dots'; }) { return ( - {children} + + + {children} + ); } -function ReportStat({ label, value }: { label: string; value: string }) { +function SmallCaps({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function PlayerBar({ title }: { title: string }) { return ( - - {label} - {value} + + + + NOW PLAYING + + {title} + + + + ); } @@ -52,6 +221,24 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP } }, [visible, item?.id]); + useEffect(() => { + if (!visible) { + return; + } + + const timeoutId = setTimeout(() => { + setPageIndex((index) => { + if (index >= STORY_PAGE_COUNT - 1) { + return index; + } + + return index + 1; + }); + }, STORY_DURATION_MS); + + return () => clearTimeout(timeoutId); + }, [pageIndex, visible]); + if (!item) { return null; } @@ -59,230 +246,332 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP const modeLabel = modeLabelByValue[item.mode]; const modeIcon = modeIconByValue[item.mode]; const mostPlayed = item.topTracks[0]; - const pages = [ + 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 pages: StoryPage[] = [ { + accent: '#F2C94C', + hideBottomBar: true, + hideInnerCircles: true, key: 'cover', + palette: ['#070B1F', '#070B1F', '#070B1F'], node: ( - - - - - SOUNDLOG TRAVEL REPORT - - {modeIcon} - - {modeLabel} + + + + Soundlog + + + Travel{'\n'}Recap + + {modeIcon} + + {modeLabel} 기록 + + + + {endedAtText} - {item.date} - - - 여행 위치 - - {item.locations.join(' · ')} + + {startedAtText} - + + + Travel location + + + {item.locations.join('\n')} + + + ), }, { + accent: '#FF352B', + hideBottomBar: true, + hideInnerCircles: true, key: 'summary', + palette: ['#F2C94C', '#F2C94C', '#F2C94C'], node: ( - - - 이 여행에서{'\n'}음악은 이렇게 흘렀어요 - - - - - + + + + Your listening time + + + {item.playTimeText.replace('총 음악 재생 ', '')} + + + 음악으로 채워진{'\n'}{item.durationText} + + + + + + Total plays + + + {item.playCount} + + 회 재생 + + + + Unique tracks + + + {item.trackCount} + + 곡 감상 + - + ), }, { + accent: '#FF352B', + hideInnerCircles: true, key: 'most', + palette: ['#07131A', '#07131A', '#111C22'], node: ( - - - - MOST PLAYED - - 가장 많이 들은 노래 - - - - - + + + Most played + + 이 여행에서{'\n'}가장 많이 들은 노래 + + + + + + + + + + - - {mostPlayed.artist} - {mostPlayed.title} - - - 이 곡만 {mostPlayed.playCount}회 재생했어요. - - + + + {mostPlayed.title} + + + {mostPlayed.artist} · {mostPlayed.playCount}회 + + + ), }, { + accent: '#F2C94C', + hideInnerCircles: true, + hideBottomBar: true, key: 'ranking', + palette: ['#FF352B', '#FF352B', '#FF352B'], node: ( - - - 많이 들은 순위 - - + + + Top songs + + 많이 들은 순위 + + + {item.topTracks.map((track, index) => ( - - - {index + 1} - + + {index + 1} + - + {track.title} - + {track.artist} - - {track.playCount}회 - ))} - + + ), }, { - key: 'unique', + accent: '#FF352B', + hideBottomBar: true, + hideGrayCircle: true, + hideInnerCircles: true, + key: 'recaps', + palette: ['#F2C94C', '#F2C94C', '#F2C94C'], node: ( - - - 중복 없이 들은{'\n'}음악 목록 - - - {item.trackCount}곡 중 대표 곡을 정리했어요. - - - {item.uniqueTracks.map((track) => ( - - {track} - - ))} + + + Saved recaps + + 이 여행에서{'\n'}남긴 기록 + + + + + {item.momentCount} + + 개의 Recap + + + + {recapThumbnails.map((moment, index) => ( + + + + ))} + - + ), }, { + accent: '#FF352B', + hideBottomBar: true, + hideInnerCircles: true, key: 'share', + palette: ['#07131A', '#07131A', '#111C22'], node: ( - - + + + Travel summary + + 숫자로 남은{'\n'}이번 여행 + + + - SHARE CARD - - {modeLabel}의 음악 리포트가 발행됐어요 + + Travel time + + + {item.durationText.replace('의 여행', '')} - - - {item.locations[0]} + + + + Plays + + + {item.playCount} + + + + + Tracks + + + {item.trackCount} + + + + + + Moments - - {item.durationText} · {item.playCount}회 재생 · Moment {item.momentCount} + + {item.momentCount} - + + + Mode + + + {modeIcon} {modeLabel} + + + ), }, ]; - const isLastPage = pageIndex === pages.length - 1; - return ( - - - - - Travel Report - - {modeLabel} · {item.date} - - - - - - + const goPrevious = () => setPageIndex((index) => Math.max(0, index - 1)); + const goNext = () => setPageIndex((index) => Math.min(pages.length - 1, index + 1)); + const currentPage = pages[pageIndex]; - + + - {pages[pageIndex].node} - - - {pages.map((page, index) => ( - - ))} + + + + + Soundlog · Travel Recap + + + + + - - setPageIndex((index) => Math.max(0, index - 1))} - > - - 이전 - - - { - if (isLastPage) { - onClose(); - return; - } + {currentPage.node} - setPageIndex((index) => Math.min(pages.length - 1, index + 1)); - }} - > - - {isLastPage ? '완료' : '다음'} - - - - + + + ); diff --git a/src/components/travel/travelData.ts b/src/components/travel/travelData.ts index 6175a65..743d462 100644 --- a/src/components/travel/travelData.ts +++ b/src/components/travel/travelData.ts @@ -13,6 +13,7 @@ export type TravelRecap = { locations: string[]; mode: TravelMode; momentCount: number; + periodText: string; playCount: number; playTimeText: string; representativeTrack: string; @@ -117,6 +118,7 @@ export const sampleRecaps: TravelRecap[] = [ locations: ['성수 카페거리', '서울숲 입구', '뚝섬 전망대'], mode: 'cafe', momentCount: 12, + periodText: '2026.06.06 13:02 - 15:16', playCount: 48, playTimeText: '총 음악 재생 1시간 52분', representativeTrack: 'IU - Love wins all', @@ -137,6 +139,7 @@ export const sampleRecaps: TravelRecap[] = [ locations: ['광안리 해변', '민락수변공원', '광안대교 전망'], mode: 'ocean', momentCount: 8, + periodText: '2026.05.25 16:41 - 18:29', playCount: 36, playTimeText: '총 음악 재생 1시간 20분', representativeTrack: 'NewJeans - Ditto', @@ -157,6 +160,7 @@ export const sampleRecaps: TravelRecap[] = [ locations: ['남산 산책로', '해방촌', '서울 야경 전망대'], mode: 'night', momentCount: 15, + periodText: '2026.05.11 19:08 - 22:10', playCount: 64, playTimeText: '총 음악 재생 2시간 36분', representativeTrack: 'JENNIE - Seoul City',