From 7e4ac75c2c3626fef67091c97e0283cd6cd99440 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 00:02:32 +0900 Subject: [PATCH 01/12] =?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 | 135 +------- 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(+), 133 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 c223463..0e51a79 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,136 +1,5 @@ -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 ( - - - - - - ); -} +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..4af1988 --- /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 f79da008a5d86be91641696a0995b4e8cdf85682 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 01:12:18 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20Soundlog=20UI=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=B3=80=EA=B2=BD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md | 45 ++ src/components/home/MusicLogCard.tsx | 113 ++-- src/components/home/MusicLogSection.tsx | 104 ++- .../navigation/BottomNavigation.tsx | 48 +- src/components/travel/TravelReportModal.tsx | 637 +++++++++++++----- src/components/travel/travelData.ts | 4 + src/mocks/homeMocks.ts | 27 + 7 files changed, 728 insertions(+), 250 deletions(-) create mode 100644 docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md diff --git a/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md new file mode 100644 index 0000000..de37588 --- /dev/null +++ b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md @@ -0,0 +1,45 @@ +# Soundlog Design System + +## Palette + +The Soundlog palette is built from the reference colors: + +| Token | Hex | Role | +| --- | --- | --- | +| `brand.pulseMagenta` | `#D718F1` | Personal taste, emotional highlights, everyday music energy | +| `brand.electricViolet` | `#4F2AEC` | Selected states, primary brand surfaces, active chips | +| `brand.signalPurple` | `#872BA8` | Supporting depth, recap and memory moments | +| `brand.limeWave` | `#B7E628` | High-priority action, travel mode, focus borders | +| `brand.deepIndigo` | `#3B11C4` | Deep anchors, player and navigation depth | + +## Color Roles + +Use role tokens from `src/constants/colors.ts` before adding screen-local colors. + +- Backgrounds use near-black violet tones so the bright palette feels musical instead of flat. +- Surfaces use glassy purple cards and chips with white borders at low opacity. +- Active selections use electric violet with lime focus where the action should feel immediate. +- Primary CTAs and travel-state highlights use lime with `text.inverse`. +- Magenta is reserved for taste, mood, and expressive music moments. + +## Tailwind Tokens + +NativeWind tokens live under `soundlog` in `tailwind.config.js`. + +- `bg-soundlog-bg`: app background +- `bg-soundlog-card`: standard cards +- `bg-soundlog-elevated`: emphasized panels +- `bg-soundlog-chip`: unselected chips and compact controls +- `bg-soundlog-selected`: selected chips and segmented controls +- `bg-soundlog-lime`: high-priority actions +- `border-soundlog-border`: standard purple border +- `border-soundlog-focus`: focus or active emphasis +- `text-soundlog-inverse`: text on lime or bright selected surfaces + +## Component Rules + +- Keep the camera capture action lime so it is recognizable across tabs. +- Keep tab active state lime and inactive state muted white. +- Chips should stay compact, rounded, and role-based: selected violet, unselected purple. +- Do not use the five source colors evenly on every screen. Each screen should have one dominant role and one accent. +- Avoid adding new hex values in components unless the value is data-driven, such as track artwork fallback colors. diff --git a/src/components/home/MusicLogCard.tsx b/src/components/home/MusicLogCard.tsx index d9d57be..5dd77c8 100644 --- a/src/components/home/MusicLogCard.tsx +++ b/src/components/home/MusicLogCard.tsx @@ -1,65 +1,86 @@ import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { + Animated, + Pressable, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; import { AppText } from '@/components/AppText'; import { MusicLogItem } from '@/types/domain'; type MusicLogCardProps = { + animatedStyle?: StyleProp; + cardHeight?: number; + cardWidth?: number; index: number; item: MusicLogItem; onPress?: () => void; + style?: StyleProp; }; -export function MusicLogCard({ index, item, onPress }: MusicLogCardProps) { +export function MusicLogCard({ + animatedStyle, + cardHeight = 170, + cardWidth = 116, + item, + onPress, + style, +}: MusicLogCardProps) { const hasImage = Boolean(item.imageUrl); return ( - - {item.imageUrl ? ( - <> - - - - ) : null} + + + {item.imageUrl ? ( + <> + + + + ) : null} - - LOG - + + LOG + - - {item.placeName} - - - {item.trackTitle} - - + + {item.placeName} + + + {item.trackTitle} + + + ); } diff --git a/src/components/home/MusicLogSection.tsx b/src/components/home/MusicLogSection.tsx index 0ea0d0b..b714c70 100644 --- a/src/components/home/MusicLogSection.tsx +++ b/src/components/home/MusicLogSection.tsx @@ -1,4 +1,9 @@ -import { ScrollView, View } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { + Animated, + LayoutChangeEvent, + View, +} from 'react-native'; import { AppText } from '@/components/AppText'; import { MusicLogCard } from '@/components/home/MusicLogCard'; @@ -11,11 +16,17 @@ type MusicLogSectionProps = { onSelectLog?: (item: MusicLogItem) => void; }; +const MUSIC_LOG_CARD_WIDTH = 164; +const MUSIC_LOG_CARD_HEIGHT = 216; +const MUSIC_LOG_CARD_GAP = 14; +const MUSIC_LOG_SNAP_INTERVAL = MUSIC_LOG_CARD_WIDTH + MUSIC_LOG_CARD_GAP; +const MUSIC_LOG_INITIAL_INDEX = 1; + function MusicLogSkeleton() { return ( - + {[0, 1, 2].map((item) => ( - + ))} ); @@ -27,9 +38,38 @@ export function MusicLogSection({ isLoading = false, onSelectLog, }: MusicLogSectionProps) { + const scrollRef = useRef(null); + const scrollX = useRef(new Animated.Value(MUSIC_LOG_INITIAL_INDEX * MUSIC_LOG_SNAP_INTERVAL)).current; + const [sectionWidth, setSectionWidth] = useState(0); + const carouselSidePadding = + sectionWidth > 0 ? Math.max(0, (sectionWidth - MUSIC_LOG_CARD_WIDTH) / 2) : 0; + const initialIndex = data.length > MUSIC_LOG_INITIAL_INDEX ? MUSIC_LOG_INITIAL_INDEX : 0; + const initialOffset = initialIndex * MUSIC_LOG_SNAP_INTERVAL; + + const handleLayout = (event: LayoutChangeEvent) => { + setSectionWidth(event.nativeEvent.layout.width); + }; + + useEffect(() => { + if (data.length === 0) { + return; + } + + const nextIndex = data.length > MUSIC_LOG_INITIAL_INDEX ? MUSIC_LOG_INITIAL_INDEX : 0; + + scrollX.setValue(nextIndex * MUSIC_LOG_SNAP_INTERVAL); + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ + animated: false, + x: nextIndex * MUSIC_LOG_SNAP_INTERVAL, + y: 0, + }); + }); + }, [data.length, scrollX]); + return ( - - Music Log + + Music Log {isLoading ? ( @@ -46,18 +86,58 @@ export function MusicLogSection({ ) : ( - - - {data.map((item, index) => ( + + {data.map((item, index) => { + const inputRange = [ + (index - 1) * MUSIC_LOG_SNAP_INTERVAL, + index * MUSIC_LOG_SNAP_INTERVAL, + (index + 1) * MUSIC_LOG_SNAP_INTERVAL, + ]; + const rotate = scrollX.interpolate({ + extrapolate: 'clamp', + inputRange, + outputRange: ['-8deg', '0deg', '8deg'], + }); + const scale = scrollX.interpolate({ + extrapolate: 'clamp', + inputRange, + outputRange: [0.9, 1, 0.9], + }); + + return ( onSelectLog(item) : undefined} + style={{ marginRight: index === data.length - 1 ? 0 : MUSIC_LOG_CARD_GAP }} /> - ))} - - + ); + })} + )} ); diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index 4af1988..f9ee949 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), @@ -91,35 +103,35 @@ export function BottomNavigation() { name="index" options={{ tabBarIcon: ({ color }) => , - title: 'Home', + title: '홈', }} /> , - title: 'Travel', + title: '여행', }} /> , - title: 'Camera', + tabBarButton: ({ style }) => , + title: '카메라', }} /> , - title: 'Library', + title: '보관함', }} /> , - title: 'My', + title: '마이', }} /> + + + + + + + + + + + ); +} + +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', diff --git a/src/mocks/homeMocks.ts b/src/mocks/homeMocks.ts index 59427ed..44e675e 100644 --- a/src/mocks/homeMocks.ts +++ b/src/mocks/homeMocks.ts @@ -100,4 +100,31 @@ export const recentMusicLogs: MusicLogItem[] = [ recapShareId: 'log-3', trackTitle: '서울의 밤', }, + { + artistName: 'wave to earth', + createdAt: '2026-05-25T00:30:00.000Z', + id: 'log-4', + imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/33/3010733_image2_1.jpg', + placeName: '제주 애월', + recapShareId: 'log-1', + trackTitle: 'seasons', + }, + { + artistName: 'AKMU', + createdAt: '2026-05-25T00:40:00.000Z', + id: 'log-5', + imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/08/2674208_image2_1.jpg', + placeName: '경주', + recapShareId: 'log-2', + trackTitle: 'Dinosaur', + }, + { + artistName: '잔나비', + createdAt: '2026-05-25T00:50:00.000Z', + id: 'log-6', + imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg', + placeName: '남산', + recapShareId: 'log-3', + trackTitle: '뜨거운 여름밤은 가고', + }, ]; From 24066f87947be27c15139041acbfa0b6b37112d4 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 01:24:23 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=ED=99=88=20=EC=97=AC=ED=96=89=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B9=B4=EB=93=9C=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 9 +-- src/components/home/TravelSessionCard.tsx | 72 +++++++---------------- 2 files changed, 23 insertions(+), 58 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 188644b..f91c6da 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -62,13 +62,11 @@ function HomeContent() { recommendationMode, selectedMode, session, - endSession, + resetSession, setLocation, setLocationStatus, - setMode, setPlace, setRecommendationMode, - startSession, } = useTravelSessionStore(); const nearbyPlacesQuery = useNearbyPlacesQuery({ @@ -250,10 +248,9 @@ function HomeContent() { router.push('/recap')} - onSelectMode={setMode} - onStartSession={startSession} + onOpenTravel={() => router.push('/travel')} selectedMode={selectedMode} startedAt={session.startedAt} status={session.status} diff --git a/src/components/home/TravelSessionCard.tsx b/src/components/home/TravelSessionCard.tsx index 23fc86a..c1e4cd7 100644 --- a/src/components/home/TravelSessionCard.tsx +++ b/src/components/home/TravelSessionCard.tsx @@ -1,8 +1,7 @@ import { Feather } from '@expo/vector-icons'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; -import { Chip } from '@/components/Chip'; import type { TravelMode } from '@/types/domain'; import { formatRecapRecordedAt } from '@/utils/dateFormat'; @@ -10,10 +9,9 @@ type TravelSessionStatus = 'active' | 'ended' | 'idle'; type TravelSessionCardProps = { endedAt?: string; - onEndSession: () => void; + onDismissEnded?: () => void; onOpenRecap: () => void; - onSelectMode: (mode: TravelMode) => void; - onStartSession: () => void; + onOpenTravel: () => void; selectedMode?: TravelMode; startedAt?: string; status: TravelSessionStatus; @@ -38,19 +36,19 @@ const statusCopy: Record< } > = { active: { - cta: '여행 종료', + cta: '여행 보기', description: '순간 저장과 Music Log가 지금 여행에 함께 묶이고 있어요.', icon: 'radio', title: '여행 기록 중', }, ended: { - cta: '새 여행 시작', + cta: '리캡 보기', description: '저장한 순간은 Recap에서 다시 확인할 수 있어요.', icon: 'check-circle', title: '여행이 종료됐어요', }, idle: { - cta: '여행 시작', + cta: '여행 보기', description: '현재 장소와 음악을 하나의 여정으로 묶어 기록해요.', icon: 'play-circle', title: '여행을 시작해볼까요?', @@ -71,16 +69,15 @@ function formatSessionTime(status: TravelSessionStatus, startedAt?: string, ende export function TravelSessionCard({ endedAt, - onEndSession, + onDismissEnded, onOpenRecap, - onSelectMode, - onStartSession, + onOpenTravel, selectedMode, startedAt, status, }: TravelSessionCardProps) { const copy = statusCopy[status]; - const onPrimaryPress = status === 'active' ? onEndSession : onStartSession; + const onPrimaryPress = status === 'ended' ? onOpenRecap : onOpenTravel; const sessionTime = formatSessionTime(status, startedAt, endedAt); const selectedModeLabel = travelModeOptions.find( (mode) => mode.value === selectedMode, @@ -95,12 +92,10 @@ export function TravelSessionCard({ - - - {copy.title} - - - {selectedModeLabel ? ( + + {copy.title} + + {status === 'active' && selectedModeLabel ? ( {selectedModeLabel} @@ -120,45 +115,18 @@ export function TravelSessionCard({ > {copy.cta} - - - {status !== 'active' ? ( - - {copy.description} - - ) : null} - - - 여행 모드 - - - {travelModeOptions.map((mode) => ( - onSelectMode(mode.value)} - selected={selectedMode === mode.value} - /> - ))} - - - - {status === 'ended' ? ( - + {status === 'ended' && onDismissEnded ? ( - Recap 보기 + - - ) : null} + ) : null} + ); } From ec4f16bef11a1d90d41143f4706f47e06f7856d7 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 01:30:44 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=ED=99=88=20=EC=83=81=EB=8B=A8=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 3 ++ assets/soundlog-logo.png | Bin 53621 -> 13946 bytes src/components/home/HomeHeader.tsx | 47 +++++++++++------------------ 3 files changed, 21 insertions(+), 29 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f91c6da..36232a3 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -13,6 +13,7 @@ import { MiniPlayer } from '@/components/MiniPlayer'; import { FeaturedPlaylistSection } from '@/components/home/FeaturedPlaylistSection'; import { HomeHeader, + HomeNavigationBar, HomeTopFilterBar, isHomeTopFilter, } from '@/components/home/HomeHeader'; @@ -238,6 +239,8 @@ function HomeContent() { }} showsVerticalScrollIndicator={false} > + + joG&3PnCNDYG1mC2_ zc9VG}3XzMV2wp2p)Db>0bK@1ow5GztsQV=d?a@s^n^3^!Gn3IAEDPD)7NDCJ#FG z&8OFvz5mR5#){JPEcR&ZyfqwWcYdxT--t^-G_SmCP<{C%q0a!c5 zx`v)PmV9-a6>)7%o4r}@%cM=N<5-&q>y#d_N6)PrV_>CI^zs!*Z{&t%CK=eN4P=SD z_}|I0s=P23okyEl{0wlPiqKUVZZ+Q2+1>`0@@xaEeVU+3K~o9398too13@;g=H$@^ zR`e&32Rsk*3?iF3`6G~nNmtR6ApZ`?l$%M+Rt(^oZ7?i*m$y{-Ojjs7&%>E%?&w}67FnSgSZOV1NZl`qvr~irbuMZRvWg?^n+q|Xj zGN>!s2X*--ol*$LNbTIV!U&xb5A_Mdxw!l=78FC^SsB=smLwL5n6gYHh~#h5HLiv- zITYNPV-2zogs~?0G2xpeHV}Set{QlvpGlW{l9G)nK`<@%Swq6VK^4n+72K3R$*)4T z`WTQW*Ly3=I641gsD6naAvKxx0Z!iJt$1^??_Q9VB-Wqrwg!USpLS2@LC8D_yJG0kN8GNXoLn6!1#z;P13R4w26-KwB46g( z1!1=cyVUdPu?POqdj z&vnL=U9!;rWq`wq_Q2MqNi1$UjFATcp34y^b>mzzs~>D{B9>4d2J;rcD@@a?L0o+q z-yT4ZIC~pEYJI8t%npP_+6rlrJG~osaAl?Ndt*(YGM{71yhK=))Ff#FcZJ-HqZ7H= zyHP-MFC|?Gx00l-+%t+7Jax*XE8`g;S#UO@>%4PMLlrAHOv0)n!V-pa+3Ip|PaLc( z!%1OMIJoKDSzqC}Z4Qzx=aqMd5#-Gv*R5x@Px0MeFJSj*gVm3d%~c?G zm55Wy#4!z?Vz53h$ZJ3zMC9Ro_W;x?f0?G2CYfOMx^$L+|{W8!`~( z$F@CCnad5a4x$K1`v&Vec($>i29Wm$DqA=?0h^=J^iF;bm}?bOPgu_^{EV@tJW3Ze zJxosHK9oXnCSxZW)MySxIfCt$NqYN!9w4zsOTSE`_wB7jZL8PqVYO)iBg?vDeN zQX#Jvj&ZN0HBS$zmeU&om0Gdp{Pw8^HBZQ!3;C6)di!y1tL-4*7L~BjVek~|KO1nz zgvkLy{1L#lXBgBhv0cUX&UC$dGly$^2EOT=q<60p>#2O5s&|))^&5OWSl2rf$<3l? zLVSK$f^gj@BuzeAA|DmXu9B?|{~->4)N#g2Q9UE;y+wHv^{KHcu<-*SdI^bEM$?DO zVui@Bph@~5o-oX{fef?i=)-|a`5>cn=g#a-o`4Js8&ZSB^vug3tR6cB~V)WB)vKdfafX;Ml~}#0?mzrd>BZ*hB&;0?()w- z-bNomZpCgdlfz5sE=Mske?T9N%y&mPyoB!P7s)HZV-3NdaW_;w1s5~=2sf-mGUQLu z=c6?;mirY6qdIF7xgd{&u9ygiD7%c;UE9(?+ft!x9CQT@aRdpZnS6J3piQ`e_2JZI z*LY|1GRWk3D6>q_TZA&@AaZ3ui9A3A(1k{=K29GVUs0xGM;T%dbLdhfDqYj_2CZs~ zvTd&pbd47LO>mU!u`qRlS`O<(%8r#YvNdq-9ty76sRhBwBO~hXH0n@vI zWg37es_@I6Sl=ePAd5v~OWy5IGTNMh$-)oJ0;aSfUFS6!dO~U58JIjrz~VB}OkL|W z^6x<2sNpbgM>!chH(_O?hnkK zCwvz~rC?d9sY^6ih7PL^%OdwnS4-4kBGHt1JPizUic-Z~p^$OFqyB(UuTI3%`oIc4 z;WFli-4$E4P*&h}L1OKw$Q7HG)e1keO6ZTesHJ6&hRTh@XRixvI?pqoxn_}U*F9Xb zge`I@oxzsU!Ce!?8Iz)DSY4KLK@--@2U^BrPm#)kL_;&5e`)DHBP1TYxHy`&I zQ*qC7MR-it(p5fzXtgJaO964=THso*neNhJsJ@P;djjJ$jlrLjhPWEdbbciyW&8?J zuuXefkSiP#e1UkIwl5Rc3Yt-2L;m-A+7P{aS-$8ctd{!ovcmJ4WqsP-a$F;N#~b=Y z$7kh`)jPpsnV1Wh6@!6ml?St!9$4GEz`?N@K00np5+XmJZQc_Nv9?k!UTjYv0jeHBs$Y`xgz?IWed(KB=6OJi0`a zVYamdD_l$W)>d(xSq0tTT30OM&A>xjq0zGG4%C_`Xc_axq)H<^jY0EAYv{^FM`OxF zLRbEr7O!y82bk>>s5~c9r*(yfxj&5`t%(A&M`fai1~(f#Zq{oKSxUZ}Z`D)=e}f-#$e#vu0cJl!AYJ+a2)qTb!fDK zbS}RAqn2SKG}kB0gyMwzLCFGC)g;QkjvZQ}#)$cdMMK3S_Xpq+EdY`5)Q0=jZ_w07 z!c&jjPkY2edxdDlLgvrYnAAS)Zp4{|Z+_*)%BQs!bK$Pldn@NZvF{jNMUPRjUQ*qu zXU;D!S@MovvfO$9@;4z&c`GTKA9m}EIq}Q;x^Fl7vR%lQV)w&LiMUYU*SYU@TJH~d z?s}uyhq(fYF9*-Q=g$^}Me*4p_v9r@*P?l=-egrzlLxYueT;>EdOOWlxwo<>LqLGY zgCz1;18wI>?bBMtjF$Y;&01?V*sM>`_KN?Y?R~U8_Xr_vzxi4KTTQzg8~0X@CJD#8 zp-4Gi;7_kXoZx&7I1OZ=+`99ZhivbY zfd|2A{$y=9uQst~NoodHGMkdR$D90?{PN9O`QVOU)^~m#@-ve^T&R-P=GF z>6&j1yyfKN64xZok%65@33l+8ByICO*qi@rD6=NeuG1vZ%>(lu6}w>azhgLYF>7>K z4)=jNfUIb^iZB&W!&n6p|>iNM3Ny&$|cT8Hf6!d<+c(Au01B`68#yhZUS7=-IQ$2KfwC z=lB`)Z8v2$Q)W|iED&^e$1e{t*N(N~iV!)AIF4D#e0eV&r#p#ROj$~Dt-%D7#Zh;% zQ;O-{<08kCj8Muf`87%s=328>Tr0{*fwZ8JvaRYZO7w!!LCp0Ip)WrMKjvT8vpxzD zyY>liRFaHl$`W}AmvL~dxQdj~`H}wg73EReR`NMaYk2nV0ClmtbY%r)w>|k4^;2ywK&0>wq2#30u)Jt=|(!b>X z?pt12%)A#yyb5g#Zs_Gaz|H{d@(fQ0FmD0NE1czfy?mWalooRqOR0GE$C%Ydd3fi& zNLCIkwtcii-tVdGQaip4)hQ7DE=s0tH^sbA>xn`1(ularsYmD(n`nDv zb2SY51YQc(hCh$AQz)0VV$$8V7B#SgUJAA5+%szo%-RW01ds6`wH*X^J?y`GUq=jL z9f)_sJo-n?&gvDf=$L$+a^qUm0h)~g+5~@e-@}U;J4+z2J`L-C{!Ywfq60@V=T~G) zBUg7rQ+Fjmj2sM?#ESTZI%Nyo`UUbKd%I^NZkhKyZ~|G|dj@V<H?T7vc7{4jt|Ms)y zf&W3}|7J%HI;2#IUM63>cQF3<3M{Wd!ZlP$H5eB>f?9=}h9|8>Q0QryHtu=|wlW-y zIP_uZ8r65)yMD{vavF~eZ3m#4v9nWvzq zTf1xB^U4PDinN`V>bDNK`EL#Bo!gJ7+T*AaV7jbY1pUrm`9iq~T~ikU)x82Dje(?G zuIiYEtu>y%1IWkuoXWfQo9Nmb?oU#=8X(L0PQWJl*hWS}% zUdM~w94dD4_s)C~AXy&<=HPpn%?+?~GloIsOA(_XcizKI)%tssv%hi`=)0ZNiPB(& znJ3KYYEP1se@+gRCjq84#M8o*bZqS4kzH`T=TV9Y1w=YR_qHDDbLNduyLdm;+NqT! z0oCr;;jS{>O*b@lmHEQ2a z0FQkyHd7DPlA#QzM4b$1x#L@TQZ?@PJ~BYYqK^h;58zNV)=L_Fo2vB0mb;qr0y1p$T#kS;9a#e`{16Ec?MQrOz#! zEImQ3NBN4ot)1LWBN*$Yf>G3Gjr+qq#*9DVZwW4jlyCU+ryJ&Oc7|qO&Rei0!S}U= ze!qVx;>rHzjr!-x%#{lt|7GCqwj(1~7f<#%a|5@v-pllR`H{PRo+e#eaHvzyny=CC z<-IQ69V*{y?4bESKJ|{zp`DMi-wwV11u?j)8nIR`8u7xBzpGJz>0 zGrRY(Ro5W5=@`k~2)V~zgWS&TJ;iTK8_AsO^K+Vh=gK4v%=!LPtByxCdb5Hy5}__3 zZl;XSqCvZhM7V6=QHMwBq{@XP!d`xZ?qjYON@ChBU(zYF-n!fKw0!pfAy5{rT#{aJ zmeA%9V^2(Hlf9q%DVH`hlm1fSTasoQ$)!ypX)%!IJ^*R92_-S-NSajWx_xC1H32~F zi@rK(i5J<>armc#6P17=KXbpwje3i#c=PjE zQ~tL^^(F7z*}y4%oPGR$Zx#nrSHn;8cwC7N6U^SLg&7Ip9mE(2h2k;bJ_+b2%mE_fc ztF)~62ci{QMs@~#_eS%Te6HqUu4Wr3dv5&le)ApjhBAqR-n`wp4mLVJf+ZnGvE8|s z0ymNZSMhgR#=gy6`2^{zIO*}2dX@wF!{m-h0Mzjh0P4O8KpmF}&_-Ms=absPTQ^T! zl&2kpe}}v1<{EhK;cf(18eZr81OV)B13{+S15KTy28zCjYO z_}U%^;%!X@XMQ(0Prq_PsgRcs;ZPjg|_U#}?l&1g3DZWl6&QczrdzTt$jUj8zk3g_zgxSE1KvDWG{= z5`I>w@P!J!N6cdy>jT6Z{@VRwh6$SMCD1f0O2ReIT85FBVIDE58tZhhF4Te$t^p4K zYpREJzNT?DSnmfu!hzGom~$PSp>>{^BqEW?*()g~$%^Lpa4q;TBzwdP5yb7~&xt@4 zoTURaRs?GI{Lvb=L{hkp>KqSr;!i@TFNA*Q5vl?1rZPLC&LcEa2<>elp`U3&d-OON z^eYcvs(;}g7YAuB?t{TPkHLd9{q*ar6)_AZ>Xcni%=}I=RMGs%cQ;1;W^!w6U*CbC`RV&}*4Z0{XV8MHbkM^zLt{Ge!T99?le)%;SpOX>tdUCAJX!uD^1ABmPwE$t*c!ulV7Ds)p{ZoP|YJ{9X&I|7?-uYNSp-o z8iE^8-7-e_xf~LTv?^l^NfydhkZ#*xV@Rr48n84QL#Aj{f?|8t1EpH%uHb?ucXf$n z$d`~ir7J>IijX!ewL@vF_=^zafFM6(MUq(RupDTt&_E1;R+F(p12F*9XFOW-gqk?W ztxfN$)0O~HJGtw+Sawj+SzbG(>md<@?&;(nPhat?75=b|A@?L#x6}!s9gxtz&H4;K zhEmRxS0{C)o60o|;e^4wQH!m0s^AF)&w5k&RAI$P@a*)fy~vNOYXHRWY?&ak!~Gt4 zzc!<5meAIFinNucbZya=XR!1HBU|{vmPZn@la`l?DxZB0JZW9CxQS9J)wQm$$+kR> zMG1qGUNB~7b1Y_>y!@he>icSWCY1B78ID4MrIxlj`aD_aiV+eXqV!b*j4n}!lsXE6 z(wVL-<_M))D#MPxR7bohNEOFF81GXs-ltczW1AmAb$Jaw%{hMH#2&R7Y5w47ZhL}`GjA%IpH1hd$r1*## znYkipvu&o)7A`gyk*AeM=_59G!^4AFxRuu$AY7WgeC_`haa>NrNPj^a1r^Nuw2!^Z}xl zTln63eZVeG4d8os=q;PX63mt=y+zDk0bsUl(p!Y0N~%jl(p!Y0%9pU*s<#M5p`5x+ zZxM<@IkjGI5sJDwb%$QeN>a?9$j<||$t!qPC@T?#$4yo-7rDlfGt6uJ%2T+x1(feT z$y@c|e3Fwwk$wZ}|ZbY-@tI74nu%8nbd#s0F`$Ti0tQmcm$+&YOQkdcGkyd zB_*0z<<7~H=$R4Sj!u6}>8I9m!Ld%lq!yXv9NwMOJ&;vd z&pNm8%|{3nE3DJIS98dfh!=Hc^$6c=r&rZ}>KKzOda{a^EC+07%{+ecL7Kx^{7(8~ zQogwx(RHu&Q=j0Qo%^x*gh>`1Rz-uAH`K9w9x>U1&H43u=LWvHb1rRm7W=8Y_-6MO zY@TP54+?J;Ve`Ry7RpmaZl-`r0h4rb=?J03S7|!Y6M0LN0y1=>Cn{)(l7)D)z^i-) zNwxJ%^gZ78l=jI|5qGuO0kuz>Iz_B^w2$HoR8kT3LLC)xsggH{xTDzYJDC;m2I`{Q50Pjy zFVhODG%Fb8qpakcW1gnXHZP+jDljj=O25ghBN6d+4Z`NdDLUJ1uGxakRALo=tCsq> zW@eNEP=Tc&8oPC_1f#^Sr2Imvb9vK}Q*qB^_Dz2BQbbg4r9@&>CY8g$p|nDB$g@6* z1C>Hauj&V8ObO} zYKw`vCy=Y8cI1(|*vNXvV_6HoD)H&el?LVzR3s@rU8fv`K{hjqV!f$8QYd_*v_p|= zkdYO@?b2d6QmLKHY{Rj+106+*p+{NR?3#hi9hpATViZ{=3eK^`7+DgVwkRPkXc;GSsRdOx8WQMbPKLh5p?TjA|aW>Rc6LUHVh4mHiW>2M; zoA8~DFI{8-A7T`YFpOpx#^(?TQwaSYKAqkRB1j#`Z&omgUQ@wYJVlw!mo}r(^ES_@ z&okbvd8=mBtC=l@sJ3BHY)haYbT3@#>FZ-gW?n(CoG4JNJc0fy0wY8(jivNQAG`F7 z3iOdY4(jM+CUsMB>*|7Qq~>&b<3o3cqEKp)^fl9m*`7VLb7MHQJsk@<-RC=E_3c6b V+4-MCX((ceH_Kq1~zK!r>keCk^70g%Ce#oERA-G;W&mXFkB}8dkRzFvdO<+D8_JH zOtd1tCK?i_i6)pF$0ji1h+)Jh1<@LlEB?M@as@8OM|Mh6tz4g|%<&C?28n^j0hA)15fH<@G-EOZ}%fdf-?|ARNwV56A%_F2?@rG~g zjo-Y2n|(G2<{WLGcc|U=(AU|KEsi@|ZT7rfwB_4^EwBC7eD(?W65#tWc=`JkerZdV za~CgPzj(=R_r-y%#T!|R1Km8X%J+$GkJ$mKndTGCQp@d#zHttWLFCWw%>q{j$vLoSWV``_yOq;%3LfrujD> z*;hVyD7fxabHkzfKl|!p`zOT?cYj!45YEf~VU;7CoBhrD;y25jXY;a>++H4Iy^3SK zN^`9}=JxFLqG!3z)wxbpY1XOxIFA-_0-V`_PMpAXtVAc)xHnj&AE5*=rqGs~z1|JGicOa9!oFXvG3n$U;_-y=%|{w?KPNzyh}b zd)EM4*MNoWb@N@<*>lz|V6C0cT5IpN*4CAHzuMky^?bKA^W9e2xvgI4x@Mv4DtlKy zJJ(eUT!QRf)-7~dXSZnG!bNKrELv;lvew>Z%>tLz3m2`nb6&I1#ounxss)Qy+By2$ zEL?15$Fi_;F|%+mGPN^g&efk`p{sAErOVLNVNB67)S%B$*PO00#Xwn2mrT_bfBSO- z;GOMKFZZ>P-}#mQBdKShm4ZYyDYe7oF9L@WQ&2My0l2(byu1`Z|lJ1>1 zIu$Q(ocLb6(P{ZbSDUUq4-66<1}5b1#$4EGW|=H$j!@p6O3Gf zq72JG%Ig2!c?z6fQm2xU_vU2f<=hL#eY)N4|2uEB&Lf7@fE@_w>z)!`X;|)}*T+^W zMtAKR(a!YIzbkDH>QiV{lH7fK{P&E+C|fj9Rv-SX>SMaG8!wd)yY9_h zx5L9@2T6W0aQZPH^N+{p*xA{w@{)Bu>+8#_a+6rt*jPlDCm%-_{L;+%*mOzS{Xfz_=qTY_CQ3;flnvpst8B-fY|R{!F{t5!>EbL8F%^E zWKMe3!Hrmf;XDifX&HxWTKUs471QB=?>$h$Jw(x}eM0JK>jn$I2 zVZO|WDVpdizGro~!g9Ow(!)xb?+!uuDTo8}S(Cj;v%*8&Fz3QJH5n(&SNg5^<+1}8skMPS z*po8eiJ5^v+FnzaY%-r56P&y~t}x9B@S>uN@lzntyn`!iDBWeRK6mBX^5nxcIviDO zJ4I3>K)>*9jzV&ab2Rq^fK|aYB(AIJkd1ucMBtDLTpj+$RasW?{)TAm>p~Wb<(_rz z!=cM*zanOaCO?V2B&nYyT}vQ`*B#m^wRt&27ES2KMJOyMS`JUid>0DOo{3)58e&&( zG1cZ|qb{P#V3V0<6CXQt6^n6FXYljY=zJ)^HzwcZb zoP>*LfnR#0)atg+aJ{W=`F3l0OOaN!A0ps0Qpsp8w$M z%f}j5gQGV1=mU5e#=T8Y)b-*yxLFVi;iMy*4U6{bb$DHi@EJg#a2(WCTgAPfUPI(2 zWi*dkwMr|B7!>ZDEw?gWStKZ^Hh3IG{;Ly(OK5sZ{hfQa-Bg|`xqogividSn9>}Rb zRCA?f-EFPug-edjt&LC#e@5#75R4^!=M^gcPMGSCk(r-VUhP%jj8)It(d7{{Uu@gf z`^CdL9R%$~FG27kFRU?erW6EngE3WOMZ;0!MiQ!T+E?fbAC;H|`j3>|ALT0+Sb!Ft z98map!%MF^Z_nD%PdwkTsE)oQaacxREIvlf1kU3M$T(+yP=1%+{1rQ|gB}oEoAYh$ zBefM?*ZPb!^35WD(9!&nne4che(xtuyUU}kLzM(tfBjF+>jw!;RDrONME@!{ zqNrzM>E#HhP=X94`Z=?9JwQNJ_14C&jSLh$&(2P5I?%p9Sh96XWWKevwfjr|?UJQS zTeFe#6Eana-eL-ToY;@s*@aq?u-Z;uT|-pE6xNaf?cRbmR9ys+;6zikfNuD z=w80>Z`!L4-U}I#=A@@opM9ZKq{YtcA`4DCVgpNv=kOQ-U66PFQ^jK-8OUw>j7C&4 z;kuWy`e5v2Vj{)&&D*a##9wzGdPx6tb^Ei>B>_kRLf?TArYmuv;FryI2q{*yoZ=#= zjb_E+rkSq3S}wwa#-gq|4+d>;`H>$5ZWB4#XV0DM=n}uG`xN&j^E8JAUd?z@&5%8D z|CpSNIkU{*tQXEL*glwpNZe;?awyyKB9gWa-Mc-Uv!*_@?_JBGmVdp|3_XmZIv+O- z4Ig|ajr?-N8BHF=_i+D)2_AHNB9fdBr-bEWmXji`;tQ26wfj0fnW{eL^rK60^_A<5 zJF-^_cC|$15Wg|r#E-1LEo4Mp+vewcaIbV%;bU8%wBZlP9F<*Jt~AG$8P?jM(smc1 zEYKD(wb9!2t#g^4#Vb#pPHz-kzEITo>}`EBBQeV1XI|C7n#9BvtfilScW}pFi@7m_ z^}+<~4Z3+cY%u-hrb>)-Q6^$}=jeql+vz{~@0Jpk*_D=L0au_5K{#NqVL; z$MBs|@-OYIdxz$0pEds&rHt~2{v_}ecFuaGmPGVltUIb)F>mj+%^<2gqyt-S%`!RH z^t*qeF7wX^3}601%;PCWz0>;QXEQY~UOA+5zBY9}c+3F)iN~YG9}sACcAj-wN2529 zf&hG2aw4N3_u22+H=Pbre7elQLm%f)?kU7F|D|%Zv#vL^7~S~&uxFU#(Y-Z{Sa^lh3FpH|M{5 z(U&&G2D(G1{O=KBI6BTn$7m(4e{Mr}h z774EsT1&oHZ%DK)LPHPfD_Ij!Z_$gZ(Uy={kFiKGZGNzK%G&LcTN-H%&)tsomAc1o z-8%lxnUt(#$T&&GYNQ`e#(f$`M92SdE3LdQA~ctTH6Ty|iJmg-P^h3KONK!zjWnLh z>tJ#-?ze{cw9M0mZFVFd`s`^wi^M>Gi^T&2|C7Fe5+mRGMwOTh)&+i_wx8VHcTI~T zd@nd;-9IyRTFi6<`)7D+kL)1eqlOh=BYxZPyEeYd6e%*t!;Zhlzi!|^HUjIES`*7T z|228sl|7h&vC3A*1NF(u;|dHmkhZWRc-s z9qlN+oHYkk5DAeH7euFR7WwQl=FMp}eIw5Si21{ggf~+DR6Q`Xxqe-DNA`d)qH$mO z_c29l4X$Y2|0Gn1eY6TENewp9yN{aXZ_t7qEoBr>;a!#~SPh{X(nwomn*~RY(eU&7 zQ7h&9t3{PvgF~<8+KFF|mQM1eAD5>N3AgtY2qf7|Ccrj91Mj8Izj-mcp<(m=B}|@7eCPEyUc)>` zp|%k`nQ~kvuAK4)d*?FP|IS5RGd%Dl>`5r^Qv9cZSv2(U-6&Y5E#YCEW#<^{rRqW0 zi(~ybFLuRU>bCGor(FqIt?`X+(Cw5@13vFVA+8Rh<7QF8Y8I0qSU^MX;N?G3!T%#K zL{T)`1kJthNL(Q3rH%2%HvnNk8zn8W3HF6gdW$ z+cYbvRMFih`}w~HTF0`tr&PN71se9W%~$bl(^Eyezc$9;=}DRb4L{ z-gcvQ9amA-LYNnEaUDqH!#2S@bDON?T3K{bMKR(?R+>p*vlTPv0%heO##69v%UIAB zJDwl$<3N*9gm7Y1u~=Dhx;xb@f2*Zw7gfOBcX9G$@Who1Y%_3KoUAW_Rd*r>?nL$k zYnCCG`el8%vl2A&(L0=|xz_7CwnKPK9u4j*gm3!rsf#mBIBLou#B=)$2xTzle32OX-mciUX77Ao*u2B}c$B~Wdinfh9;IApClyOhP@N$}`vBy^Ck zfkv7r(b!oCGZ7K}en1xZSK(!19`5@FdxZ$TpMnTstgI=lwS0}4zb1P4-FCzPu+)~g zhi7;lL-;*gC07)`7Kt#VC#&-It}4UF9dwG+zJ&|69I5i0r>W2)`=-&i;W5{b_|YK_ zkLJnoJO%_GoZtGA0)n}M)A5@B^XrH6;PqH61DJofr66!cpM>!hWvyd+d(>tVX-)ar zrw8W4)6NGne%kago>lV%Efp~3k#gS21DrU`)@f&JEoPluwCc}-#VrQee%Q$TOLJKc z%IR}gsUA4SGGRFwd&hO!ZvRz%HN5f`<!ub)SQ@_2_E@v7$ByUbp*Odz}+dY!H}jRFk84LnaFCk+!I&jZ$T(xM8)NN#qT7VBX^ak|v%aU@3p+A3kq1^mqE?Ro6vM zYjnT#lFBY$uHCBrCUlfn;BiT?BD?F+mer<()fQ}iUxwe`3OnnWYNaWoF#g@K)CrS0 zx3y+#K;s-|4n<%bC ze%QYyhLipHxzPoLyWyZzf{9u3N*vvVm>(+5m8qm8ONbw z0YD!Kplu3N7(vM2E~uo3kltoaoB zc8EE>5+2XINs^G?w~_?kBZw((6!Fp+Jz?$iu~Hc}DGSEl#_b-Tw38WQ?(w!26vxZJ|VCFN79(`o7pU9|&?tjJiP zzfO@5kszFcJuiWX)BCp@14lHqP$3*iVC<#AL!6@$-ee>qNulWL2R-jaO^{)zhWe+o zpH7-Bz-x=y4RE&aClyqg zNkP(;j6`m_rp=`Iys>m1;d?gmrXeL~J9aL<;157!-9+2o=6#kjrZ)Lm5f7 z9u2EpycF7o5+0DyM|j)6j>Ry04Bt2nbP2e^o+hB8>E0Lc4KsKZqg#i>SsGBN=nG{H zpKpvCdr*jt+%VBZeGKXJ8!eZX(%ZEY-#Fa{xwCeB%!*U%$!W)P^BL1|qK0WD5E=2* zM}vddXERIkiGI>igp3Lm0}+k@PHYgh9++D?U#a_?NWw(~|7wAB@;ecfP568>3MfAo z`E<<%7BfwQqjWQt6@m>87*VE4~NyiO1LB@@bs0pN{E@vG)ZeJ2MmaD*=#UqzM6J z&MWYD1#18Y%Cjy7-woR0zu+zdD#JkFI){Ac18ZvaTi(Sqp`@%(^oCf|n%Uh83YW;J zq?iG5kYPsFmA6VdEBr0Y!?8G&O@{X4RSi##V4^O}IJ%F=zwfO(LDXhS@VL*1#!-f& z(8Nd~!3gZv9K~Xx*=Pzxn8LtnkZhbqq>F8b2|{$58ob**@nLS9`z0R72W^~xuanWo z7^!TG)zr$!I-8$=R-NLHp9jIcc$w5r1#$BzY(PMu-x-49%dTIYeD><5(8I8_z(k-Zc4~j z-|LdlWuz_BGV~v{8}k+T4F;kC9Mp5WbCh#M9m7RS9t$RmVJ3u_2nuGK2;OqD$f!S+ zIT!Y;BZ3H3VzBby@y47JBxY56`RNJo(-?cA#cyr{F5Em>c%Bu+dVuqBStGB9f!ncp ze!kKl9P2j>3GTVpK(E=$V#vf(S;XH`$`5Z0Gy6z@f&2+S;5b(p#jI#Q0s>RPu)8t( zl?t<_!B16`=Vl25Y9P2FjtSiWiGB37J7%XWbjuj2@maBD7+8b+%MGE@Lg~fX&+-5G zinYo8ys-y;Mu!w^Ma2DyD`MIt^}WyxsTr|)hE%?)$~9M+Gw0ZaDI*zB&&#gI2c&%rm2h+J zMfFTAbpuHm0k%zR!Y_KLNJ>%%QUa8clkrAn&}vj)W%4<4%*1Y5;mzFKyf=pydgYD$ zAag9qBAuTNam{DbXaDAJY)^0DZ^15&;p2tW*q--Pf$Gbt$DJnRZVy)&G7SK3oYK*0 zNjd5R1>-B}J{|Vc`~LWKbn3hulv)Sbk9(CNbH{LEK`@>&J>AkB`ckw>6f$!uf}-V9 zj0JChPT}B_X^6z={dgIfb6ahmQNi_P^F(P%k@jb@vhWr8AI(1!=#rf_6gFFT;}`i; zmdj#4>W_Kb^QJ-VZ~V9os)Np~H8(UWFuy;#NjJv+Xj)eK)E!B?EG9ENf0mNSRgLy#u=DN2*w2&{<{^>Gp-s03=g)+*BltfEI{xgSan1Kt$HKlR zjlX(wL<{i|+ryb{5Yw(x*c{hSE5ky@+24A_8)Ud|8P3n?=c*C~xO6CoKhnvF>uCu+ za55wJj^3B)r*Ki)B|?bDu`1Q22vA4_oE2RHaJgv`pLMVw|7_uT zN|-Z|-acvh6(7Gpctyb*Jjul__fcZz@ccBd%NEiSXet!G=dAKrZC#|yeB4Er5c)-Zlt zPjNP`Q1pmvEvF{#W1H0A`ZXdh{$VU0*zynQHF7`h(=#Cj4k_8yI_;lslVDZySeY_Dgj}p`8h!0-GBdrXf=O;vB6J12b zd6L4ZQ1G|`3qjP&^G+PKgye;>naf|-xAUhbFe9G^KW0D897fGQMqADU(cbF}iLCA^ zR`ggXRPw+Z?LexJHzs{!TjAYE%rrxg%IbJe*Fq$+bm_G7s=?YP74`>Xxj9$sP2Kmu zE0D_@O0q0sxpC9Rw04wg{Sqor zJjH0@1!Etu4|4ZqbHP>XUyoyR6LOXx>-)TARl}}@7y9IKZP1E3ebeKg{zalhts1%uRYeh(K!t|vpR9Gz6u6KCj#m&gy`}y}>XV;6-3NX) zikvrEH;yqSkr%W!7G50>+O=ywlf^*)NZr?7(+wNlS>xJ}|+jEj9pKlTO}#>vC* z5x;|TncfZCZ|SVvRii*2Uk$cf_fq@dkPVyi72hM3{o$HXG>u3k$s#MbrE7Lt6BSlX zfhzi0r0N3|tSQj43M7h%DX8Q>0m0WuwxrJ5^CB((rjZM(wKQege8j`SJ;O(wLKS!` z32vB{NxkPx#82>MHA`{drE*-s_L5@DhH!5#rZu1Qj{@%(iu$_@Vj z#aN*nuC&{|EsNLP;%%I{5^tJMDo`TrTyWqr{F&Z7OI2E^1tI@=X1`;Q68p(9mLT8r zY=|iR2*hh&AHJa#L?5q`>eBM-uLYv7m&GF&KA-CF%IEAuua2w2QZ0as>1|#5#?qt+ z`9wc^F4R~>V{sstzQC2Aj`KJeORFlax&VJRYkCyIUd2VVS{=vH-?!vU1EyT(m zwf>aiUYPGYL?v4D_&(~|KKu?yp0+A+bY(mXkj^5BQhgwNaQEA^cFQp_{-e4`+%M#p zM0RPK*m+)E67o@YIZ`j@>#r13c|HnZil1w7}$aUx;9fTzdFJ(UTj z3hQTu2x4(cKz`vo^WkOF>8`NyTrPDCFZPb>cZ^-!O*VfXZWxKUrY}i_1D2k{m+>_}U1H5ldAz!? z_i6m18b+alw<=Ac0BNQM1j%YA&yyutvbdov*?{bZ=S1|w$~Plc%5kzThOFZ6tJzp$0rXQKc=kMQ&xPzI-+1afOl7ObZQgALAQA zjgB%J_B!<(<#V6al3{-DTxB#pj=~HVW9|9>+{WYI6pj?A2Y4B(v@?DeuNakgw6tN8 zN9^XII!oN-H{(6+16exAw{wI%I1W);T-V=Znm@ZWf8ElSi6rC9CLVtqxK1vMOPlKu zpt3?MMHv;*IX{Hyr;jg+aw!!rMD^qJ+X>6+EGNRREMh=&3+lM|I*uvfd&%hgYxqSq zBuFJBWSe{I@59|9b(iJiyg$9gC6&J~q%(mvq$LmUA72(Ry>#jscVJdGt=6O}I*z37 zkY`3K6f4=8r`cSMO|sapjRzrh)C>m5HliBd(S>Z6tSK5@Tej+~RceW5FZ*z>-}pAe`e&rF59vHZ zC16ja?8UlCuF(?(dzB$CBXfEtNd@5?IdUX#$s;2$9wv)sR;tkvs8p34x0MCnf_!r*AyHT_DYkS^M+uY z#j(>+gfmMJdQ6F{UXC@!4jfvwJ1&%_$nL~n{8R%GYZ{Di*2uVWCV$(iHbnruCqa8T z!Vw@UB(&W6QH{%>_%+5Ut(uW6g%lFE(rs4Eq+%PEa=4et2 zucMc-1TB>mR>!N;HQ+;&68$u2z_CRtz@MTl2$Q?SZ#p{1pO8}s)Z?9jKYSh!+&{9C zGDHq3XTZ-t_y)o$H-q#93DOYGm7h~w#;>%xweSeQyqcm z0@_2Atmor`u7ojrY12ov&WsO_$~O!7?WEb4_>CCPAQQ(OX9DPp1efHl+l>nXRvo&Xs-klMLbO=8w0m_bG}GV{)QG+FdiE29qH%7Pp0Ltp=k(ieYSrG6i=k!m;fSgQ1MWgt4B0I|DuWMdqs0^nlEV&j_ z;7x}M9bQ^D2t}&c4Oruqjf#zD_ZABO(56hX8IMO~j}?Pi??3YdQali&xpx+Ewo?~T^~!XwLKL(LFtVK2={Jj0>e4AA6vg^Yc_ zFzQ9jk2feTBA~f=GIbl=)YkU8lY3oFSx{o?3iE<`{sBp@HdGlHLE}CeDlb^H23*|%^(qzhFS=KuU=xv zX)7c6+g1>a8H-_B9xL8ZdTDs*nYmtjxf_k6BEW&{BJN)}Ken2Y5TnxuwI6 zxABwgGhGVB&WknNg>n2&T*k-SbJ()+Bu1P;ACAdrhE9^cD-+I_XWK$}TPqQXDIjzZ zFsY!Y2@X1vO37)#UIq0ynZt9SiOTv90#W18;Gndm{a;fWR196cKYdCYaYffLBZX+SP@jb#FWtiRzfwX&*lPXqi9s6g zcHhKs3%_bWwinmuOY0knjw!_Y;;Fo`h9ohs8>1R8l6;O&7YxI9#zZL%WC-M0=Xb5!%X?U~s%zG@o4?jN zxjN08W#&A`&T~cAL(`&po9dMRJ84>Q)+1*@&v%4b`Za0Am`d>w?;qch2lm{u(U6AF z8s5WfMoz45GwY~~E$g&F5XsmQVV4l7O zJB9&qGh9_f_g-u|x+;JIUX@zi$MyHC>?0}5LZO*#xljAW1D%(muMt$DAjnV?ltHp> z+zwm~Y?TZs@D-6}qQ{UTosJi^ka6v~r`D$}|9U+3_4y?|<;BWbuJy=NU*}|r{vKj# zW&D@|2gx9fxhX%F&Xf1BcxCv>*R$B$Ux!IpJb7Y2w(e_ejAz!k`d5t0l8O{GR$>`X z@-u;|**4zSIbljdN0F3TP*F#=(agRMTmmN7`=$V zO!7owmlOOtbkAU^f2QT%Pn3RU`bUe^`tE9eUoe8I{%-wGjA%{`RkZu z{!j($QA8jV4o@WC6o%s5Qw9U$X7%#eI6OUj3B&n^_b0r^ul8qPYAlYa*VCYtk7G%i zrX*V`iU_Df+FPTxTSuND=>9-Hwr(4dh-6sZoXBm?y^Ygh=Yn5KojxJJDG-vA2~z3Ks=>iqJ0Y^~9!HIHmy`&C9LlM7cxeI`;%vb~ zjz19xK=}AZ74&ww8d7tqd;K&JU4PIzN*0-gBB&tGWK+S;+iUh58K~MK9KTyJ`P#Ci zYO;5a_&cxuUH(6NLy=;F-t-YKQkBj;*Aw_-&f>VK<1)>WQ5nY77?blxwqAwhGP_oM zsbGXWIcK#7RSM}SE}|?*UX3k$-fXH z=ZzO?fVB#^??QdxrcxfmbjS9Yea-)r;Tv`;7-xicpCj$NGbdDruA0o}eRSD`JNsVj zqH@mOWZ!SJ*jtsuyZ@rmv;RjJ#`izPXyCD<1>VGJlEfPm5Fx=ZMpQ#?G@j(d9a-JJ zzpQ=c{ZG8DhpbP6;L1(~p%u{9)~49m@3fNgCJ|8sQls0VlGtc~(C=yaD+}IQQQlJ# zrrR4bw1y7g`4QhojX`uP9L(?OQ=Xg<$b?TA^B5yPX|XD-O;MfIvgHvs?WG>27d2kn zWcY+_P(f|tR!!^F-#^M-`}OGU@z}v6_g#F^d1E$B-)b_o)h0>Ke-^+kjUB%ayTm|h&KAbp=Vp9jgpcv2C(B|-S=UDJ2V9oQA}P!l5xQkVSAxV|6QW2L4He$l z?B;fv0UjDI2FZ)%e9aX6o|fdkYg@n{wgCk}695}_Or_F&kSc@w`p~p=8Z_bqC-wnT zZIPWJ)KHjY!S^r*2&K=1UVm#X&aCP2+V%#~CX(y&F}?^7bgoZ@eh)7(bh*WM6};+-~(qrIZ6boQvzH#|1o_^~USo%AVWje6vR`9OJOLQYqf zuk#{hxC2ea_nB!0fuiHmbdDMm&_8wSXVNb1B}2(96LyZ#$`|Ej9) z)m2IFsa{z6c{Lj`j$PRO>k_SF`yQR2GHva|B06Dg&X4KvW-`Hn6!V3Ox~ZU7=^$Tj z00aLsRt4@Vo)Z|j_$h)a{8L`3fNyb2rMQ(2g8PlnOi{}g{+RwAHOiw+2oTAzCIQTE z->QO6Eg2cH{K^QIXL}UxC8(#Rkq;TBg}=m9q_>u8EI7-MDnv@67ZwFEYJYgt+;P5z zck$Z~PcK}wDoiQf@_fBuu(I#4h-;aKUrC?;E2-R*|F-dKd$`-W9kYSdiW(2M;>!uB zzE5x@WTOh~*C6IW;>h^-aEv;i=`?S z)JB*nHAi_KLmHR;-j!_Ivymi9nh$Zhnh@p3=Rai>bm0Hlh;ncv-3h!e)IF0=LX+N8 zostbBhn6nH$?51g?Kf}N!PJ{yo8ilh5xfQTq8=ok0DQ(u*^4s)p`ArwxT!%P#0mFg5H-8_XS zj{CE`H0J6VGuk%{>VotraF6!fE7u~m(MjlrT7&~3+P2$g5DX>_8HntH=Id(o^F(=z z{=0b4Zm!9{T{Xq$sdR!{qM!yL-Ye#tqMvpjrsb6hv>oED0vl2qzfpm=B(S6X@5%+}||tQ}Crr3-9@M2Rgqd?VHGM61rDgb(!HRdpJf{pmrH=0>V-!nseXeI1)-CCBRgV^I{wkSrfEFA9Ne#Y*-r z;vAh0I&-gq?O&?kX5i4HNPMp`deAnR0XyX(<8-9$09CFS*e3?KPi4-&7%i#1XDCQw z_svk%EsYE6`9Stb5--!va$0wL{e$66{90bO=duOp<>-bZw+0{D{dWIb@vJ@CLiWEE zeY?IZX()ijZ^E-muW`$GV?Vlj*%E=xKR0MKISY7kh4`FV6K3xek)%VsoUX1tI@(*# z2roxxr&lj@v|!`p95O{yF*c-)o(Q;T*?YLAW}I-~1C5t(`5hq=X%}Q;N7xL>I-XD@ z#ZxDQ2X#p3+rA-0(1pyNLI@6Go{;-+BM@Dg51)@dqGz4TTvYh{`7tWcbty4EcS%Z;xa;Mzx+athoOF( ze2$S~ zV(3cH4?Ks)9_?7*=k~AU(v6PSYzj`lyPIMyo96}zf@@E|8C|@zMenJu)*JSmY5|VV zjrz2oN%6N5Qc+<8glw#f*{cG_o}ithY!w(X&}75F+8sc!J%s4N`9;dbd@rFZY^Mp< z1-`P9(&e&c4thaU5UnnCoIfq-QnM^6o5@*Hn{-btLyra-t(X5JR;@HoU_-~f{7Z{# zUOplqK>}_C0g>-^G`TC0{M?4$AeDn%2g9*1#1@GakJi_vgta_YcF1@ZdiB4xx0hxZ z&A9vJKu)X$JF5L_&k>L1U`z4Y{;`{&uCM>`s5I8Ni z_imcCd<^ccz8=rLra>V=OtiaVa)9`{cry(xYRVLiWp}vD;d6j?d&@(#_k|Gy14fg1(B5*tuoPug-)wgn)<`qSCl1RKMU$-6C0Tziey-TUgQZB2*qU zu(gb~2UmpP<7{cseW~meV^l+hJ^KfLYC=?U_hM^n6YS1-Nza)BU>)45y8BJxOp8Mm z@)xRV5Y#_Ip5n$=^e?|p6)cIT?gpEh!-~w0d-rJvOc@L{C1A~xS6ZMzB%>Z=O9nX20Xx_i{5%cFb?7|V9S zN0+SG*KMKn8m|8#IMx}Vw~0=ME&FJbA*LOkK!xcOaSL&a!T$3v7`&kah2xD6ve=8~ z<(iiqoe{g|vSHhOGYFSvznp{nV;hR=vl6!q-*tJm4Be~;0m`li|0#3O&pNvI_(&(j ztR&(#3LF~KltSMIFRju0lfwGjjE5V|-aFPz+bw5|MuUdC)3fOE*|$F2+zyG4ke@9m z0T0*S&GL#1Jhk5JX2jX=GkoMScAWHebV;+S&64{cybN4DQ{5}YH;{7$8;QVrW!uGu#+evfGg6)8>* zH#**L<*VkCMY55jJ3jVO+O`=Y=my0Y??E5DJk4>3myZVuN-VbuVt()$P+si`ecM7~ z?_cs3JvKNJJh+bl*#bA}d{!PY*-K8Oi&FDN)@YsCv6W;%j69zPLZONP-EWj z13{Qgf`}13=(TD8fv$<%^45xD!dF%&Hg;?dbQ+jW*WU7AI6bis(b!F{z_Q)i_q!YM z|7g1pJbRHDlBvl7Aa>}gA#ht=I^d#ELd7-9|4P*=dg)DFvkqQp7jH~CfP3G4ybKX| z-+j6pPb#S#+HlQf;q8``u1>PYf+Kxx_`JejjlIZyg}&I&+FL=uxvW9R zA*U--Su>77_K|dLa+SeZO3cy$@?&d1-BCa{4t=f)0cjbcL@x3UOCUF44`#niSz^YhO~Yy2wW%`Dbwq5s{}wh=h#PY`k4y8^1QbaAq=%Kkz1T-W7t_H15^t^Z)w9W3RB z$6?8TvVpC&JYVZT!L{q%+Z}`NoQstasB6*cnJ-=NaCvOv$p)_Wl`r$zO#J2Y^jbz? zmsqGp(SkO|3L|2t}Kj56hArk9Z71(;`~$YG7rTu=R<2>;L2IEWe`szCL`0p<6*Z6{Wiy zL_|rY8wBZYBnOZ#1!)OEy1QWz0qO1PM7 z9nI*4{lz%{83%dy`u-#1iIcUP`HOi(gy_THta#t$@LvbCg8kJpmA_lWwwu>o5BMN`7@i!Q40gLWfLQqX6c%($~_!cnhF1g5g!jXDuM(0^~KG(cDthns41Z z7!|rgQ0q{PTH9UoyXhjD)O}i#^q ziD_3aFgAa{nc60*yS^EC_%OBi42poEz=GO}B+niQXnaoJ&fcFu=g(qu+tulr-wr%6 z{u`M@jUWTC)aWW7N>t1VNN`vYWVm=HMM#R+PWk?aOh*oUW?2S_UAr4pu|`KzsaJ(z z!m1yOZx>eeg_;q95F_8#9$77qAZ}S_0oF7)A8@l?+?O!vduZ35B0<-*SY|H zze34x?L&-q>}mQD9H;1K7YzT(fkaAsawJ!+ODsX*+XsSNNAYvn3z3jd5WRir|XKCC#ZAYSAi3 zO`-DdXs-e!cNVb+2tA-Q$R4Co1+OR#=HQtm87>G3HbTGpw>Fy5=S6@cQFkkLHV{7y zFf%^s58WM&w)GH5N(cXyrU6-fL;akh&f8skI+7!%(?Av4$^3ZzfaBWE= zqk6t5FdxfdPFmcP8(KfEeWD7l#=~+t1XhK2*HMp3#wQ9=|6{cEF{6yj)))nimOlbd z7#SG0tIkeO`(vquUAJ@hjC^)RerT1&FUB78y%Yl9(xnP$7hK1`)}z9ykyAnk^aXzb z9NdG49NGj3;gF2P>zNMx+t#Zk2j9IV@1sQzG@&cRM1FsCtgCO_(jEouOClQ-0ijxc zpi5hcmTkw7dEWfcFe_+$%XWqFI`)}FCTcsa@iDIFgGyyLE!$nC)8tX<@w)y#2a=;L za(zEATfJ3PwO1zUy69$>v|?p?^1j6s4YWNA@D&Z_z%T1FD11aOMF?KT!`?%V4;)^VLZ5<(N6cSw z4H=0s(b%40UnRy2N;)~cjl5PK?4Rx^BsfYkM8 z9$Y}jE%f|O;u$kv9=()p}xCeO6;)uc~;;A<-x`ptSOP z5rs_co5em9=1!bB-kK|tOe5x!1ZN)dn+}PYH)c*qYUX`WHhIpY@C7<>)71{4>7Xu5!v{jcFqCmR!zUuA<95X;+ws&EW+|$eso0E5XgWi(a*eZlC zk*&VWbUZ_tK)4F}vT!sJYAd~R!e32rZJmXVKxPfHaaf?{vdD$-k-+#Uh6!AE*XIbL zKtS9t+UN`gi)XDTXABFc@Fv0ywT?+^d1R8wo0$fA^{76inL-35v<1mmJ!CnQ$KD1r zejGs!W&Gw zR!-oF8Fdfukh+gexSsW*9Xmh!%*J=isIy5B`&Sl--4pBGOCrq?xPeDAF;~|pi3&hI zht8ghSPc~df$t+jXzNr`KcQKKh&P6P?*D;NZBYL@D7-$15rV{wHX+;bL)Q16G@d*r zFpHb#6G%|(;`+-HDG+l4jDPzskLvG&FcMTw4#3%sS@RW+s6R~)8G_kphf}LqiQU*@ zudorhW&^^dv68{ZbQf+`=s%9Rx0m2IIkIBLmCfsPLG(`pO(+lbKEp zPzK0*V9skxNT4lH!sgQ}XH{qn^CD(zBaz>ECU68%cUZ2dH0XK+uWl@Fkz;Enn}>Mk z`u4{+l6U94DwQdc4SSr7T8d-Yd&B5DEwtbrv&DU-x^atqaIu7_elj&J&GYcQHD_(i z3Tklwg$MN4NL#}dDNsQ;(_+IIGo;3oklYM1@R~&QFf10`fjoUG#w9M-Ez!dMXpm#= z&)9!D-|)VfL2F>;@~Zf!EpSdde7?F-ie8B<8g|2~Pk%FzgMeq!o#HSmGo2U!r>4h; zdmOW)0G%!bijUrFcx?bG-P}X%jfeH;i7-YwUiqcH0e1nD(@fJHsktQ?FJ#h*^y;`% zhW?3U`vC?N@s<{3bPV}#-ffvNZaBXzN-m5TH0Hf+l5`6#Q=MexItXW5p16>@%jL076U{^1~z{usm0<7Ek#c zO4cdjw~;MN2-J(N2(o@g>g)hmoJ9wM)^8iWrV>(q&bEAYy%t-IB{^`O8FzL2<3#d4 z>hbN?hULc0lY@-4p#)!BlW!6Xc69}V%;L^_)Aq8o2%CU(Da$)dnB>Pj6BE@t?|oSS zQ$@$Mhkl8NeWd}JCCp3yoc{oAS1FkO7Xh1IYWCm4x7YeM{oE@z5I6H&ViOsmIYdIz zxOd?rAD81RZr73tN-iaP-T&RFKI{{H8uqVy{YGAqn2+M3u{}tGINsUi)Ce+<98>UT zVmS5_kfGSmc^C+ISVc96J_a72A+TWJjkF;&ReL_z8DB5oGZ}*io|}iM`?&Pf^>v2b zJ^FN)7e5A>KWM<9DzG1+B@Yefl7AacpHQ6z5gMENBaY(RzDf!|6{s}1(-pdc0B*tZ z!JE#0GT<|4gnDcSaiqbVX)A;KPq|})mqp;c3lLg`hTiH2U?NY0ax)J2q}FnCyRBRz z-EXWE*VeB?rNjUQ_8gMwxw_*B5P{{d&>9eHAAq(q2`d~$GPTI1y$XNCGBEeP$^W6X z{=>;b-F#cUzyHn-!*Z_9_g=@wNxGFBI_|Pky4CpYVW%uBWi0 z*CAjTxow|v!08!3TA|4JRqXev-=Mnq_T!+9Od%L_gL0$fxF+Y9A(WSU#hy_FKHQGP6CIf zoy{~uzg#i7;7tzSGE0>a83}gu4jkJH@_qkw@bB|fPU}~vl;1OJ6YxVo|!a7`Ge?#%F*(BezElQ}cG6BM@zd2XKW~<@n#wTKwedSbhZW2@3 zJ3QX@4Gr}(v&>(GY8eWj_R(G@RN8mr(xhRSa;yj&k-!OHqR13UDn{0RV4@sxZ+SEm zEtVp=)R-^^7|q%|B@jR5JG-JKsPy5xVZ6%}KN!BF}nM6QaD? zN>$L$8lG`M#`3jiXjj3taTe$hJZ02l-S&tK_pxn>ICId^5QCke0Ut#QT3X8Zkn!K# zDJksEJB9j1RlEIuii(8bFajI78m-Wj6+aHO9pM_soIDB)c$Kki{*pZW{NY`b>({j_ z{UJd38eBe^z0Lf+or(4!?D&Bc(1H1l)Mv02s;C(D|xj~d%>tOsGZ7f;F`WzxOEXMFnget906mlyAH zN{a^&DHef}T%-uA*_-L2IHLI9=cwPWbCE0(xEDhOG?Wa#1AgSM(u1OF&jdmYCdHL+ zd@rCN4f~5NQ}dPpfFB^K+Cz76%>Sr*!e0zs{&XGL@z|l_!{p!=76@+y!?*u-j3l4V zOSq%`dkl${ER4Dh0GWdbP1XA!qxZq8%h*CYRjgmW=^{-AqW6C@H)N#mN~KQUz~>K{ z`@JHQrlT> zc6VL=vz!#YTy&vShT;rDv+kh(@zRfCG7Vvy|+locY}d(Qdz zd<~1$3SWAT!)&C^C0SwBV}IUb-k{+L6B~}_jonS8>NfI4uK=M27S>4;l5t0jRL6>) zhQ(c33`w%yIbo%Kj#~d{NXqC)YQX7z`n;J>_U7oX*T*Mxx_GcxBEyEi~^B-{gyvsRECc0UM6<6j5ed4cmY*hwCO$@RjMetA4^pJYWZxqKJ+JW zc=#uN6Um3ghX`^tRCq!=99crm^oY{9R@nP;z5f?G&uq5pp_a%z=dUPI0!+e*VA-3i zWp~Oc2pM{t$BpE2wZGAny`30Ro#wX%9zQ-7)8*HMezFZ$f{3ap3@OR4Cp?q54e@`U zq25_dB&YGOqy%}BJ8V3tE3cNUU#xcUx6+Idc$SFV)s}-Py^>4&dCyJ?4r-Fuf4+ME zg;5(5sT@jJSfp|3?b1YF_c%epKO^zTCrLbm(6+8}Semq*?&)y4l^-F6H z)AMo)#2fmR6g%FjBalr+R?%(bh^tP@48Fj2uRWC&`=%(W+)190CJ~d89x(A)R=Hc! zeU#5duCffHm6_$?wU~=n3*<1$>6Fe`CkSIufG>uS8+}<5`L|y_VucA<;y0Eq8MmQVb{h$E;Ukp=@Z=`+D4WV88J*YU5Rp+RT!u49v;2>gF^=|{*}+`VmHy@QIs{>$%lt7P5)){V`hF3&%8* zS?Kov9r^Pj41V(C|DoF5X-#7K%(`9Ss2oPz`jKJmufr9c;W`H^Co3!KRWYuHpH2x6 zSpe&$Z}=VKeNIiyt!gTk)Kg?^1fkluq%Z8UdA_4DvNN7Py}U5DpgYU5;TxL$&8`Gl zF524L*|lQ%FN?W&U7V+(nLKXwLmIBw5_l#Q*cJ;4_$Z2tir*b$wG=NfabKH1O(@k& zEh;LWoqn(JkU84GVOe{Q9rtES5}^M0SiGujVc>0{;NjuX|K7#U#Y1Co$E4MnC9cV} zHfKlObTMpp(eBV)Zy(t_WaCaVlzzO^f)0b{J*AWvWKA2;xDe=Fac^$P8Im|#>5De0 z%|bSS>zs?)W;redVj+Oa+}lN^)JY@#B`00DD?N}hX!=lcU|%Q75ZY-@N#%N(N*R^b z0W6Cg?Coie_MEg+-)meXqy-1Y{P#*?iO%?OSqqEZmjn=HqOfTF6p`VPc1I0=VN~Q-{<}pA23MmgcK@M9J&QSxi;Ok$$)^u zc^i>xpQh+8TjG-R7VXVHVlZKgrTd>+v@K0i00kMHe2tDF$B?$}{#KyawdRLO^{X%K z(&(^Gem)@~rVr`x(I^@i9WoX969)<`ibO-^(C6pv;usoVm#EsvbZHU-{3{U=|g`wXCIL(DjIm%>m@^t99H=xO-H7~l&EpR zDRQVRvfJETNq}72jxqmxQ%tBMNw%7TI^O9AGI26-uk+G}rbNcm$WX2!?`*jBN_~A7 z7fG7Y)b$z@Qsp_R!(AZ&MkFs-Ov%tI%=5VvE^~kj^qyN^X5s>++PFdUy**w*(SKAV zp*eM4k%mH#cLGtQ9{z-Nt~l4$UtHBsX`n-mOADtq;%t2{H_dxsohEg3px{y1tEMb5 z*bn;9`3(x|SODLN@k(NN>+4>P3wWUb50(BaG64F`R$Wl1bm)@4e*QTgp74$69?LQ= z1pWaN!XUZ{{;}gn=F$KuGpWZrFCnnP&be(Wij#)tdZH*<^u(YCUqKVVa&CU+BB3WS z@ISdMFeU=V2ecHRWF4(f)0#@VMt>+pA;rtBl^OkTnDrwnfk&_ukZ*JG)d0yy-|Df=IE|AFv-TN z=?L9*Ylg^V>Zi9ytOBXkS00e6UxE$jrM$iUJs&%^cI?M7=+S-#V-@pfb&xD%MY#9p z?NU&sH1VH3WiC9RHBiZ${~uY5SE9wL)l@a_KGR z@s=5Q6ntJMKIC!awVRye8)ImDbZWKp4Ixc>dWH)o$xv!?FzBFJ9mKe>=sd5q^*BOM z=Vyyn=HT57M($FA05W%bb0E4sRM>-Nc+q@ib4>DU>n{{U2$F%2Irb6)9tX*o}!HZVOWW8=l)x!3IweMkOQR|tT0ef8!Hq_4)~-2 z*SxJj;jr&10}9->%)$SL9=1%WfE3HL-2U15_o8*MSh>=0vHA9|ffWJq)emS%bymk7Z@%{zDx+H-M&CxQsajSWw`}^{dV=y z<;fhda7+n=309yBR@5~$HPtmanfrb)Vs2jNLEfwxB6q$rAy#adU*@r7++p0t(iHrN zH2LF$p4q=ag1J9GhIp(T_(Zst@V+C&<&3+R`FmDG?1o#zL|2ztk?JeEI;IyVm6b)P z(mBlhW%jmqrR5ohdPWB6oSdvIgsP2v87d(bwP^|oN0N^1CoCE-@=b%pWu=*zn2&S~ z=8f}VnT9&ti6n(9k;l)h^iLvsU5lFKs?vROs@)1&J=A|4z|kKXLB6OpmB3yY!-o_? zjG&bHy7SW?C@cv_Q6Hq=1ccJP=-_;oBBT2xA#u30EHl6e?Un{%pA==*WnKG@4t}OI zwi!_p&$YNbis+=3$a3}Jq4L(Wyi3hiU3;3L-_}%BqW)8sv^*_Z0`BG!bw|hNnUKx)=7JIvHyb;rvZ|`=?8hIA3igxJ z_4Mb2s(G{wqj0|9w~1ng|RQX zjJ&-4e-t1mis&RPc-v!74KIYPR@W#?7UlQ^3v0J3_~Ig=Og?8++rVIU6m8hXtTY=H z6JWyRIC-WeP(hKRhPg;mWcio2-wjnQHA3^F=#u(YCHu*Gx(2+71$Zd8uJ)rWo?uYi zerLURYU(TfRum3|jtL{QV73uSUpDAB=s9K$uNE93kpz4QUBpfkaV1P=e|L2uJSHUM za@Sg|GraNdb2`!mMxSS5FMM{!d?yp>hC@SD*454At>rnAROyHlIqlPRbdrm03X0Ju z`M+yx3s-9+nJsGP#(`gP*}nqWuAv+uG=^;x6AGrsAw0ajuQ(jdjoL;%e!8W@*BowZEX7c75XC;Jb8<4Y>u;7vDKVAbO`x2SoK(YfC6=> zURn%|gua%VSxMQp?p(4(z9(;5cp`q+u^u5#f08C?voh>tV$w}fkUFg^sms;G$*mE0 z4f}`ZEJR`(-&jIX|>l3NSKBbB1Z@X_^;KQQH<-25F+$9{FIm(osM z-F;&vz1kWs=LIRfx*BFB-;H#ZwZc9q20B5F)I7xl92JhoN=wQOv4=&I@}&^JR4%x^ zC!Elw4cdIXoPIQAXWLu8{zY`Sz)@fC)>K>Q(o|hoSlFR$wX3T?&qESf62yMK3eE!f zi>Wlp)y&M?IxD9Hm1)Fh$9VX4rbV0@T&fD8(*u8NOIwj%>Zi97#YdW|b%~@TAAEyx z9G#r7_}d%I!+3Z(HQF1RY8tB6$5&TZf8Bchpapu7IL8UH%l^acZ0=8-j2}|tx}v-K zic-USbL8~I2Gs@^7v;nm)8<53SP})IbzZ(m$}?q%s`2OQ$v)kf48}K|Tp0=vAL{Lm zFO_3t)MXT*m{Uq{VU2uVnEEYyGN!IOz=;$k%jM(GWvk+FRG7{0Ze@bQz3NwJfPh9P zl|zH`8;5P#kf8eHgg~vbG7Y~Tn|*92Zaa~Knt_~#*+zKR_?a0YHl`Ey@9}3mA|g>x z&tvb2=Vxif26hQbp?XWl6U{wRi;Igi{jUrXMr&%cvpLjlHtW76`I0Yn_H-qbHmG~< zT3LOhBW+T3ZS)CY8Oq|&(bwZiVcC8CS|$4O>hel7RzW82ql=EMecJ4#&a_uUVNqUL zwyBv*8=uB`*RF)j(C3kM+lv8xrODqby@j0@<_&!drX-P_2TslG=0_`?>D1H--LthD z&TfekOpFB!Ar&GPzrW;E6-D?K@TD{f;C?;$R~Yi`FGo&cQ;08XiRlZ|+ELfn?AI~d zb1+&v-jw49mQgqEAvbc=grbVo0oB^dK9`C2%lr0acAD#C)|N#)?j_$jG%c`;2L8aH zrv5V4JG&xZD(yFac5$0I2@Nb}a97XUhG=0t;%cAMuw>HZ=8u{X0&Y}VXhuL5^WmI+N@+n++G#^Rg#kxl zd3kw0hQk1l+o^iAlha_q=)Sg28M;5FVdd^_L{UTp_FDK8UzSgRG{~{H|6SqZ3GeA7 zQ5GfYgPf@kHg|kfY>5`dSZiB?Tk**S+F6|UY&DesM@ zmI+6TD7Utl7xAX)SvRxKggRU%=d+pD&l_;+P+Fu$b?0`OD4Bm}c?=qxCVu^j7?edQ z-b8hol=TG9h@08Fdx+*yA^zy4%@wnfXhmWtS*X+q-;@GaZ}{bp5BbF$$O3wIfn&Z# z$+@`$N(NYCumQE!JIe*#qJpBLgB|kXBP;tu_vYpUantgc*`EDDcSqDzBmdUMSxZ|Z z-8~l7p!8ih8X_nNT*|{ZAzuUXZqNJl-3C8k+zN<|?T>_aNaqugad8PAdDs0Y=8h6DPMtjiazpeWoerahBXV zYUN;GH+_ZtMv5z+etk^?59VkPadX1b!LgxE&`g?L5%xMqBK_=gDm45mitg?-dtZ|0 z=1XLv0Sd3af<_)k4k14Hu$&@mDr2z7Fjp?KyGIQp`?WzqK~7_BLoW3Jt*KcmLIWY&*1^j* zFD>8Byg5?kAdHbIb33ZTJ#rU?srwJ}=ld-$Z2XGnL?-v^-e{%`ikMPTh)G<}ArOd) z>S;qs!@V*zB10YK37j8>c=s|RN#*QhY29#ORDN_++e86jUAlj7;ox8HQnNg3>!H01 z_stMc_Nc+b6F>oML;!ZbgkYc(CEw;!#&Me+0K(1Ugea&GAfc9&L#tsFdGDu^*}5#k zYflRnX42Pk);gh`c;gp$&_Q=^Ul(6frJmbe!^S7{>|&^}Ya=dV2iw7^)Imf{z&IDa z&CSU)faK)#q$_)#caFVFE3PY=5^Cl1?%S-mQpCH8ojbV9F!Y|}J_e|42XVpr+u%*$ zB<0jY*(KK1#5aT8R~RkTwaAH=`!BgLirmY~%dbS$)#J|`bp+F5-Pn?H?)tCaY+d6@ zGi}c$mx_qYeqQF~JD?6su(w3zpTKvJfkUow>Uo}~0-^JJZg|wR0?l^uwykmTut4+b;t#*QT9zda z>pbQDrB8)4cEttf)+sBv3}0VwS5Urm?A_}+@!=r-4aA|)&Y_M=Q>96TNM`-lic!w7 zO?(yit~Cn@&((s=_Ae?kXkw>DqgBxTsC+0mTX>%3Tn3y#Lf3a~54K;aHid*z$4pIK zy62B;YW+308fgSQsbXWtS)~22r2q+Je_~}2DU9WW(m}ryq#M3FTKryYvMU*LxdLdd?pe&8glp5F^h!U?^XANmm6T2aYU6_4|hi%aY1jcCg~6Qw5?2Pc{H!*)gI zUC3WYxt>1fuk=D1aX4IWA|Ve(YE`wCB`GkB$U1ReiIGr@_(lUlA)SAK-n_b#oxf=j z@h|qGqu9~s_u)TWm0<+ZJs zy6v^O8pOjSrWmSvU2hE|w7;Fy_h4*)@m+3fI)tDb~2w zxn_KKR3JgcB71YhELWX-p?$HcTd2$Q1`i^Y-zg!2yS#ZB#qZ(0yE*)7mhD*{0J|;t zIF!xYd}G3+6YnRVpu%_=t#zq7??|qayPl`m5&C(G>e-kZThX|Stn7Md4}`{M_^f@z zL4UNkh_9e`6HX*kmlGO&Rvc_Bf(>{~c;I|E3Yv*UDm51>vl!@9WmsPt&(?br6G_72 z5wCS)nqG&d-wnNwr3?mS2ZA!kd}C9{pTV|eMV?5k&3OYmCeYK~T=#9qxGnQdtJdI+ z%XKKq&$k#@K}s;cEs%tfGdQRby~EH){zJqx7wTz{CZ$;FR8z#HY;6-=foVA`;W4V) z$!0dv6|^46Wczv6opvFwd0rfyU}^>CMoa2HAJ%Wu;otmaeyMU3D`|4|Sbeb65>?zz zwuKCQc|j&XqBPE40rNI*kr6mz$ZIs7q^0Td`D%m0ag{iP#o9E084%K!fKAU|A+UY! zwboR)X6fN_wl@|&YdX$B>@~9#Z^<-Z2p56-zaGR5vPxx)(cs8n9=)MFF#|Os|3W)L z2*Kh!R0Jd?%a7`b@03#aXCgj`oQPI+05RSudGSt-MEJ~8^okbRD zLOuG>2ykfPWfDu%A&BGAobEyA(WCP!s?;BrE?u5|8?PQbwNZevA8O`EuONPX3na6i zs`f&(!{xP29AsqKq0+UACkq*^wnv41UY-_2I8F;zP{EGw;qzSI9ESMc%sd|v9D#ZyhY$dgR|q+rO*A<(Mz)d+TzwXKKG! z856Et-xCyqMhjPm-;Zv%s(cv2Pm8oIV|E$-_E)_#Ja^-%^!WuU58!({ zxKf&iH2&a2xIU1RIYKR7yeO6Fc?ujSY&~?T7*&*&YZ~nCVwef8IgRrGX?=d>BLCC3 z>FK5Ef0&O|;o74@se0B?XEuTKU%>CEh1H$BZt^qrHQatn&(;PXk@ZdVVM=y(2gSNM zZ60`s*kg2%uZIu$XE*f@xKg>#fA8UL{{gbFKo;I-0k0n)eaqx2XwrAmPnr-C3yWFi z&D1K7&$S_u+qkZ=pTuYuG`D~CJPU{&fA^_LtiVDteXZjj&28FTHm7wKU!iqf7EsYr zQ&St$!*E=%#==MsWvlR%cr6Ble}t^lK$E~3iUb%L8N)Gh`nBsLCl}wpmvN#ix;R+g zwghE;X`!^Z5+b5z5L7rfF`;fDOFIpH1S`$4voDpxoCsCzT8$3wQ@c=$AupeHvtErR zX>k5v1&Obq3MR}dB;w#3*n<)xUbond3tSb8mpAY2DfSyk>z$yIgI306KotM)`xxH~ zNDc(ZfFyDh4M7T?21t*!xgg(nPOJuQJ&%cny_#9Lot3z?)xN_yReIHg%!DXz2#b=6 zN~fxza5#YT8LDl4?w?(rp5C|Q*SN+@gut_wy#HyZ=aM8|rMRO3Ts!-u&P{Yz<93wh zCuyD0ej+%Kkj2CB@UO^wLTRZ8D)N|J(A>)U+>B;tJb{l6xS8q$Z0h=#ENGEwQzkJ} z(?$0i__Yw-BYn=JC^=`9ANi2jmw`!?O71Ofv%1r@8YYB6FJT6R;G2V{)~vVp-pxYt z4@dlL0$wpOF5}=YnB$d*V+Bptr3i+tS1X?oYSm4!cbI@5g2?`o#c8m|!>_0-r_URo zp22+8&_6Y*ZlJTsEh-qBnr7!vTwGo&ATXy-=$2Vzn5yu|wZ+E@psOY&MTHg*EOpGz z?&QTYidV7Xz8=*!GSbr1)6ke7nUdi9%ndPznCDxxTn0?1r48ay7n^<;8{x#^05XW6_6CIIBD3*G&#&j53w-At1=-ptS})lOY-(M?gH~ z@=FaP*S@vpTfo;2zwJ^`e&BXn+xdPNAdz2MSUbKrZBc)fLOI8($)fhE@jE79B8(8+ z6iI!d@=0uw-JwC}Bk%tH0ftKU;uQWcPNu%n-e?ZHqp5u&0{&L2Q!Qg?>W9_7c^H7< zL14as2Q2Y1d328*ps!iw4BlIx?fEXHxE~9R=zH%w`sZ}EUf><82JUhbR|a^Tbth9+ zyFt|k2QS7GA{G3;N3+^egpenB%A0wn!D zK0Ymo-S(&(vW+CfTMNS&+KAz%LL#IAXue-__ z%!do_|1mQBp1CJ7ijSLo++>_CEO2nDj5k9+l3egzkM;2_(AFtPJ=Qfij34`ALn5XL z;CV0P$-h9dT1SPW&}U65J7ne%XKoDu#C0uAPftsVSO#l3XB^mRe-|BHT8ddB7SSQZJ-@h$<`6q6|7M+^-Xj7Yl(M`|wr6rjr zIIDb6*ft50b#lS=mBW$L*#5_+V=*5O&jH%FTUx$utOjTK%Dr!pA$tM;Zb|l7>sf#C zUU4M#Ot!-U8_DX}hAUAL14*0kGPn3&bY23iTc0!J7PjXaYGrR_=4Q6k;9>t^V_+_@ zS6kTk^te}fn($12<#E}O#N7K1eY39Ag9>e|J_Rc= zCkv)?DG|J-iCtr||707t(Ah)T8qJ?K?i{3wEdasH&EHatHZ?tly=Dmgv|yh^wYZs> z*B8+^IaoU>oa1C~<+gSnSD3wN*=m+G{i$eF)U@-0yqfV6B1i2Bq=BW#etH00%GtXG zfL;yBn>>2i38!p<@U6AU)ZFJe69RiwWRxz=A#-VYP0dF2qDI96gKLS1va-Iv!L@_6 zpVT(E%e}MOjN){aGhFdzo6<18S5LY)kl-}7EY!t!SYYR-xzrBbFELkdY*QLqxlY(S zWLRfd?{cj;m|87OOx89u)zyh<>CAHNk$L;>M=N{k$F#MrjJ~(XX?Do6b{`bn`Rw|E zwbVGbcr*I7!wq3W07=?``rWIGRqIMW!I*v%USw2H@$C~Fg}yCKEz+hYMOK}_2t4c{ zK_`!{rbkHJ6mK9vhiwtz&?I6Z+N3Gi+!yhRTVvvogNegvR(qF+CPPfj$}TOXLl@RF zZku6ykUY=!zUQQ+VB;Ks@o@SyGzj?7v|h#k3Ik@iohm7?A`7B$XQ`A94tn;?hl#J< zCn11B?qS|sTzG|ooXm3Pd#%XIDXoWqIw!~4ZERfJSSyZ0Tca@#wT(kfi&ItYnYNrw z{zpoxnp6U<432m$Rf!j+QmqZDc5bcsK*-7DH$T{Oy2H)GCRHw{RKUhVZ~YRds;)2L z;WewyZzUkQXLKg7M#HI46coR_RiFXjHvo}Jj^2(Y0}FukqWx%w#A0v~#`Bn{?~YwG zc{%9KA0KNA!d(k4)@QV@3I)b*yHhKFYI9+t2}S&A@G}2V)jKIRE_xsm4lL_EL*%^W zqKjS7>4Ody6);@QO4+pcQpxlV8+bptR)DyF>*nKomr7|lLlR_e}sqk6760}LP z=I!F`js=h7C_1AFxBp1>t~hmLU|G z(X+R6pe#vQJcec&o7UAm#KxU>|Fl77IC95D)=NF~%%fcP`Zu}&PpjbqJ8?p$PMH8d z4EmwleQS!O_j0aaa!jYPY8nG1ysHxS&K?{eU!$R*aQ?)uW*%BxS6yV{*4iTV#taX) zS@u1rWdtj-BZ^2F_7o?nMgeK;6rkcc#{IHMJbE`WZ6ofknztQy9i@2D7w6P1Fm`y# z1EL1Bn-7x&F8GlmgP zWAfhg zLw%L=tHXigd*l-nY^bTt%LhddE}9lO%3X_0?ccUic8;yr$R6^|5$GcNnuiWC-)^%6Tw47woxOr>mZ5e{SPK z#^zy8Xg=$fKqS%$F~Z3{5kQJE&G#9UWaER8R~JC>jCy7{1bkUQK%$Tjy^yAnvU4a` z5)odZ-l-N9u_&2JnN#Z~%D*BBq|ZGh!PPI4&QxvjnwwbS8r!zC6L-fYiVX_F?|-!s z_|`bcwE}adR(EzpXmWfFBGS}CgOPmUda>}2YDfT@qa2?f5lC;AP{vM9IR|;GcF2qi z8ToyajRJ0{j<1i~Bxr%7GsbZ7#B)9B(7k+$P(&?6u#aEWdpEk1s5+lCVv>SulCSRV zTa2q0#Dkd`b~dypUMd-`%^3d%)Q0}4wJz;yAwYd&Xj9MpZrxCPB zE^v!Z=WdRfxF4);xN1`n5q!==fm|yKd7)IUV`1)mA-|(xSNH!W?I( zQbl&e`hq?k%$C8i_enWhisCcRzr4a!_Jvk;COIvzPC@_~am0ndgn{kpEIvR-2Vqr@ zo;>yhGQ4gs5P)f6PMPjsqZ{kql!x(;p;v@=^i$F4@7LtYqbzS$XE5>J6XPlr=Y`<+ znpNUOXZ1^5r*=A3S=;MCq;#c6r&xfPV~^4N3eemeV{maQ8QI$OY4 z88EG7PIe;-eVZmtos#V|QDN&8+b+|gC|RBRasTdw0Xl%t*2YA$7HXH-*Crd3O)1xV z4~XM(``PsD?*{6YPWMyZbD9nI27bvEupiLqwE1i{833vFp0UbIElLwNU|P>^-0a>_ z6KLMGh4Qv`@*-9hmE5kP=FL^q?b(O}yQ;e*abu>P8Vg|-z9&1myG_01dhOK?pn`8q za$(g8cY+0EF(70Skdic!)QSjp(XCB2C71qSbU!Uc++oHQ$3HdNVPPGu#H+Yo-@ee- z_wOQ{+2*Yayw}z#)zqTL$7k-3cE!w7MGa!QLrBRE8K+l13SSFq_nOGb~*wo`;4G1ff!lAj{l-oB-UV%>G-E zoU;Y6M`1P3Lv!7lnoK4ONSzi)uIErPw37RGCT;o~90+fAN6ctlQ>htkv{kqtOEG>c z_fV{#3hMO|yYAB-P@5vk8x)2daI@iM7*yrmZ)3+c3! zdjyg@#^#i}R9PX7>E{-zeb~%tz+||$Yw4EkY`>mZ@yVNxs= z%KI1|MWV?E~z%(U%q>ZW!KMia+O*D%#E*xfLwD}b-)-=3lsL+>*`@?d&L&(1ET6B$i zF=nl25!uf#HgdMBzP26J>fUFouRXB_e?_DJv+-}=i=OKVn2YDfkKLw6PTnGl{d>D8 z?my`lu<>cuKYa>;88a<@=N-Hh!9efE2r+mzw335t&aYwuQfbV0HB)9Hla^m0bi#&5 z_JyfN2Q26wlFE#KHV)xZ=)X*TIG-xuQf5$$ZfXnU9W#UhOXl6 zZshjG?7a1y*@n=8{SQF7H!Uv#=Td78NeFZ0k1tnjC*4 zz=5z<_xo>-e8zDu$;LKRmn-L7^8`>r_F5!kTcmHx%6ro zj%oF?13^ULk3Y6@iO?5tDr(qkaoqV!IU1_(2Y@K|`1r=;WD!~aY|}LP3H~03%kEwz&aC( zz0uAe&G3mY37#sfI|a#)ddWq1nx>p8&LV~wrmsCS-OOkXp?6aW!A*6K^3yLMFonZ} z-OZ`$r}a5-4^1{crB!*>XvJnWqG#j0Zq+scJctyc)*(sxv0h~>Of@?wFLeTr3v;%Z zb7Xw{?bFO;`iR`U1I-&aEb?L#5C7_d>YxCXRbCpv<9xEMewoxkrWY3RrxkPlkD{|| zXlvbq@DA>Ba0}Fs;_eiuIK|!F-Jzir*WwVQlmf-wT>{13-Q8V#bMq7O;oWPknR&*p z+O$ZXYR`|d-6q?+#W>+GVT1rrE(Xga0T&wZ6viAgqn96)*EPyTbqkj)3=*s`vVC%f z^rkLoADNrh8!;beV8ya=h|f0>=wXO5w`>#36V?;-p8`o4ZqQuNzsb=+qZ@zZzm($KhM%24O zk6Fx)W9l>LlrN_QPG z9(6HpEp#!d6a5QQycoj7?k^w$AgBQ2djuL?_-YQg0TOULbS^#;qzfL*0y@9SPg1~r zaz|ONicn1x1}TZ1XHN?WFS_pV1(n1^*r^`J_HFbs6%|>jl^YGwyYlnd}7g8|U`rb@9&VBfSz zZ}rtWt8ZKf51+l8;k=WBF(*NeQwhUqYDq`z~(cLK3fFwT$8%BQ7437hGXr zNZaiyEzfZw?Hk?BdG2W5UmO~CLMIKL;mYHgr1yxOk=b?vseM9_f*%{8I4l5N^KX-f zqB=kd0t}IW6l}V%(Ac@*a#SJ6&8_d=*OuSJFvRnDrQtH-k5}K|{7DeNAdgw+*+P}& znR;|z33G8J2k=t6?U%fpP5;_geV!_9={iXx<6vPa=H%r2M_hrC}f7m)_JbXK%kBw z2tU1-Dzlf@QVY5~*S#9zBEiv$kUUc#CFbw}|7NxZRbfscHXs|VH6^dj(5Abw(RSRe zRU7YMlI}`CaRW-X=%Pv=O4pM|w~})4@w}*!P!7Qm>m$8xPK7<3l27X57Z-=qPTQp9 zt4jwx$4Y<5pDE*%_mi)rkV?g=%1iqWE6^DQY~ zI6*f+b2)&Qb!wPia#bA=1g22>lYLmFS-!R0%GFg_tun>M#dhj~jlB`8f&TW%!$Zy; z-~xd6fN0BhHYquI872zw9e8<3zyz&-pIO-~)=TSAp+y4l5YICM9die9AzflnEQo@C zEyH8T4o0d41_DLu<}|KGc~Xry^lYC^EIW#Jusy`EhDXb1(3Xk}4kd>NMcpOZ-t%Yi zr1oqk9B&2Uil(OY-JU9Dtb|KLae<*^aw=jY)8}9{jZ1X#0dFUmmE}@c?A_?_`mQO^ znbzWKqA~IzNcc#LxEK!r{~j2>m>|=$yE$-u^C5yHFH^kn~;l|-pU#~VVgv~v;&#%Ax@SqNMX)5+tJqYz@6v6jp z!b~lt`VQ2+@iQltO`Ue<&3cS; zouB3|(w!G(J+KzuAgKWBEB~+k6J2?qkJiMgPI`+5 zrSf#cuO=kGJq%+{T;-%$+9|!#KX35|M#O=8r7uv`+Ex{A`<%;P!kh1n8q{a1H0?j% zA&T9f1Ve3c$jq%6iF}!J_k^=P3 zk?0;1E6e#w8(n5CH=DQAEG%uJ3f&jzQ8WMbip9+LtRn#POS?4oBm^4`)m17x83!l* z%RA?P+~#*q{;YmPL7*jZE_!sn8r$L6T<;>&W4`Qtvv)2V=U1EpI9Gj!X6UxgXe?l) zo?LSQqP96%RZT*5YB)9ho7V2K^`oO17*oregOC12Kl>TjU6A3!h7v1WJqW4s$$v9& z$QI}G`$sVh*`ED|8Wkrs9v3)hylJ!#sGr!OqUe$1H|{w)pk9Xb05$e&8BRAxE7E3zqZhYGkvzRR#&#g*QTfQR%|DmjAM1-XF8y1+x$qn@R zpX^&Vv`{GcTQ45DDyuVXD9vJtb+oF?O*@{M6$wXw&(DCLolqd5vI7=15L9C~Q8YGf zf^87m5h;SUgN)uABi^C`es@SjUm3J2onZ4(!2YrQ34q%%` zoib6t-t3h~PFp@I%wm6OsZ^6oq9p)$)z#0+lFoB6 z@yH(rZmH>hU$L_vd^noX;LGhh%CdIgx2bBfEYbXkc`k}RY6PhCtzg=p;c2zjYYPgT zI(zvE@rC#8r2R20Rw?{l^HFrFcG}LX(m}S7qSEnHfeV1_wa;%SzlZ9O+wekh8)AmJ zM$3^Of!U@1@9sKXh-qLdPY-Z*=RB3(e+X9aHb$i3{4>3>@_sQe79gQ1Kg5nCia|%4 zpH!M%d3&qVnq`KAE|}O%6YIQ<(fcw0m6eHrWG{vTwZGNXH3~uQm%cT ztP+B_iV1xDkok6DlWF2pHDH=UR@cPrSa@~+XGj&bD#?&PMQHnVljFjRxRAl9%r3BP zX+sq7Hdql>3r>c9V1O5hH7AsXq6{n?^$v(2ZaFqsH2a!#(d?TWF+DFMvS<)dTR}+H zzIAv|N;#qfkS+LL(md&%WlX;+WU#<4xM33*P-+Md)&$ANA%a}MJNFPw`4JZA+@UKO zMXII(dEH{C$)>!KXY=6kd9|jGO=#Wi49f|@MXdn86mcet#1-p$WQtXpnl=*fk8n*< zQJpz``gq9%-`?Zqwr3>9yDblKYkoCvLW%+n0o*yx#y4Df8RG$!k+!tmV?z=hhXhyh zFY!Ifq?TE9b8k}ujvne2$(SZKlEmHn-`f~9nD%Blf=0i?CKuGvOXwY2r&7z(Ln|p{ z55ZSTIixOeKE;~Rhe^xfAHMWa2n|jDx`_=<4>Bq25uV`Z5*}1S`3M^|1uwu;M*n>W zTBIQxa^ z=2uk}%_#r?8fa3UN0CPxcG0j~sjW+g>oq&VOx~UKwL|sZTTJ-V*LmSnp;LQLT)vg; zqM}CvyB7YS&~JuF-KT6~^g4Q+WXg)ZeTF$1RXnC^6gZbX66bI=Zz67PO4P`0YkAQ2 zhc3M+fM#J;JpA7gxN}w{b=yOOIr(fgM)ODCa$r9G=WPFqsr=uB<)gX<%V-b=TnZ{a zHAFCE^#7wkYZHDg$Z2J{;LV{V&6X*Je-Dr`F|Qp}5>V@T8chv^j+8G>eDeK(B)Zlq zSqSe=`iaY{Q;Nt2=cb3L2y(|h#;x^Q}QHp*Ltk3EdYu#FODZ^W) z-j`d?Op9E}@DNnGS$_rroLa+zkgkl2c|0Ng-Xn6i#sXa4)`;NY+FySNQt*Hti4!{I zc6Nq^W5j^jCT>Mq>JN6TJ(xhHuG^)@m0ccAoY(IfH~ZshntO`#`-G4L_&Zd@z}rII zU{&o=Tw~*CSi=mcnwhGjOzB^hAbu4Ri3Z>;JDGs8^*%DxXXmvL_R8J_GAg z>;MqUs4oL_4&vefrk!szn8_v(+2{d27XJC;wh4v`biJT=@Icf%U|G8s;p)2f^GQD( zN%qTg9v3n?+jjy0(oJ5l{gY3Qp48b%(v|nSh=9=g zY`9`$r5!ozlT$MTmxw-s7^cmR0a*KR2y>XNTH zN+VSwH{%o6fe5ZBxzK3rvn%$PLK8funM#ZfOf=53bq!iLk7ehnK3Q)qORJ!bYaAW- zOVhv*NsINdu|eVh+tkxy5^Ui@DZual12@AO}LfXFF<@{^S_} zd*Z$W`W`EN*?s~LcYJ!O!tcjYPdvON3Vq~mcJOcro!(5>@e@j4zYAKvV)Y_`V0cxe zGga`V(~~e@K+}T?=LshHbHS_&eA&bz@iFx~^TA;5+-nq%BGTH=d&N(16tsJW?3Nt= zcfekd4Z(Q)q)T=N$johSVir!NB!eHz4h5(J5Gy$vaD7H5|60E+7f=J;yY_80THPxU zETH~J4MOLV-!g~Ag)TmTLk)Be8AE@=A12cup>S;yrr*Y9^!H+5DOD{}Q!o_e_(iE9 zpP>KST?@Im1TYM`z7u?Z&C|C$8IJ{AcdC95fn`)_q5kHf2HxBClbzT6B4_q1u-%gL zSV_wd?Ar`YUWFh0ZTPA*qHi#B?-gVaAGI2JG2VFVrp(%XqtGEV3v=5ip!h{{ z4hnm$B^_01o%u3TJe=52xm93mMG`Wt&Ym8J0?}7uS`pZQgY55LIx*w&YJA&-aNOYW zBc&zdPfAt&KA_b%YI7-K%P2rutVkiIG7}~LM{KH!tQ8Ct(YzXxJ$Tn2{KZz1!3G(x zFdMNiJ{q|}sbc=SJ#V4&2Raoe=`wUL@ZkL&_yeYnWl%j0OfvSR_SnZw4YjFhZ)#s4 zOX4K&WfcvWw|{v#*2dA|EgcJk*88_mg*@zJoMwy6jrV>LW%V|w4;`%I5bmdQKm|tr z1OMKx#MOI`0s(3DMDRu-p+H6mxEtxxEkIOoRaecUpsf1@#h>bx1@_|eu%YX=YV4T5QKpx=C!nhE{5Kh&%_tCh0bB2cX8K}C>Ar@Wa&!=>FoMg#3~qfeezUi5wxY9}}gR;ur=CEwuWahGd_)f^iM9H~w$j;zkR!7+p9dF{u-T z2LLoTxXn68;7|YyD8d3l7~QxDh(7_w8cu&i3@kgd;=df{V51!AT3d7YRbuh+vvXkM zeG5s=!YKuQ9#oF(z>!(TXzPfg{zCmkXgh^Rl%7xLE<*gEez&F48BBQ;F%mLYHlWvr z3oamedah<*g2t^rS?KF(98F0nb@(}qn)f(Jv1GQ}pIvZe7wZT8RE)-em>-)gHrUq= z0Bl8P38v;+AHCk4u(TdRDDzsLo9V zZNkEwoQlbM7HQc8&6>q}UFp0v;b2ZsY-}=y@OLPCi})N1&lrmolfF*2=a6gWx1T11 zYIAjl^!t1$kc=MAwwHOjJyU2v$?j~F>hsiR=lSGJBtlI~JMu0cW2<-pU3-b(n;K}Y zS(nU*V?@$}BWPOE;z!=Wn=c@mFd!H&cHUm1&AY%z4fzNW@Y7^vQLpcQw7sq7@52f6 zT5N25Pe>o}upYvmr7TZyjnZ@Izf-y(JSYVDVPo|P3a^gnlCz}I zll5}@YO|%Aulrec!bD(ZzZxN#iU+zKSXVfP| z6fNp_pWIOL10;w5$31+K=~QD1m+u<5$oR#vb-CC#=qh7zb6uNC^#&VA%c$m7(k$0R zJIK_obgHW!KqSf4^F-*xhRBzFrrx7Q`mb?#+=QOVr=|xLX!I1Wgv>CPpfq47Lq>_Q}0Zq;B#95vF?y(LW9M+?00`F`3gZq;OmpK5QZ=Q&wvs*%t#C_1U zq5>Nrsf=)VhX914G80EB!cXa5EqdkvTOJ#;Ms~Fi6AM#y6?1FN3>{D8{MLj^6R79& z+{S&4f|xo}3=haz}f%ti>vc26NbJ%(lx`|uj$(}Lj7KnQB*P5~oH z>M-ynEbkXzKyhwksp-zhOnInTjlCm|_ih^}=PGY8u_ng%q!n${Qa7CsyrI`PoV{5o z1(9`aw!!fmPDr;EoXd_~@be*m5g$Dxf`BOx3_uk|sHB};;^~w5qUlX(Uwp43 zK@qF4cdu_?49>$v`IT#aCF!@cAN}-u;bh>cll44q_de#tTeshHwU;XkDQa%7jAhE=U+elr<{t2`M0|C`wmR9fxGfF{16$-U)G zti{QYn5Jf(rv8g$zn-B^E4HhU-${RG91<<*>kWt-}m7a_tpR5R~*P@yQD**;tE z@rph(uZy}X?eWTH#g0MHqzydDaxex@Ac(2Lqu{mI5}yvy*re*RlB=^%Rk4Ng6x?rq(_Jk6 zSus_s*u7PsU7tl@WyZ4PtOi$t346_|zFGPxI2MI~$C4s!LEZQ)d}Wp2M-$h0fc^$& z6HggB!oSQ3fZ=Dj$L_kOW&v+ao`GPygU=OHOFC5?h3xAR3}_?2h{MGP`byCaBL;kr zD*IO^53)|Znk9rGQ&eFF<*O#BS3o81&jK?{VTgib!(UjkqdFHqxs~O60N`BlbuxR> zj4&LR!V|^&ei_r;bNYo&kb0ama;jCIXs7^zC3pl=)oEB0cVU_DWA8yiD_nMSD**A5 z95hb<1Ywfu=NA@WWF+T*w4sdC>p$t~>47V+^yuC$>L7Rf?ZUGh~fK(Z} zNbw;A;5p{HCQl`PqmSnmtO#fRb&djT@UBXBUiA(g!~HnG9AO`uNPebn*Yc_J9Pjqb zFZLHIUJ_zFF3OKFWOx~H_7zXxux8se_7+Qfkp`)gC6v_g^MP)8ZFf)Y z2L7-5+#hq9H@gReBN#z37ufgP2Tk6FHWQDkQh80Q{_52N!m)W}^>6knDH0)XEJS4w zsgflimQfCW3T))Fx{Ee-M8sX1Mf$`tY z7so=ETp&}RWNG5SRUB*8l`!){%g!p=Zb(Y5Z{lm5c&G z(ll+$-knHPx(5*$|0u2G;Z+|E8bB9!CV->%1-LtXeRTRoEdGwYP3Q3~8++INvzkaeBmGLwW$3E(|xLI54B4F0VP&#F< zoX0$N2hnJ>wQ0i7qxJBx+$gP{1dPlFEQ$ocJ)Le}Kg|w1XckVb?tx8ZJKx)za}hc+ zacLV^rsg#*ZIRhfQ`SNasAjJ8uB3d$sOcHeP`p)+FbfK#<(1uP7KE1VJX>8<;W_R) zF3qQ6ppnYEEKkby<_=mmue1T=Ac_OR(1f2-0HITMBGsLoygeYs_gcOFC>~q@XZ~SQt?Py7>t#P$pSTW^KJj{U44*C>a@T~ z@}uEP$L(r;C#^T1z_=yEim@o`a6Lv$3c+3zATXI>|hb-VZj;; zC|i*2z5k(X+2+^!mu|QFS531zH<;?u55!hg_FLLe zhtAly>9BksB~EH!*f4~A~wx9Y6rD6IR>_+Imm_He|V>4RR3jSvF(s!GQhe|JwOL(0qG2YHHzO!;?A~owWjv&)MuD&%ovnrvuule* z)+Z7S8bWFiJKruWE>Yp=!DG3Zp1cT)FJkZ%g55 zt!_}=oW3V1rk3%1hEp(2w`n$CND)Qlzii7V1&Nj8$@KE-O(roj6-2r98+{&)IiF^z z-*Mhl`RQp@c92L4sA8O@2r;{U0$ihsVUP4*+2WyVHB7|RNQJe@aV1*(+9x1Mc^`z9~ z21sF+J1D(l(;Dm^{nwm>WpO5E1PdMSZ(_o61% zEZR0AL=ebS@+&ldUqWROp^KpsUkpj$l z3itoV@h=n4Dd>f`IC=z3XdCM4>ehR`ULS;nE-oy*8Q2U(UJ^bQSQY%JqjzN45Q$7R zrkW@~lUC_fQ$l6RGi08HtrAg5TFI+xRmNSzSjbBp{d7yVky`;Lq%SR636E4L-%#gx z@He~Akis3YiJo4AgZek$n`+8)7pJQlo7>wf8%i7hs5q#UV9p$Fa%>$AX4-iAx+h^T zWCJaTf;!xX3so8}-#{=!C}XW6K%b^qQ%-uBUJtp9s_f#DKgStH+S=Ng zIiJ4RltOFk8)kSqY`wtLE=?_dkCoWyG$?@f8$kd4qCx8yeuKTDI^8(#^x_i1X5thr z<%$X>&-CWq6y8});yr}|f|IGnM@0n%FL)lID8j*wAko=9_NR^^hvrN>JjxL0?`-`C zOyMuInqI#UdRRO-Sbw-!TwEYs13L!Wo%X5S>KkU73eP@EYxHaSh|vuFW?c|qKf2{Z zt-@gAaGz}dd~n{g^h8_HK%>mZfBBux1z~8_>FNKof*U4wdOMf8k@3(H z_uF%S4l8;=2iy2{UiJ^rPc33sN!X{&<+-`V%>^jW#7h*}r;ax6>mK2NFKU=@wnz&B zxQ$>NQ50jLAK3^+l9RMr_o#owaR5b2Mk1uDy4uXq1kA-NxTI;oFNk>?e)AL&VOGC; zu{aP3^8k(gmBOVlq}RzQF$Dt%qL&#XYHH5d&<`~YRkNBJdQPG86;166>?ruPunqDH zQ(cE+bHyFs)Hl|f%FUGmMn3azv46e9MGxcnSUGo8Hu*#fH8RRE z5DBG#WB!&!Tw85#qs6lCSY0cE3M>~BtsH!A z>Cx2eXj>fcXJruw%pv&z@U<+MdDc$PWsID|WwPDJqFwlk0^o3_P$F(A4a3uf#$^qU zJc#T-sRyM7upWT`+-HmTbRR_8%C4#-Qx{MY)BId9TtX!_n$b2o`qLo6^a+wT&E!pK zd?y?3HXJjRe5IFjG z4Q_4^k2>xcO}KZX)Nr7Xp~ZpRYyK#K(_4vzggTM-RM0?80~3JEe@SR%fsTOiNcZhd z?Lw^sex9kjwNbI$OGlYkZNACes^~uSs2;ul*Wgv5Xk%{I)Bk3s$;H1otv`A_NFhH> z>$~2Fe7i#XUq_7i9vRihk7(gPb5)FK6jBpnGAjEegpedb7i?p?E7n|Qvx0QSB&&Exoc=F_K4(u7Dkq5V{fgBqDxG#>D9!?r^ zPhLKk>8EJ9=T)_|v@()lMtx{PSzkNGy#$2=<3mz|JMNDmU|_Xf_w>h%ZvaqNN=(aA;^!38OeM1@VV0e^`|ZiVXeDlZ?@$7oH)tD32F12 zJ%7Wo8^*bb9RmpLu}`xp`+**Ke|Vs))AALG#w}Q{pxMBQiKUQyrcPpFBz3j4SBi^~ zpf1uC2GyU#%TlM(T%BLEMQKf z$dj-X@si_pxg+-htW5jgfU3VttGU0Gl~MMNt>UVdwh=WFB)Hr!8aQXJ{bzEgW6V-_ zTTJ8IX=>uYOom_M&FsS0!ABh{Nx;zLXs{MlaJ{|eq2nk>%y-&kQT55kWC<+9wg5F) zv(M9BUMx=8P7zhKe2V_pO8&r~9k-47@K;F$oEmozJ}tAl0V=C2DfkW)2DVK&5cbiZ z=)#uUEcpt~c+2e;u-TFS6jBU4Xm6fG!5Xf97$pw$xYwx^Y1$4vp8DJClmo}=#GE?p z$oq`c2%QCJK#}#clq^?GNW)_087?9u)4hM|L)mk2g0{|WBtz%^AAfQ2XLp{GUxr{Q zHPoXc8QYlT}24)Ed53k%WjhRkE(iJr8IN{%{peCJgvjyu9@;*4iSU z>qu~nfd@MCxE*1mFl!W_Ki_X*+i7WOrW^-c{=FmMKsBrS@8JVAYvkNV?p{Vssi%r? zvxSmE^P-Ezl;xl|)R;KYH9cAc=PD%+t$u#O_h#HqKHTCTaNU_z4ACkxuLNgB0yPv<{jvDX{EyTj@v zl)#L_hj_=Ci*$Iu&p|nEaYE>X6?{EPwf42hO)6KH1hjE)&)P8AY}AAdxRd@9bFlcp zKoHEOY1-b-j!epnK|aqsQUl36P3;x5^ILk$mp?bR6Vq&7q+T*(_^D5nilM9?{%dbp(bmC_Y1C)d>Kz_D6SmVK)Tz7iW&I7eJ)`b^ zfw`rt%?&*?mhv-kHrVQx>HkSK(i$?w(TF~tR;!ycb^np-g@20!^E**5_RDx~s<(X& z&Q3#!hAK)zI-ap;<}tvM;&AMW{_g5u#~E{+Opmth2K_lX3Om)MnC&M?7dj)Nm&Jjm zrAJ9zz!J7yE1-Bw%_<1;@Di-Oq^)8upMQBd*}ko}X2}RlWq!BRQQ7XL@`=!Gv~NSc z38whtN^_|UVB_+rBh*vsrHD{9cOFw@A!C_abD*@RH22Djc^MC+rLYd0>v{X}?;D>g z_8bGFD6R@>lxXS7l9UueKfzK3)xmYpY60zlv<@@B~!jSgo?Lq=D*-*v8?wukJaw(8K3$f1xi$ z1L7c#{Qk?$%b>IpRu&eP=f!xRp2>?tC6V+}R^))T*Xv5kauIgy>em5z?W&Rh4^Q69 z+mmk!?8OH%Uk2%hbomXGsA;_}pMLnVd2ed$J?H)w=^Q^CI+C%gPTDEM7qvdo5Wcz9 z;vbJ?#>qyhT_FID-vaMzA2&CbLL_UOX{B$jOp$(ej5ZohblQ-UgGElBo=vs?`>G~q zC*v2~#~Ql236$)(HaTk#pET6 zNs8Dg^uLV%E~G|HwaoD^j;>2)k21b`M){Imk46}EwrC%!WRH}U&AFm#NUf39vIgc> z6T2UO#;(WBK%aPyf(78Wl*}w@?pve+=MG4~Sd;RKLWG@|9x=8uZQf_wUwgCUH=*}h zTvijIH$I}e@)k9yZoghWamGaAcdl~~q@HhkMQ+|^QxxC zJ3~P2_R;f5K*A8XZBZa8M64H$#+4MuaG4DB{nvyze^v@}ld7!EYd#}Pz%@W&YpYs1 zkuJy_@KK4WVZEUD67HV z@W6E8>7!SLzpHlQ_-dn$;}ok?lju#|%i|Q4Y5hZ?Z*|=mCgU3}|0@cHGz1ihXdJsv zMLKNmzM*;;&e?RnV+m+amEU;q^7Z|ZP(XGx?>ghh>fyXSa+49eu_a2MO!A^pR^Y&7 zRTGvw(tgx~W<2okFee+|C80J1{t4g`adaKr4p(j?p#yQhRWcE#5+SUz-0ATf%ujw? zod`;aWf?>Mwld1uGPB*BwQr^pF7+Cwpyuj9j)lUx(bMK{(eXCSvifH4s^o1(3VD(G zOTLzap*NMSpnlP-Dr1Uf0(R35qr3Ss%I|>Zoft*GiqTla!()5PD{JLb(Zq&aDFDt0 zfxn=Gh5kDsNRv&ZEef3p*Bm|2(%BQtr3a(%uE9S43LsP8OIbZ3Z|IR~&|o3nA%a>@ z8=*|!=I#eiU_^o36IPZvQmbWe8lc zX_CsRTlKwv=#E)Va?lz7695{``+&r3sWI+=nWgJ6T%5aWOQ-i-a_?hk@Z<`1TgxzS zWWwO)3GupF>sGx{^AbH%#{gbnSSGK+u&iS44u{89gLwznPG={B$!1Cdad62a4saJ z-JB)W3cqyy)bNpxTJqZr*`1z}kF#U?g9<4-#JpH4o*6`L!Vd?|6-lcy3S3iDvvyT! z@P^-C^{KsVCdj8ACfl|b&otC;U$YV6Jn6oysYk7FJ@nvOzHm&*0Ju8>keSE7LR`kZZs8QLl0C<^uhjxF!@D`O6(tU_2T$6BLsPRB zg6mMIVtHxngqvB}T*F#vtIabxR1KQ?LdM?k!rpLjC7&B_6m8Ir&yMWv%Za>ixo!39 zu~6c5A>Qc3m$KXs0iqZSJQuHhebL>FTl@hs{_L5XS3P*qD68NpeUh^5L?>hT^K4uhPXA5d#$|F89(p`* z3j^VKWK~Ls9q*rNvcu0_<1^(3(B3wDA%dN0Jge3hzxlem=`QUOvm3ite+|&k+L^H4mvnQEcso$!a6zpwme7v04+x}Fn95tN(a@!EyZS*_Ms*h%1 zNx#-xL$*(0R7!NEGL~E&*J5&1u5fGRVsy$JNVhY@o2#I;DobxNKHvYn^rdindCew; zLW>p^CV?6-N04|RXfeOZe^wv7`GI(fHDSP=slK-L+0?{~ef(Iz*fGkcyZC-_wMnyt z;qGI7EL6PYZ1M^eu2tm)`}ft~pXs06h-LZNm%dXYL_XX|Rsy5)4wVug|4hN0dUBAl z`kIy#k1&3ig`a);VYY|v#)37M5VbsOf!Tz%uTIp+*q9TKvTy&TtnJfOprH48J2wr^ zPal5M8+`|XnzB@-pFpU(4p8nH*9b_HU6He0@AW=!ph=jt_roC zikx^TRb*xURlXC-n~T!%id{}_*{{E@`0ro1)1kZI%Ib94M(sqN?oD2GV_RF4ry~Qs z(qDTfp^8I^Fz0fwiHxct)LYcE+6+ZHaQ1KtuT-&%vZYQS}9o!BiUmP~?nqHlD)qT52UiQFlegbWd zehm9JVX$>`Lo7AR8PR$lSI=yyYIgWWa7)W|byc-)QJDi%`{$Xfr`NnwwM4gG0kNff z%UJ{kW&Npa&ArWPuSt-xq6Bsh*dOTR2U^uP@qw+mZ%bH{0$X%~j@ltgW8ieY_5)2_?j@$L)% z+wh(}%*pXs>>tU?|Gar=ERZO{f|TWRfKR&-Q4-0DcfR4 zX6mk>s%WpUt!8xK%lT)8o9!+yR^LsMw@x0B4H?bnEM=vorL%@kEFI&juwNZMwsUIf z?CAHF`^-m09&(aFj%S;PhYdr4|1LL=pMTByi)?9{uT`AI|I-JY89Y<{`UnWWtY02h z`@Jr~9(;K3{0B{_2ponUn$8*q-&(}M!p$FjTKrrl&Thy2Rx+&?9jU3>M%Aoa225_ky0M1z(%=zsiXDKNZ$~x zZS=~@(S=eun(2UN>a2PHD4y+&Q)%o_=guTSly2E68MoKRuXLBCp2C|R3W>Y2>698N zpCcpSi9Wqg+J@s8$)!Ne%=x2OV8@k0^{ZuerITIX*=kEpH*$(E=`bQr0M(nERsqqwD$`fNIj+Ico!FJ zA0i!B=(Qp}L`KD(5r3p9Prf`K-=uu2uOao${yW*5UGw{P3bpq40*xSEoAyDKapa zxNDx@#b9nLu~&ynkSM*){_}*IP-OY)8$&tgr5mYlGPr@>=3w>(s;(4PT3%k8g&6HT z{c^&detv!g(kl2}gUGiefuvN&J*fJuik`UT$DJJY?NFftb^50@$WHg__U{EtHT%fp z0V3grKdV(ex)5x=yhD-0XdA8A^{+`~;_Jpg|D;R9P z$UpS(XDwRl+Szi~klW5GQO%1F{&8 zCzaTb-Lm*MblU>=@%9i2Tnp%vtYQ4Gn)nYw?eCMq%MDux8x6JBTU@R@2%6h={|y9% z&)9;0=;`&}+`g{#oSAP;{!3l3sTWC@*ibTwjcBR6LP3xD^xWWfE_DYkKI$P^bQJHC zp*p(R7((GKjw1&T5e9IuMf5b|_+FC6Bm~W^l{mR)+*>DEw4igaB%Zu>x>EtP*CxI1 z#5$}#l9yinFbvb4z-(5+x#amO5Z|oOHv*D+#-ODZ8V^pRlvprl3B%7rO7ebV;ayM> zIy7V2t}d5PFpwqAebkm#H3W$2t~=RcI2(*vU0X&oQ==!B$>_6P{x>}r{W^c=3;yEb z!7qjTu7a)2Lm~WBslKEtf)$=N>ut0?j4QWGB$YSY{8rao>Wi7xI2!QIaKYVcRK!Ly z>LN~jl5Th5v2zzE@1NI8`s;FXu!Z(;G$pg~tyTj6Xo%NcdC$o^GV1S=Q-%G& zZWn8B!I+J7|MfdCb!&P1!kp(?Z^{N(!dBS#n z@5Cg*m+ZFJw`5~lgEviioDHUl8yUd)N$tO`zjHhv`w@5oY~(Aj)(KMw!&iZhhQM~z zxsJaU$(XCvVPsVz)k;dF!DFn=h3{9zSdP-SXA$<^l`QZ$a?bYcyb-DrA-f)%0~s%K znJDZz-S{WECOJ>#Qh>TWmA6u>dJ5uJcch=8(q2|*E+w+D(3o2m^6j5zRNRhr3(+=1 zSuPR!tTQ?1a96wI+&u>^SK^&=ks~b?b*#m;=6C3{(Sqk0-nyk={D^tZmqMBTXwJgB z=SKrdAN?&_GmwjV1=lXWnnZ|go&L&_9(`HqE)4j&QcqtnFc#+be3N$O|GeZF7R{>j zs+y#y7u-&>u74N$_4?t{(KPAK6sncfr%(Te?Y^FV{^TLzC`1^2E&H_=?P=Vea_-Nj z^{qft(OHbK|BEd_@E?x%du-KQ;Q_ms-t~s1F98mUC9J($B@f*{d{3+B?U9~&mj3li zZ2mg~t>>ZKbua-@O&djEU70v5( z6Ae9@9@M-mh~!JQ}25JO5293}!q7+n=1ov7kS!;M`|>ccvXK4t9$=_@Sg_?3?xrlR+nAG5Z*EW30X zU96=-WN}0{P9e%PvYkVu`||^f#-1owOss{#9bV_V@U^$h^yS{Z!EmNBRunQ zU5dy9xOMR6Yn8Zs>E*`iDp5RcSW>N&xUUlc-bQoAOr* z<%@ItGLY7*;3#I9M2Y~)hpfh2nC>~keM5=t;mALw0}p*Nwy#I^vxu-E*kROP?Na{> z>L;ynafqz3(#4sEA}(6>Xg>RHcO9pfJH4n+I!}o}m`@1;3nu;%f17B8?iOu9i zE8W{@@$%&2G@~rwyC+&9$p+}lC@end@^5o-@9b)@PyJ?4G9k}h(>3AUxlqSo_GjGkqZN^XbeNthu&>CT<2e9s=rel-%6{eMIhiWYo6|aUbk8V0dDDv4Z5F} zS%oPTfZH(y6`C_=5^PJ`fZH)6HxNbH+=FGsaUv=Bvpiv2poiIN?<_49Rv)sc%7$B{ z<})e_fMc}o#07DQWD6_reGw-*5H57KyfISGvnu@zm}!YT zo6MzVk~^H+_*HP%GiVR=R0{h9Bq8Qpn{veO;>1VyT|gJqoHuZ5Ha}Nf*DN=FbLSbA zvr-ZM;$j1DpoBRTC--u_a2#!i^aYdcA^AJ6GhI1$3WXCDeoNTqQ|`k*&mM3)OU@ zIdoz-VNDJXUv^Updy~JEbCyk#@rqA^}Bi(uYKXMx=Ggy{E3d>-&j@43j z1Ko)s2Z&oM(`lV7(SCYkO^~>qqqHNe$7e~-@sIr1pr6R@@9ga7QBDdR)H*Lh!YnOU zPqtbV=3u2=q1)&I8!ys$6O}{+z_cj1jlhDvU$z>1y%hD_%m9 z4+GLazOQl&(^Q?r`QvU+YHr7rju}us>DBa+yh|02iz1w z0jJ@NHKB5kW9j*b?ef;{@>bNMCzD(~M^hUJx^9m;57!wlC9_!8Kl8c-q#fADU;>?=tg z&P?6vSSgK2*i$iA*h7BF_A8|oMt7_SG4ysqO8n?3xdQxye;oLG4rqh4;4E-Zjpfx# QNuMS}ghT~52c{JM9}sy$_5c6? diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx index e0b8eee..3531ae7 100644 --- a/src/components/home/HomeHeader.tsx +++ b/src/components/home/HomeHeader.tsx @@ -33,16 +33,27 @@ const musicModeOptions: Array<{ }> = [ { accent: colors.accent.blue, - label: 'Everyday', + label: '평소 취향 중심 추천', value: 'everyday', }, { accent: colors.accent.lime, - label: 'Travel', + label: '여행지에 맞는 음악', value: 'travel', }, ]; +export function HomeNavigationBar() { + return ( + + + + Soundlog + + + ); +} + export function HomeHeader({ currentPlace, isLocationLoading = false, @@ -57,31 +68,7 @@ export function HomeHeader({ return ( - - - - - - Music Mode - - - {recommendationMode === 'travel' ? '여행지에 맞는 음악' : '평소 취향 중심 추천'} - - - - - - - - - + {musicModeOptions.map((mode) => { const selected = recommendationMode === mode.value; @@ -99,8 +86,10 @@ export function HomeHeader({ }} > {mode.label} From 68f67c6133351318e48067c0cb2399a954915277 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 01:34:24 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20=ED=99=88=20=EC=97=AC=ED=96=89=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=A0=9C=EC=95=88=20=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 36232a3..ff1f0b4 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import { router } from 'expo-router'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect } from 'react'; import { ScrollView } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -22,7 +22,6 @@ import { isMoodRecommendationFilter, } from '@/components/home/MoodRecommendationSection'; import { MusicLogSection } from '@/components/home/MusicLogSection'; -import { TravelModeSuggestionSheet } from '@/components/home/TravelModeSuggestionSheet'; import { TravelSessionCard } from '@/components/home/TravelSessionCard'; import { Screen } from '@/components/Screen'; import { getHomeContentBottomPadding } from '@/constants/layout'; @@ -41,8 +40,6 @@ import { createRecommendationEventContext } from '@/utils/recommendationEventCon function HomeContent() { const insets = useSafeAreaInsets(); - const [dismissedSuggestionPlaceId, setDismissedSuggestionPlaceId] = - useState(); const { selectedMoodFilter, selectedTopFilter, @@ -218,12 +215,6 @@ function HomeContent() { profile.locationRecommendationEnabled, ]); - const shouldShowTravelModeSuggestion = - Boolean(currentPlace) && - profile.locationRecommendationEnabled && - recommendationMode === 'everyday' && - dismissedSuggestionPlaceId !== currentPlace?.id; - return ( {currentTrack ? : null} - {shouldShowTravelModeSuggestion && currentPlace ? ( - setDismissedSuggestionPlaceId(currentPlace.id)} - onStartTravelMode={() => { - handleSelectRecommendationMode('travel'); - setDismissedSuggestionPlaceId(currentPlace.id); - }} - place={currentPlace} - /> - ) : null} ); } From 92db19672455c117510c52f0d119f62c301dd283 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 01:44:46 +0900 Subject: [PATCH 06/12] =?UTF-8?q?style:=20=ED=99=88=20=EC=83=81=EB=8B=A8?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=95=95=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 4 ++-- src/components/home/HomeHeader.tsx | 14 ++++++------ src/components/home/TravelSessionCard.tsx | 27 ++++++++++++++--------- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index ff1f0b4..4ec96e4 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -220,13 +220,13 @@ function HomeContent() { diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx index 3531ae7..200d93c 100644 --- a/src/components/home/HomeHeader.tsx +++ b/src/components/home/HomeHeader.tsx @@ -46,9 +46,9 @@ const musicModeOptions: Array<{ export function HomeNavigationBar() { return ( - - - Soundlog + + + Soundlog ); @@ -67,7 +67,7 @@ export function HomeHeader({ const locationLabel = currentPlace?.title ?? '위치를 확인해 볼까요?'; return ( - + {musicModeOptions.map((mode) => { @@ -77,7 +77,7 @@ export function HomeHeader({ onSelectRecommendationMode(mode.value)} style={{ @@ -99,7 +99,7 @@ export function HomeHeader({ - + @@ -108,7 +108,7 @@ export function HomeHeader({ - - - + + + + - + {copy.title} + {status === 'idle' ? ( + + {sessionTime} + + ) : null} {status === 'active' && selectedModeLabel ? ( @@ -103,14 +108,16 @@ export function TravelSessionCard({ ) : null} - - {sessionTime} - + {status !== 'idle' ? ( + + {sessionTime} + + ) : null} {copy.cta} @@ -120,7 +127,7 @@ export function TravelSessionCard({ From 61afeb7da288d35aad524d1fabc54745d171ff3a Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 01:58:23 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B8=80=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlist/PlaylistBackground.tsx | 29 ++++++--- .../playlist/PlaylistBottomSheet.tsx | 56 ++++++++++------- src/components/playlist/PlaylistHeroInfo.tsx | 63 ++++++++++--------- .../playlist/PlaylistMusicFilter.tsx | 50 +++++++++++++++ 4 files changed, 140 insertions(+), 58 deletions(-) create mode 100644 src/components/playlist/PlaylistMusicFilter.tsx diff --git a/src/components/playlist/PlaylistBackground.tsx b/src/components/playlist/PlaylistBackground.tsx index e9cc221..8321368 100644 --- a/src/components/playlist/PlaylistBackground.tsx +++ b/src/components/playlist/PlaylistBackground.tsx @@ -1,21 +1,32 @@ import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet, useWindowDimensions, View } from 'react-native'; type PlaylistBackgroundProps = { imageUrl?: string; }; export function PlaylistBackground({ imageUrl }: PlaylistBackgroundProps) { + const { height, width } = useWindowDimensions(); + const repeatedImageStyle = { height: height / 2, width }; + return ( {imageUrl ? ( - + <> + + + ) : ( )} - + {stickyHeader ? ( - [ - - - {stickyHeader} - , - - {children} - , - ] + + + + {stickyHeader} + {children} + ) : ( + {children} diff --git a/src/components/playlist/PlaylistHeroInfo.tsx b/src/components/playlist/PlaylistHeroInfo.tsx index c2f2690..ea7e53c 100644 --- a/src/components/playlist/PlaylistHeroInfo.tsx +++ b/src/components/playlist/PlaylistHeroInfo.tsx @@ -2,6 +2,7 @@ import { Feather } from '@expo/vector-icons'; import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { PlaylistMusicFilter } from '@/components/playlist/PlaylistMusicFilter'; import { PlaylistCuration } from '@/types/domain'; type PlaylistHeroInfoProps = { @@ -12,39 +13,45 @@ type PlaylistHeroInfoProps = { export function PlaylistHeroInfo({ disabled = false, onPlay, playlist }: PlaylistHeroInfoProps) { return ( - - - - {playlist.regionName} - - {playlist.placeName ? ( - - {playlist.placeName} + + + + + {playlist.regionName} - ) : null} - - {playlist.reason} - - - - {playlist.trackCount}곡 - - - {playlist.durationText} + {playlist.placeName ? ( + + {playlist.placeName} + + ) : null} + + {playlist.reason} + + + + {playlist.trackCount}곡 + + + {playlist.durationText} + + + + + - - - + + + ); } diff --git a/src/components/playlist/PlaylistMusicFilter.tsx b/src/components/playlist/PlaylistMusicFilter.tsx new file mode 100644 index 0000000..04ab68b --- /dev/null +++ b/src/components/playlist/PlaylistMusicFilter.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +const musicFilters = ['전체', '드라이브', '산책', '시원한 바람', '신나는']; + +export function PlaylistMusicFilter() { + const [selectedFilter, setSelectedFilter] = useState(musicFilters[0]); + + return ( + + {musicFilters.map((filter) => { + const isSelected = selectedFilter === filter; + + return ( + setSelectedFilter(filter)} + style={{ + backgroundColor: isSelected ? 'rgba(255, 255, 255, 0.18)' : 'rgba(255, 255, 255, 0.09)', + borderColor: isSelected ? 'rgba(255, 255, 255, 0.42)' : 'rgba(255, 255, 255, 0.22)', + }} + > + + + {filter} + + + ); + })} + + ); +} From cb7ac0c1b563b668b62aae04cb26c3d6d64fcab5 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 02:07:19 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=EB=AC=B4=EB=93=9C=EB=B3=84=20?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 11 ++ .../playlist/PlaylistBackground.tsx | 11 +- .../playlist/PlaylistBottomSheet.tsx | 6 +- .../playlist/PlaylistCurationScreen.tsx | 75 +++++--- src/mock-server/playlistHandlers.ts | 2 +- src/mocks/homeMocks.ts | 5 + src/mocks/playlistMocks.ts | 168 ++++++++++++++++++ src/store/recommendationEventStore.ts | 1 + src/types/domain.ts | 2 + 9 files changed, 248 insertions(+), 33 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 4ec96e4..2d6ef9a 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -118,6 +118,17 @@ function HomeContent() { }, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]); const handleSelectRecommendation = (item: MoodRecommendation) => { + if (item.playlistId) { + router.push(`/playlist/${item.playlistId}`); + addRecommendationEvent({ + context: createRecommendationEventContext(), + playlistId: item.playlistId, + type: 'playlist_open', + value: item.playlistId, + }); + return; + } + setTrack(item.track); addRecommendationEvent({ context: createRecommendationEventContext(), diff --git a/src/components/playlist/PlaylistBackground.tsx b/src/components/playlist/PlaylistBackground.tsx index 8321368..6ab3bda 100644 --- a/src/components/playlist/PlaylistBackground.tsx +++ b/src/components/playlist/PlaylistBackground.tsx @@ -3,10 +3,11 @@ import { LinearGradient } from 'expo-linear-gradient'; import { StyleSheet, useWindowDimensions, View } from 'react-native'; type PlaylistBackgroundProps = { + accentColor?: string; imageUrl?: string; }; -export function PlaylistBackground({ imageUrl }: PlaylistBackgroundProps) { +export function PlaylistBackground({ accentColor, imageUrl }: PlaylistBackgroundProps) { const { height, width } = useWindowDimensions(); const repeatedImageStyle = { height: height / 2, width }; @@ -42,6 +43,14 @@ export function PlaylistBackground({ imageUrl }: PlaylistBackgroundProps) { start={{ x: 0.5, y: 0 }} style={StyleSheet.absoluteFill} /> + {accentColor ? ( + + ) : null} ); } diff --git a/src/components/playlist/PlaylistBottomSheet.tsx b/src/components/playlist/PlaylistBottomSheet.tsx index 1667713..cfec4b1 100644 --- a/src/components/playlist/PlaylistBottomSheet.tsx +++ b/src/components/playlist/PlaylistBottomSheet.tsx @@ -35,9 +35,8 @@ export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSh {stickyHeader} @@ -51,9 +50,8 @@ export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSh {children} diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx index 0adb3f9..c2d4236 100644 --- a/src/components/playlist/PlaylistCurationScreen.tsx +++ b/src/components/playlist/PlaylistCurationScreen.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { View } from 'react-native'; +import { ScrollView, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { usePlaylistCurationQuery } from '@/api/playlistQueries'; @@ -27,6 +27,7 @@ type PlaylistCurationScreenProps = { export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenProps) { const insets = useSafeAreaInsets(); + const { height } = useWindowDimensions(); const { currentTrack, setTrack } = usePlayerStore(); const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); const { @@ -75,6 +76,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro const hasMiniPlayer = Boolean(currentTrack); const listBottomPadding = getCurationListBottomPadding(insets.bottom, hasMiniPlayer); + const usesPlainMoodPage = playlistId === 'calm-walk' || Boolean(playlist?.accentColor); const playTrack = (track: Track) => { if (!playlist) { @@ -131,40 +133,59 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro }; const closeMenu = () => setSelectedTrackId(undefined); + const playlistContent = isLoading ? ( + + ) : isError ? ( + refetch()} /> + ) : !playlist ? ( + + ) : ( + setSelectedTrackId(track.id)} + onSelectTrack={playTrack} + savedTrackIds={savedTrackIds} + tracks={playlist.tracks} + /> + ); return ( - - - + + {usesPlainMoodPage ? ( + + {playlist ? ( - ) : undefined - } - > - {isLoading ? ( - - ) : isError ? ( - refetch()} /> - ) : !playlist ? ( - - ) : ( - setSelectedTrackId(track.id)} - onSelectTrack={playTrack} - savedTrackIds={savedTrackIds} - tracks={playlist.tracks} - /> - )} - + ) : null} + {playlistContent} + + ) : ( + + ) : undefined + } + > + {playlistContent} + + )} mockServerDelay( 'playlist.detail', - id ? playlistCurationById[id] : playlistDetail, + id ? playlistCurationById[id] ?? playlistDetail : playlistDetail, ), }; diff --git a/src/mocks/homeMocks.ts b/src/mocks/homeMocks.ts index 44e675e..9d84693 100644 --- a/src/mocks/homeMocks.ts +++ b/src/mocks/homeMocks.ts @@ -31,6 +31,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#2B176C', genres: ['인디', '발라드'], moods: ['잔잔한', '감성적인'], + playlistId: 'calm-walk', track: { artist: 'JENNIE', fallbackColor: '#192554', id: 'seoul-city', title: 'Seoul City' }, travelStyles: ['산책', '야경 감상'], }, @@ -40,6 +41,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#B1913A', genres: ['팝', 'K-POP'], moods: ['신나는', '청량한', '활기찬'], + playlistId: 'drive', track: { artist: '김건모', fallbackColor: '#48A5B4', id: 'moon-seoul', title: '서울의 달' }, travelStyles: ['드라이브', '바다 보기'], }, @@ -49,6 +51,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#1F2937', genres: ['R&B', 'OST'], moods: ['감성적인', '잔잔한'], + playlistId: 'city-night', track: { artist: '폴킴', fallbackColor: '#D70D31', id: 'hangang', title: '한강에서' }, travelStyles: ['야경 감상', '카페 투어'], }, @@ -58,6 +61,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#3F2C6B', genres: ['인디', 'R&B'], moods: ['잔잔한', '청량한'], + playlistId: 'cafe-indie', track: { artist: '10cm', fallbackColor: '#814D2B', id: 'cafe-night', title: '카페의 밤' }, travelStyles: ['카페 투어', '산책'], }, @@ -67,6 +71,7 @@ export const moodRecommendations: MoodRecommendation[] = [ color: '#9A3E62', genres: ['K-POP', '힙합'], moods: ['신나는', '활기찬'], + playlistId: 'festival-kpop', track: { artist: 'NewJeans', fallbackColor: '#29376B', id: 'festival-air', title: 'Festival Air' }, travelStyles: ['축제'], }, diff --git a/src/mocks/playlistMocks.ts b/src/mocks/playlistMocks.ts index f60038a..929238f 100644 --- a/src/mocks/playlistMocks.ts +++ b/src/mocks/playlistMocks.ts @@ -128,6 +128,119 @@ const geojeTracks: Track[] = [ }, ]; +const calmWalkTracks: Track[] = [ + { + artist: 'JENNIE', + fallbackColor: '#2B176C', + id: 'calm-walk-seoul-city', + platformUrls: { + spotify: 'https://open.spotify.com/search/JENNIE%20Seoul%20City', + youtubeMusic: 'https://music.youtube.com/search?q=JENNIE%20Seoul%20City', + }, + title: 'Seoul City', + }, + { + artist: '아이유', + fallbackColor: '#6E4FD3', + id: 'calm-walk-night-letter', + platformUrls: { + spotify: + 'https://open.spotify.com/search/%EC%95%84%EC%9D%B4%EC%9C%A0%20%EB%B0%A4%ED%8E%B8%EC%A7%80', + youtubeMusic: + 'https://music.youtube.com/search?q=%EC%95%84%EC%9D%B4%EC%9C%A0%20%EB%B0%A4%ED%8E%B8%EC%A7%80', + }, + title: '밤편지', + }, + { + artist: 'wave to earth', + fallbackColor: '#2D6A72', + id: 'calm-walk-seasons', + platformUrls: { + spotify: 'https://open.spotify.com/search/wave%20to%20earth%20seasons', + youtubeMusic: 'https://music.youtube.com/search?q=wave%20to%20earth%20seasons', + }, + title: 'seasons', + }, + { + artist: '카더가든', + fallbackColor: '#334D3F', + id: 'calm-walk-tree', + platformUrls: { + spotify: + 'https://open.spotify.com/search/%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4', + youtubeMusic: + 'https://music.youtube.com/search?q=%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4', + }, + title: '나무', + }, + { + artist: '혁오', + fallbackColor: '#45536B', + id: 'calm-walk-wi-ing', + platformUrls: { + spotify: 'https://open.spotify.com/search/hyukoh%20wi%20ing%20wi%20ing', + youtubeMusic: 'https://music.youtube.com/search?q=hyukoh%20wi%20ing%20wi%20ing', + }, + title: '위잉위잉', + }, + { + artist: '오존', + fallbackColor: '#6C7F99', + id: 'calm-walk-down', + platformUrls: { + spotify: 'https://open.spotify.com/search/O3ohn%20Down', + youtubeMusic: 'https://music.youtube.com/search?q=O3ohn%20Down', + }, + title: 'Down', + }, +]; + +function createMoodPlaylistTracks(playlistId: string, sourceTracks: Track[], colors: string[]) { + return sourceTracks.map((track, index) => ({ + ...track, + fallbackColor: colors[index % colors.length] ?? track.fallbackColor, + id: `${playlistId}-${track.id}`, + isLiked: undefined, + isSaved: undefined, + })); +} + +const driveTracks = createMoodPlaylistTracks('drive', [ + geojeTracks[1], + tracks[1], + geojeTracks[0], + geojeTracks[2], + tracks[5], + geojeTracks[7], +], ['#B1913A', '#D29B42', '#1D7F8C', '#DA6C51']); + +const cityNightTracks = createMoodPlaylistTracks('city-night', [ + tracks[2], + tracks[0], + tracks[3], + geojeTracks[3], + geojeTracks[7], + tracks[5], +], ['#1F2937', '#45536B', '#526391', '#D70D31']); + +const cafeIndieTracks = createMoodPlaylistTracks('cafe-indie', [ + tracks[5], + geojeTracks[6], + geojeTracks[3], + geojeTracks[5], + tracks[3], + geojeTracks[4], +], ['#3F2C6B', '#814D2B', '#334D3F', '#6E4FD3']); + +const festivalKpopTracks = createMoodPlaylistTracks('festival-kpop', [ + tracks[0], + geojeTracks[1], + geojeTracks[0], + tracks[4], + tracks[1], + geojeTracks[2], +], ['#9A3E62', '#D70D31', '#E66A73', '#29376B']); + export const playlistDetail: PlaylistCuration = { backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', @@ -141,6 +254,61 @@ export const playlistDetail: PlaylistCuration = { }; export const playlistCurationById: Record = { + 'calm-walk': { + accentColor: '#2B176C', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg', + durationText: '28:00분', + id: 'calm-walk', + placeName: '느린 걸음의 산책길', + reason: '잔잔한 산책 무드에 맞춰 천천히 이어지는 음악을 추천했어요', + regionName: '잔잔한 산책', + trackCount: calmWalkTracks.length, + tracks: calmWalkTracks, + }, + drive: { + accentColor: '#B1913A', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + durationText: '34:00분', + id: 'drive', + placeName: '해안도로 드라이브', + reason: '드라이브 팝 채널의 경쾌한 색감에 맞춰 이동감 있는 음악을 추천했어요', + regionName: '드라이브 팝', + trackCount: driveTracks.length, + tracks: driveTracks, + }, + 'city-night': { + accentColor: '#1F2937', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + durationText: '31:00분', + id: 'city-night', + placeName: '도시 야경 산책', + reason: '도시의 야경 채널에 맞춰 밤공기와 어울리는 감성적인 음악을 추천했어요', + regionName: '도시의 야경', + trackCount: cityNightTracks.length, + tracks: cityNightTracks, + }, + 'cafe-indie': { + accentColor: '#3F2C6B', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + durationText: '29:00분', + id: 'cafe-indie', + placeName: '창가 자리와 오후 산책', + reason: '카페 인디 채널의 차분한 색감에 맞춰 오래 머물기 좋은 음악을 추천했어요', + regionName: '카페 인디', + trackCount: cafeIndieTracks.length, + tracks: cafeIndieTracks, + }, + 'festival-kpop': { + accentColor: '#9A3E62', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/33/3010733_image2_1.jpg', + durationText: '33:00분', + id: 'festival-kpop', + placeName: '페스티벌 광장', + reason: '페스티벌 K-POP 채널의 에너지에 맞춰 밝고 리듬감 있는 음악을 추천했어요', + regionName: '페스티벌 K-POP', + trackCount: festivalKpopTracks.length, + tracks: festivalKpopTracks, + }, 'geoje-ocean': { backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts index 54bd2b3..a830080 100644 --- a/src/store/recommendationEventStore.ts +++ b/src/store/recommendationEventStore.ts @@ -12,6 +12,7 @@ export type RecommendationEventType = | 'track_unlike' | 'track_save' | 'track_unsave' + | 'playlist_open' | 'mood_filter_change' | 'recommendation_mode_change' | 'top_filter_change'; diff --git a/src/types/domain.ts b/src/types/domain.ts index 380fca6..722e846 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -54,6 +54,7 @@ export type MoodRecommendation = { color: string; genres?: string[]; moods?: string[]; + playlistId?: string; track: Track; travelStyles?: string[]; }; @@ -63,6 +64,7 @@ export type PlaylistCuration = { regionName: string; placeName?: string; reason: string; + accentColor?: string; coverImageUrl?: string; backgroundImageUrl?: string; trackCount: number; From a7522b97f22eb8b82755fc927f9354de85bb1522 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 02:10:17 +0900 Subject: [PATCH 09/12] =?UTF-8?q?style:=20=EB=AF=B8=EB=8B=88=20=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B8=80=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MiniPlayer.tsx | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index ec49a27..fc7aaa1 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -15,8 +15,8 @@ import { createRecommendationEventContext } from '@/utils/recommendationEventCon import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals'; const webGlassPlayerStyle = { - backdropFilter: 'blur(24px) saturate(155%)', - WebkitBackdropFilter: 'blur(24px) saturate(155%)', + backdropFilter: 'blur(30px) saturate(170%)', + WebkitBackdropFilter: 'blur(30px) saturate(170%)', }; export function MiniPlayer() { @@ -155,9 +155,9 @@ export function MiniPlayer() { return ( <> - + setIsActionMenuVisible(true)} + disabled={!canSkip} + onPress={handlePlayNext} + style={{ opacity: canSkip ? 1 : 0.35 }} > - + From dd6f82fbc9187840d5a9ca6e05111074c6c75fec Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 02:20:02 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=EB=AE=A4=EC=A7=81=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=88=9C=ED=99=98=20=EC=BA=90=EB=9F=AC=EC=85=80=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/home/MusicLogSection.tsx | 52 ++++++++++++++++++++----- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/src/components/home/MusicLogSection.tsx b/src/components/home/MusicLogSection.tsx index b714c70..36a806e 100644 --- a/src/components/home/MusicLogSection.tsx +++ b/src/components/home/MusicLogSection.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import { Animated, LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, View, } from 'react-native'; @@ -44,31 +46,60 @@ export function MusicLogSection({ const carouselSidePadding = sectionWidth > 0 ? Math.max(0, (sectionWidth - MUSIC_LOG_CARD_WIDTH) / 2) : 0; const initialIndex = data.length > MUSIC_LOG_INITIAL_INDEX ? MUSIC_LOG_INITIAL_INDEX : 0; - const initialOffset = initialIndex * MUSIC_LOG_SNAP_INTERVAL; + const canLoop = data.length > 1; + const carouselData = canLoop ? [...data, ...data, ...data] : data; + const initialCarouselIndex = canLoop ? data.length + initialIndex : initialIndex; + const initialOffset = initialCarouselIndex * MUSIC_LOG_SNAP_INTERVAL; const handleLayout = (event: LayoutChangeEvent) => { setSectionWidth(event.nativeEvent.layout.width); }; + const resetLoopEdge = (event: NativeSyntheticEvent) => { + if (!canLoop) { + return; + } + + const currentIndex = Math.round(event.nativeEvent.contentOffset.x / MUSIC_LOG_SNAP_INTERVAL); + + if (currentIndex >= data.length && currentIndex < data.length * 2) { + return; + } + + const realIndex = ((currentIndex % data.length) + data.length) % data.length; + const nextIndex = data.length + realIndex; + + const nextOffset = nextIndex * MUSIC_LOG_SNAP_INTERVAL; + + scrollX.setValue(nextOffset); + scrollRef.current?.scrollTo({ + animated: false, + x: nextOffset, + y: 0, + }); + }; + useEffect(() => { if (data.length === 0) { return; } const nextIndex = data.length > MUSIC_LOG_INITIAL_INDEX ? MUSIC_LOG_INITIAL_INDEX : 0; + const nextCarouselIndex = canLoop ? data.length + nextIndex : nextIndex; + const nextOffset = nextCarouselIndex * MUSIC_LOG_SNAP_INTERVAL; - scrollX.setValue(nextIndex * MUSIC_LOG_SNAP_INTERVAL); + scrollX.setValue(nextOffset); requestAnimationFrame(() => { scrollRef.current?.scrollTo({ animated: false, - x: nextIndex * MUSIC_LOG_SNAP_INTERVAL, + x: nextOffset, y: 0, }); }); - }, [data.length, scrollX]); + }, [canLoop, data.length, scrollX]); return ( - + Music Log {isLoading ? ( @@ -99,13 +130,15 @@ export function MusicLogSection({ [{ nativeEvent: { contentOffset: { x: scrollX } } }], { useNativeDriver: true }, )} + onScrollEndDrag={resetLoopEdge} + onMomentumScrollEnd={resetLoopEdge} ref={scrollRef} scrollEventThrottle={16} showsHorizontalScrollIndicator={false} snapToAlignment="start" snapToInterval={MUSIC_LOG_SNAP_INTERVAL} > - {data.map((item, index) => { + {carouselData.map((item, index) => { const inputRange = [ (index - 1) * MUSIC_LOG_SNAP_INTERVAL, index * MUSIC_LOG_SNAP_INTERVAL, @@ -114,7 +147,7 @@ export function MusicLogSection({ const rotate = scrollX.interpolate({ extrapolate: 'clamp', inputRange, - outputRange: ['-8deg', '0deg', '8deg'], + outputRange: ['8deg', '0deg', '-8deg'], }); const scale = scrollX.interpolate({ extrapolate: 'clamp', @@ -126,14 +159,15 @@ export function MusicLogSection({ onSelectLog(item) : undefined} - style={{ marginRight: index === data.length - 1 ? 0 : MUSIC_LOG_CARD_GAP }} + style={{ marginRight: index === carouselData.length - 1 ? 0 : MUSIC_LOG_CARD_GAP }} /> ); })} From d6230cb54fd3f2cce699bb20e31062b6e65cd1f2 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 02:21:52 +0900 Subject: [PATCH 11/12] =?UTF-8?q?style:=20=ED=99=88=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=20=EA=B0=84=EA=B2=A9=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 2d6ef9a..c9f82a1 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,6 +1,6 @@ import { router } from 'expo-router'; import { useCallback, useEffect } from 'react'; -import { ScrollView } from 'react-native'; +import { ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { @@ -273,21 +273,25 @@ function HomeContent() { onRetry={() => void featuredPlaylistsQuery.refetch()} /> - + + + - + + + {currentTrack ? : null} From 9dad464609accb9f15dca84afdc94989ce5b5987 Mon Sep 17 00:00:00 2001 From: Su Date: Sun, 7 Jun 2026 02:38:29 +0900 Subject: [PATCH 12/12] =?UTF-8?q?style:=20=ED=99=88=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=EC=99=80=20=EC=97=AC=ED=96=89=20=EC=83=81=ED=83=9C=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 36 +++++++++-------- src/components/home/HomeHeader.tsx | 45 +++------------------ src/components/home/LocationContextCard.tsx | 6 +-- src/components/home/TravelSessionCard.tsx | 42 ++++++++++++++----- 4 files changed, 61 insertions(+), 68 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index c9f82a1..270924c 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -244,27 +244,31 @@ function HomeContent() { - router.push('/recap')} - onOpenTravel={() => router.push('/travel')} - selectedMode={selectedMode} - startedAt={session.startedAt} - status={session.status} - /> + {recommendationMode === 'travel' ? ( + + router.push('/recap')} + onOpenTravel={() => router.push('/travel')} + selectedMode={selectedMode} + startedAt={session.startedAt} + status={session.status} + /> + + ) : null} - + + + void; - onSetCurrentLocation: () => void; }; type HomeTopFilterBarProps = { @@ -33,12 +29,12 @@ const musicModeOptions: Array<{ }> = [ { accent: colors.accent.blue, - label: '평소 취향 중심 추천', + label: '일상 모드', value: 'everyday', }, { accent: colors.accent.lime, - label: '여행지에 맞는 음악', + label: '여행 모드', value: 'travel', }, ]; @@ -55,20 +51,12 @@ export function HomeNavigationBar() { } export function HomeHeader({ - currentPlace, - isLocationLoading = false, onSelectRecommendationMode, - onSetCurrentLocation, recommendationMode, }: HomeHeaderProps) { - const activeMode = - musicModeOptions.find((mode) => mode.value === recommendationMode) ?? - musicModeOptions[0]; - const locationLabel = currentPlace?.title ?? '위치를 확인해 볼까요?'; - return ( - - + + {musicModeOptions.map((mode) => { const selected = recommendationMode === mode.value; @@ -86,7 +74,7 @@ export function HomeHeader({ }} > - - - - - - {locationLabel} - - - - - - {isLocationLoading ? '확인 중' : '위치 설정'} - - - ); } diff --git a/src/components/home/LocationContextCard.tsx b/src/components/home/LocationContextCard.tsx index 1690eb6..967ff33 100644 --- a/src/components/home/LocationContextCard.tsx +++ b/src/components/home/LocationContextCard.tsx @@ -34,9 +34,9 @@ const statusCopy: Record diff --git a/src/components/home/TravelSessionCard.tsx b/src/components/home/TravelSessionCard.tsx index bc5ed83..72cefb1 100644 --- a/src/components/home/TravelSessionCard.tsx +++ b/src/components/home/TravelSessionCard.tsx @@ -2,13 +2,14 @@ import { Feather } from '@expo/vector-icons'; import { Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; -import type { TravelMode } from '@/types/domain'; +import type { PlaceContext, TravelMode } from '@/types/domain'; import { formatRecapRecordedAt } from '@/utils/dateFormat'; type TravelSessionStatus = 'active' | 'ended' | 'idle'; type TravelSessionCardProps = { endedAt?: string; + currentPlace?: PlaceContext; onDismissEnded?: () => void; onOpenRecap: () => void; onOpenTravel: () => void; @@ -68,6 +69,7 @@ function formatSessionTime(status: TravelSessionStatus, startedAt?: string, ende } export function TravelSessionCard({ + currentPlace, endedAt, onDismissEnded, onOpenRecap, @@ -82,17 +84,25 @@ export function TravelSessionCard({ const selectedModeLabel = travelModeOptions.find( (mode) => mode.value === selectedMode, )?.label; + const isActive = status === 'active'; + const locationLabel = currentPlace?.title ?? '현재 위치 확인 중'; return ( - - - - + + + + - + {copy.title} {status === 'idle' ? ( @@ -101,14 +111,26 @@ export function TravelSessionCard({ ) : null} {status === 'active' && selectedModeLabel ? ( - - + + {selectedModeLabel} ) : null} - {status !== 'idle' ? ( + {isActive ? ( + + + + {sessionTime} + + · + + + {locationLabel} + + + ) : status !== 'idle' ? ( {sessionTime} @@ -117,7 +139,7 @@ export function TravelSessionCard({ {copy.cta}