diff --git a/README.md b/README.md index 41ac1e4..a88f056 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ API origin이 최신 서버인지 확인하려면 아래처럼 실행합니다. SOUNDLOG_API_ORIGIN=http://:4000 npm run check:api-origin ``` +이 검사는 로그인 필수 API도 함께 확인하므로 `SOUNDLOG_CHECK_EMAIL`, +`SOUNDLOG_CHECK_PASSWORD`를 지정하면 해당 smoke 계정으로 로그인합니다. 값을 +지정하지 않으면 `@soundlog.test` 임시 계정을 생성해 검증합니다. + Android 지인 테스트용 내부 배포 빌드는 아래 명령으로 생성합니다. ```bash diff --git a/app.config.js b/app.config.js index e119c74..de7b244 100644 --- a/app.config.js +++ b/app.config.js @@ -79,6 +79,7 @@ const baseConfig = { ], 'expo-secure-store', 'expo-image', + ['expo-build-properties', {}], ], experiments: { typedRoutes: true, diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 820e520..6366e20 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import { Redirect, router } from 'expo-router'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -16,6 +16,7 @@ import { AppText } from '@/components/AppText'; import { useNearbyPlacesQuery } from '@/api/tourQueries'; import { MiniPlayer } from '@/components/MiniPlayer'; import { FeaturedPlaylistSection } from '@/components/home/FeaturedPlaylistSection'; +import { CurrentSoundtrackCard } from '@/components/home/CurrentSoundtrackCard'; import { HomeHeader, HomeNavigationBar, @@ -38,7 +39,13 @@ import { } from '@/store/momentLogStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; +import { + createFeaturedPlaylistsCacheKey, + createMoodRecommendationsCacheKey, + useRecommendationCacheStore, +} from '@/store/recommendationCacheStore'; import { queryClient } from '@/providers/queryClient'; +import { useAuthStore } from '@/store/authStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import { useUserProfileStore } from '@/store/userProfileStore'; import { FeaturedPlaylist, MoodRecommendation, MusicLogItem, TravelMode } from '@/types/domain'; @@ -65,6 +72,21 @@ const travelModeToMlState: Partial> = { walk: '산책', }; +const travelModeDisplayLabel: Record = { + cafe: '카페', + drive: '드라이브', + festival: '축제', + night: '야경', + ocean: '바다', + walk: '산책', +}; + +const travelStyleDisplayLabel: Record = { + '바다 보기': '바다', + '야경 감상': '야경', + '카페 투어': '카페', +}; + function resolvePlaylistMood(filter: string, preferredMoods: string[]): PlaylistMlMood { if (filter !== '전체' && moodFilterToMlMood[filter]) { return moodFilterToMlMood[filter]; @@ -77,8 +99,27 @@ function resolvePlaylistState(mode?: TravelMode): PlaylistMlState { return (mode ? travelModeToMlState[mode] : undefined) ?? '산책'; } +function resolveCurrentTravelLabel(mode: TravelMode | undefined, travelStyles: string[]) { + if (mode) { + return travelModeDisplayLabel[mode]; + } + + const firstTravelStyle = travelStyles[0]; + + return firstTravelStyle ? travelStyleDisplayLabel[firstTravelStyle] ?? firstTravelStyle : '산책'; +} + +function resolveCurrentMoodLabel(filter: string, preferredMoods: string[]) { + if (filter !== '전체') { + return filter; + } + + return preferredMoods[0] ?? '잔잔한'; +} + function HomeContent() { const insets = useSafeAreaInsets(); + const authStatus = useAuthStore((state) => state.status); const [actionMessage, setActionMessage] = useState(); const [creatingPlaylistId, setCreatingPlaylistId] = useState(); const { @@ -107,28 +148,66 @@ function HomeContent() { setPlace, setRecommendationMode, } = useTravelSessionStore(); + const featuredPlaylistParams = useMemo( + () => ({ + location: currentLocation, + locationRecommendationEnabled: profile.locationRecommendationEnabled, + place: currentPlace, + recommendationMode, + }), + [ + currentLocation, + currentPlace, + profile.locationRecommendationEnabled, + recommendationMode, + ], + ); + const moodRecommendationParams = useMemo( + () => ({ + currentPlace, + moodFilter: selectedMoodFilter, + preferredGenres: profile.preferredGenres, + preferredMoods: profile.preferredMoods, + recommendationMode, + topFilter: selectedTopFilter, + travelStyles: profile.travelStyles, + }), + [ + currentPlace, + profile.preferredGenres, + profile.preferredMoods, + profile.travelStyles, + recommendationMode, + selectedMoodFilter, + selectedTopFilter, + ], + ); + const featuredCacheKey = useMemo( + () => createFeaturedPlaylistsCacheKey(featuredPlaylistParams), + [featuredPlaylistParams], + ); + const moodCacheKey = useMemo( + () => createMoodRecommendationsCacheKey(moodRecommendationParams), + [moodRecommendationParams], + ); + const featuredFallback = useRecommendationCacheStore((state) => state.featuredFallback); + const moodFallback = useRecommendationCacheStore((state) => state.moodFallback); + const isUsingCachedFeaturedPlaylists = featuredFallback?.key === featuredCacheKey; + const isUsingCachedMoodRecommendations = moodFallback?.key === moodCacheKey; + const isAuthenticated = authStatus === 'authenticated'; const nearbyPlacesQuery = useNearbyPlacesQuery({ - enabled: profile.locationRecommendationEnabled, + enabled: isAuthenticated && profile.locationRecommendationEnabled, location: currentLocation, radiusMeters: 2000, }); - const featuredPlaylistsQuery = useFeaturedPlaylistsQuery({ - location: currentLocation, - locationRecommendationEnabled: profile.locationRecommendationEnabled, - place: currentPlace, - recommendationMode, + const featuredPlaylistsQuery = useFeaturedPlaylistsQuery(featuredPlaylistParams, { + enabled: isAuthenticated, }); - const moodRecommendationsQuery = useMoodRecommendationsQuery({ - currentPlace, - moodFilter: selectedMoodFilter, - preferredGenres: profile.preferredGenres, - preferredMoods: profile.preferredMoods, - recommendationMode, - topFilter: selectedTopFilter, - travelStyles: profile.travelStyles, + const moodRecommendationsQuery = useMoodRecommendationsQuery(moodRecommendationParams, { + enabled: isAuthenticated, }); - const recentMusicLogsQuery = useRecentMusicLogsQuery(); + const recentMusicLogsQuery = useRecentMusicLogsQuery({ enabled: isAuthenticated }); const musicLogs = [ ...momentLogs.slice(0, 6).map(momentLogToMusicLogItem), ...(recentMusicLogsQuery.data ?? []), @@ -184,6 +263,15 @@ function HomeContent() { } setTrack(item.track); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext(), + playlistId: item.playlistId, + trackId: item.track.id, + type: 'track_selected', + value: 'home_mood_recommendation', + }), + ); setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); }; const handleSelectFeaturedPlaylist = useCallback( @@ -196,19 +284,16 @@ function HomeContent() { setCreatingPlaylistId(playlist.id); try { - const contextualPlaylist = await playlistApi.createContextualPlaylist( - { - location: currentLocation ?? currentPlace?.location, - mood: resolvePlaylistMood(selectedMoodFilter, profile.preferredMoods), - moodTags: getMoodTagsFromFilter(selectedMoodFilter), - placeId: currentPlace?.id, - preferredGenres: profile.preferredGenres, - preferredMoods: profile.preferredMoods, - state: resolvePlaylistState(selectedMode), - travelMode: selectedMode, - }, - playlist.id, - ); + const contextualPlaylist = await playlistApi.getRecommendedPlaylist({ + location: currentLocation ?? currentPlace?.location, + mood: resolvePlaylistMood(selectedMoodFilter, profile.preferredMoods), + moodTags: getMoodTagsFromFilter(selectedMoodFilter), + placeId: currentPlace?.id, + preferredGenres: profile.preferredGenres, + preferredMoods: profile.preferredMoods, + state: resolvePlaylistState(selectedMode), + travelMode: selectedMode, + }); const nextPlaylistId = contextualPlaylist?.id ?? playlist.id; if (contextualPlaylist) { @@ -222,6 +307,7 @@ function HomeContent() { addRecommendationEvent({ context: createRecommendationEventContext({ moodFilter: selectedMoodFilter, + source: contextualPlaylist.context?.source, }), playlistId: nextPlaylistId, type: 'playlist_open', @@ -392,6 +478,20 @@ function HomeContent() { updatedAt={locationUpdatedAt} /> + router.push('/camera' as never)} + onOpenPlaylist={handleSelectFeaturedPlaylist} + onRetry={() => void featuredPlaylistsQuery.refetch()} + playlist={featuredPlaylistsQuery.data?.[0]} + travelLabel={resolveCurrentTravelLabel(selectedMode, profile.travelStyles)} + /> + {recommendationMode === 'travel' ? ( { @@ -462,6 +567,14 @@ export default function HomeScreen() { } }, [isHydrated, profile.completedOnboarding]); + if (!authHydrated || status === 'checking') { + return ; + } + + if (status !== 'authenticated') { + return ; + } + if (!isHydrated || !profile.completedOnboarding) { return isHydrated ? : ; } diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index d82b6d7..fefc714 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -118,7 +118,7 @@ export default function MyScreen() { { description: selectedSummary || '아직 저장된 취향 정보가 없어요.', icon: 'sliders', - label: '취향 정보 수정', + label: '취향 수정', onPress: () => router.push({ pathname: '/onboarding', @@ -126,11 +126,9 @@ export default function MyScreen() { } as never), }, { - description: profile.locationRecommendationEnabled - ? '위치 기반 추천 사용 중' - : '위치 추천 꺼짐', + description: '위치 · 카메라 · 사진', icon: 'map-pin', - label: '위치/카메라 권한', + label: '권한 설정', }, { description: '데이터 수집과 보관, 삭제 요청 방법을 확인합니다.', @@ -161,7 +159,7 @@ export default function MyScreen() { contentContainerStyle={{ paddingBottom: 132, paddingHorizontal: 20, paddingTop: 32 }} showsVerticalScrollIndicator={false} > - My + 마이 diff --git a/app/(tabs)/travel-room/[roomId].tsx b/app/(tabs)/travel-room/[roomId].tsx new file mode 100644 index 0000000..f38507f --- /dev/null +++ b/app/(tabs)/travel-room/[roomId].tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from 'expo-router'; + +import { TravelRoomDetailScreen } from '@/components/travel/TravelRoomDetailScreen'; + +export default function TravelRoomDetailRoute() { + const { roomId } = useLocalSearchParams<{ roomId?: string }>(); + + return ; +} diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 638ccb3..cbc560e 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -10,6 +10,7 @@ import { BrandLogo } from '@/components/BrandLogo'; import { Screen } from '@/components/Screen'; import { useAuthStore } from '@/store/authStore'; import { useUserProfileStore } from '@/store/userProfileStore'; +import { migrateLocalDataToAccount } from '@/utils/localDataMigration'; type AuthMode = 'login' | 'register'; @@ -35,13 +36,12 @@ export default function LoginScreen() { const isPending = loginMutation.isPending || registerMutation.isPending; const { clearAuthError, - continueAsGuest, errorMessage, finishLogin, setAuthError, setStatus, } = useAuthStore(); - const { profile } = useUserProfileStore(); + const { profile, updateProfile } = useUserProfileStore(); const handleModePress = (nextMode: AuthMode) => { setMode(nextMode); @@ -82,20 +82,22 @@ export default function LoginScreen() { password, }); + const didCompleteOnboarding = + profile.completedOnboarding || Boolean(session.profile?.completedOnboarding); + + if (!profile.completedOnboarding && session.profile?.completedOnboarding) { + updateProfile(session.profile); + } + finishLogin(session); - router.replace(getNextRoute(profile.completedOnboarding)); + void migrateLocalDataToAccount(); + router.replace(getNextRoute(didCompleteOnboarding)); } catch (error) { setStatus('unauthenticated'); setAuthError(getErrorMessage(error)); } }; - const handleGuestPress = () => { - clearAuthError(); - continueAsGuest(); - router.replace(getNextRoute(profile.completedOnboarding)); - }; - return ( 이메일 계정으로 취향, 좋아요, 순간 기록, Recap을 서버에 동기화할 수 - 있어요. 바로 둘러보기도 계속 지원합니다. + 있어요. Soundlog 이용은 로그인 후 시작할 수 있습니다. - - - 로그인 없이 둘러보기 - - - 계속 진행하면 Soundlog 정책에 동의한 것으로 간주됩니다. diff --git a/app/legal/terms.tsx b/app/legal/terms.tsx index b8b729a..bad003f 100644 --- a/app/legal/terms.tsx +++ b/app/legal/terms.tsx @@ -7,8 +7,8 @@ const termsSections = [ body: 'Soundlog는 위치와 여행 맥락을 바탕으로 음악 추천, 순간 기록, Recap 생성을 제공하는 서비스입니다. 사용자는 본인의 기기와 계정에서 발생하는 활동에 대한 책임이 있습니다.', }, { - title: '게스트 이용과 계정 연동', - body: '사용자는 로그인 없이 일부 기능을 둘러볼 수 있습니다. 계정 연동 시 취향, 좋아요, 여행 기록, Recap 정보를 서버 계정으로 이어서 사용할 수 있습니다.', + title: '계정 기반 이용', + body: 'Soundlog의 추천, 좋아요, 여행 기록, Recap 기능은 로그인된 Soundlog 계정에서 사용할 수 있습니다. 온보딩과 약관 확인은 로그인 전에도 볼 수 있지만, 앱의 주요 기능 이용에는 계정 로그인이 필요합니다.', }, { title: '사용자 콘텐츠', diff --git a/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md b/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md index 2bbc01a..41d598f 100644 --- a/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md +++ b/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md @@ -73,6 +73,10 @@ npm run check:server-web-export npm run check:deployed-web -- https://sound-log-app.vercel.app ``` +로그인 필수 API를 함께 확인하므로 `SOUNDLOG_CHECK_EMAIL`, +`SOUNDLOG_CHECK_PASSWORD`를 주면 고정 smoke 계정으로 로그인합니다. 지정하지 않으면 +검증용 `@soundlog.test` 임시 계정을 생성합니다. + Vercel preview가 Deployment Protection으로 보호되어 있으면 `/api/soundlog/v1/...` 요청이 JSON이 아니라 Vercel SSO로 리다이렉트됩니다. 이 경우 Vercel 프로젝트의 Protection Bypass for Automation secret을 받아 아래처럼 실행합니다. ```bash diff --git a/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md b/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md index 4504ff8..e23d09f 100644 --- a/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md +++ b/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md @@ -65,6 +65,11 @@ curl https://soundlog.shop/api/soundlog/v1/health npm run check:deployed-web -- https://soundlog.shop ``` +`check:deployed-web`는 로그인 필수 API도 검증합니다. 반복 실행 시 DB에 smoke +계정이 계속 생기는 것을 피하려면 `SOUNDLOG_CHECK_EMAIL`, +`SOUNDLOG_CHECK_PASSWORD`를 지정해 고정 검증 계정으로 실행합니다. 값을 지정하지 +않으면 스크립트가 `@soundlog.test` 임시 계정을 생성합니다. + EC2 origin을 직접 검증하려면 아래 명령을 실행합니다. 이 검사는 `/openapi.yaml`, fallback 장소 source, Spotify 메타데이터 제거 여부까지 확인하므로 예전 백엔드로 잘못 붙은 경우 실패합니다. ```bash diff --git a/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md b/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md index c90f00e..29fba1a 100644 --- a/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md +++ b/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md @@ -2,7 +2,7 @@ ## 1. 목표 -Soundlog에 소셜 로그인을 붙일 수 있는 인증 플로우를 설계한다. 현재 앱은 로컬 온보딩 프로필과 로컬 저장소 중심으로 동작하므로, 로그인은 기존 데이터를 잃지 않으면서 서버 계정으로 연결하는 방향으로 구현한다. +Soundlog에 계정 로그인을 붙이는 인증 플로우를 설계한다. 현재 MVP 정책은 게스트 사용을 제공하지 않고, 온보딩 소개와 약관 확인을 제외한 주요 앱 기능을 로그인 후 사용할 수 있게 한다. 핵심 목표: @@ -15,18 +15,18 @@ Soundlog에 소셜 로그인을 붙일 수 있는 인증 플로우를 설계한 ### 2.1 MVP 권장안 -로그인은 필수 진입 장벽으로 두기보다, `로그인 권장 + 둘러보기 가능`으로 시작한다. +로그인은 필수 진입 장벽으로 둔다. 사용자는 온보딩 소개 화면에서 제품 개념을 확인할 수 있지만, 홈/추천/기록/Recap 같은 주요 기능은 Soundlog 계정 로그인 후 사용할 수 있다. 이유: -- Soundlog는 여행 현장에서 빠르게 써야 하므로 첫 실행 이탈을 줄여야 한다. -- 현재 앱도 `나중에 하기`와 로컬 온보딩 저장을 이미 지원한다. -- 추천, 카메라, Recap 체험은 게스트로 가능하게 두고, 서버 동기화/공유/기기 간 복원 단계에서 로그인을 강하게 유도하는 편이 자연스럽다. +- 여행 기록과 Recap은 계정에 보존되어야 사용자가 데이터 유실을 덜 걱정한다. +- 공동 Recap, Live Sound Map, 음악 취향 매칭은 계정 식별과 신고/차단 정책이 필요하다. +- 기존 기기에 남아 있는 로컬 기록은 로그인 후 서버 동기화를 시도한다. 권장 제한: -- 게스트 가능: 홈 추천, 온보딩, 음악 플랫폼 선택, 로컬 순간 저장, 로컬 Recap 미리보기 -- 로그인 권장/필수: 서버 동기화, 여러 기기 복원, 공개 공유 링크 생성, 소셜 계정 기반 백업, 장기 아카이브 +- 로그인 전 가능: 온보딩 소개, 약관/개인정보 처리방침 확인, 로그인/가입 +- 로그인 필수: 홈 추천, 음악 선택, Moment 저장, Recap, 보관함, 공동 여행방, Live Sound Map, 매칭 ### 2.2 진입 플로우 @@ -39,18 +39,16 @@ Soundlog에 소셜 로그인을 붙일 수 있는 인증 플로우를 설계한 - 완료 후면 `/(tabs)` 5. 세션 없음: - `/auth/login` 진입 - - 사용자는 소셜 로그인 또는 둘러보기 선택 -6. 둘러보기 선택: - - `guest` 세션으로 표시 - - 기존 온보딩 플로우 진행 + - 사용자는 로그인 또는 가입 선택 +6. 온보딩 미완료: + - 로그인 성공 후 `/onboarding`에서 취향/권한 설정 진행 ## 3. 화면/라우팅 설계 ### 신규 라우트 - `app/auth/login.tsx` - - 소셜 로그인 CTA - - 둘러보기 CTA + - 로그인/가입 CTA - 약관/개인정보 처리 링크 - 로그인 실패/취소 상태 표시 @@ -68,13 +66,13 @@ Soundlog에 소셜 로그인을 붙일 수 있는 인증 플로우를 설계한 - 인증 hydration과 onboarding hydration을 함께 고려하는 `AuthGate` 또는 `AppEntryGate`로 이동 권장 - `app/onboarding.tsx` - - 로그인 직후 처음 진입하는 경우와 게스트 둘러보기 진입을 구분 + - 로그인 직후 처음 진입하는 경우와 온보딩 재진입을 구분 - 온보딩 완료 후 서버 프로필 저장 mutation 호출 가능하게 확장 - `app/(tabs)/my.tsx` - 상단에 계정 카드 추가 - 로그인 상태: 이름/이메일/제공자/동기화 상태/로그아웃 - - 게스트 상태: 로그인 유도 CTA + - 로그아웃 상태: 로그인 유도 CTA ## 4. 상태 관리 설계 @@ -84,7 +82,7 @@ Soundlog에 소셜 로그인을 붙일 수 있는 인증 플로우를 설계한 상태: -- `status`: `checking | guest | authenticated | unauthenticated` +- `status`: `checking | authenticated | unauthenticated` - `user`: `AuthUser | null` - `accessToken`: 메모리 중심 보관 - `refreshTokenStored`: boolean @@ -95,7 +93,6 @@ Soundlog에 소셜 로그인을 붙일 수 있는 인증 플로우를 설계한 - `restoreSession()` - `signInWithProvider(provider)` -- `continueAsGuest()` - `finishLogin(session)` - `logout()` - `clearAuthError()` @@ -104,7 +101,7 @@ Soundlog에 소셜 로그인을 붙일 수 있는 인증 플로우를 설계한 - access token: 메모리/Zustand - refresh token: `expo-secure-store` -- guest flag와 last provider: AsyncStorage 가능 +- last provider: AsyncStorage 가능 웹 주의: @@ -192,7 +189,7 @@ refresh token으로 access token을 갱신한다. ### `POST /v1/me/migrate-local-data` -게스트 상태에서 만든 로컬 로그, 좋아요, Recap 초안을 로그인 계정으로 이관한다. +로그인 전 로컬로 만든 로그, 좋아요, Recap 초안을 로그인 계정으로 이관한다. ## 6. 소셜 로그인 제공자 전략 @@ -253,8 +250,8 @@ MVP는 `expo-auth-session` 기반 OAuth redirect를 기본으로 둔다. ## 8. 에러/엣지케이스 - 사용자가 소셜 로그인 창을 닫음: 취소 상태로 복귀, 에러 토스트 대신 조용한 안내 -- 네트워크 실패: 재시도 CTA, 게스트로 계속하기 제공 -- refresh 실패: access token 정리 후 guest 또는 unauthenticated 상태 +- 네트워크 실패: 재시도 CTA 제공, 앱 주요 기능 진입은 로그인 성공 후 허용 +- refresh 실패: access token 정리 후 unauthenticated 상태 - 서버는 로그인 성공, 프로필 저장 실패: 로그인 유지, 온보딩 데이터는 로컬 보관 후 재시도 - 로그아웃 실패: 서버 실패와 무관하게 로컬 토큰 삭제 - 앱 삭제/재설치: SecureStore 토큰 없음, 재로그인 필요 @@ -275,13 +272,13 @@ MVP는 `expo-auth-session` 기반 OAuth redirect를 기본으로 둔다. 검증: -- 게스트 진입 +- 로그아웃 상태에서 주요 앱 기능 진입 차단 - mock Google/Kakao/Apple 로그인 - 로그인 후 온보딩 이동 - 온보딩 완료 후 홈 이동 - 로그아웃 후 로그인 화면 이동 - 네트워크 실패 상태 -- refresh 실패 후 로그인 화면 또는 게스트 상태 전환 +- refresh 실패 후 로그인 화면 전환 - 이미 로그인된 사용자가 `/auth/login`에 접근했을 때 홈으로 복귀 ### Phase 2. API 문서화와 서버 DTO 반영 @@ -323,7 +320,7 @@ MVP는 `expo-auth-session` 기반 OAuth redirect를 기본으로 둔다. - `AuthGate`는 `auth.isHydrated`와 `profile.isHydrated`가 모두 true가 된 뒤에만 redirect한다. - 현재 path가 `auth/*`인지, `onboarding`인지 확인한 뒤 필요한 경우에만 replace한다. - `authenticated`지만 `completedOnboarding=false`이면 `/onboarding`으로 보낸다. -- `guest`이며 `completedOnboarding=true`이면 홈 접근을 허용한다. +- `unauthenticated`이면 온보딩 소개, 로그인, 약관 화면만 허용한다. ### 10.2 API 401 처리 @@ -333,7 +330,7 @@ access token 만료 시 여러 query/mutation이 동시에 refresh를 호출할 - API client 레이어에서 access token 주입과 401 처리를 한 곳으로 모은다. - refresh 요청은 단일 promise로 묶어 중복 호출을 방지한다. -- refresh 실패 시 auth store를 `unauthenticated` 또는 `guest`로 전환하고 민감한 토큰을 삭제한다. +- refresh 실패 시 auth store를 `unauthenticated`로 전환하고 민감한 토큰을 삭제한다. ### 10.3 SecureStore와 web fallback @@ -345,9 +342,9 @@ access token 만료 시 여러 query/mutation이 동시에 refresh를 호출할 - web MVP: mock auth 또는 memory session - web production: 백엔드 httpOnly secure cookie 기반 세션 검토 -### 10.4 게스트 데이터 이관 +### 10.4 로그인 전 로컬 데이터 이관 -게스트 상태에서 만든 데이터가 로그인 후 중복 생성될 수 있다. +로그인 전 로컬에 남은 데이터가 로그인 후 중복 생성될 수 있다. 보강안: @@ -359,7 +356,7 @@ access token 만료 시 여러 query/mutation이 동시에 refresh를 호출할 아래 항목은 제품 정책에 영향을 주므로 구현 전에 결정이 필요하다. -1. MVP에서 로그인을 필수로 막을까요, 아니면 게스트 둘러보기를 유지할까요? +1. MVP에서는 로그인을 필수로 막고, 온보딩 소개와 약관만 로그아웃 상태에서 접근할 수 있게 합니다. 2. 1차 소셜 로그인 제공자는 무엇으로 갈까요? 권장안은 Apple, Google, Kakao입니다. 3. 로그인 전 만든 여행 로그/좋아요/Recap은 로그인 후 자동 이관할까요, 사용자 확인 후 이관할까요? 4. 로그아웃 시 로컬 여행 기록은 유지할까요, 모두 삭제할까요? diff --git a/docs/frontend/RELEASE_HARDENING_PLAN.md b/docs/frontend/RELEASE_HARDENING_PLAN.md index a4e77f4..5b31af1 100644 --- a/docs/frontend/RELEASE_HARDENING_PLAN.md +++ b/docs/frontend/RELEASE_HARDENING_PLAN.md @@ -42,4 +42,4 @@ Move the current EC2-connected demo from "mock-auth integration works" toward a - Real provider client IDs and redirect URIs. - HTTPS API URL and upload public base URL. - EAS production profile secrets. -- Decision on whether guest/local data migration is automatic or user-confirmed. +- Decision on whether login-before-signup local data migration is automatic or user-confirmed. diff --git a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md index 69072bf..11fd53a 100644 --- a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md +++ b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md @@ -117,7 +117,7 @@ MVP 기준 탭은 4개로 단순화한다. 프론트 고려사항은 다음과 같다. - 첫 실행 여부를 로컬에 저장한다. -- 둘러보기 모드를 제공할 경우, 위치/음악 연동 없이 샘플 데이터로 홈을 보여준다. +- 현재 MVP에서는 둘러보기 모드를 제공하지 않고, 주요 기능은 로그인 이후에만 보여준다. - 온보딩 완료 전에는 홈 진입을 막을지, 제한적으로 허용할지 정책이 필요하다. - 권한 요청은 앱 실행 즉시 띄우지 않고, 기능 맥락이 생긴 뒤 요청한다. diff --git a/docs/implementation/2026-06-24-ios-apple-kakao-login-plan.md b/docs/implementation/2026-06-24-ios-apple-kakao-login-plan.md index cda5a70..82703ba 100644 --- a/docs/implementation/2026-06-24-ios-apple-kakao-login-plan.md +++ b/docs/implementation/2026-06-24-ios-apple-kakao-login-plan.md @@ -2,7 +2,7 @@ ## Goal -iOS 앱에서 실제 소셜 로그인 진입점을 Apple과 Kakao 두 개로 제한한다. Google 버튼은 사용자 플로우에서 제거하고, 게스트 둘러보기는 유지한다. 키가 없는 개발/프리뷰 환경에서는 dev fallback 또는 안내 상태로 앱이 깨지지 않아야 한다. +iOS 앱에서 실제 소셜 로그인 진입점을 Apple과 Kakao 두 개로 제한한다. Google 버튼은 사용자 플로우에서 제거하고, 주요 앱 기능은 로그인 이후에만 사용할 수 있게 한다. 키가 없는 개발/프리뷰 환경에서는 dev fallback 또는 안내 상태로 앱이 깨지지 않아야 한다. ## Contract diff --git a/docs/implementation/2026-06-24-real-user-test-plan.md b/docs/implementation/2026-06-24-real-user-test-plan.md index ace1744..dcdc123 100644 --- a/docs/implementation/2026-06-24-real-user-test-plan.md +++ b/docs/implementation/2026-06-24-real-user-test-plan.md @@ -8,7 +8,7 @@ Test Soundlog from a fresh user state and fix P3 or higher issues found during t 1. Fresh browser storage. 2. Login screen. -3. Continue as guest. +3. Login or signup. 4. Four-step onboarding. 5. Home recommendations. 6. Playlist/playback path. diff --git a/docs/implementation/2026-06-28-ml-playlist-api-plan.md b/docs/implementation/2026-06-28-ml-playlist-api-plan.md index ea16f7a..05b5b29 100644 --- a/docs/implementation/2026-06-28-ml-playlist-api-plan.md +++ b/docs/implementation/2026-06-28-ml-playlist-api-plan.md @@ -18,7 +18,7 @@ - `.env.example`, README/API 테스트 업데이트. ## 클라이언트 변경 -- `src/api/playlistApi.ts`: `createContextualPlaylist` 추가. 서버 source + API URL + 로그인 세션이 있을 때 `POST /v1/playlists/contextual` 호출, 실패 시 기존 fallback 상세 조회. +- `src/api/playlistApi.ts`: `createContextualPlaylist` 추가. 서버 source + API URL + 로그인 세션이 있을 때 `POST /v1/playlists/contextual` 호출, ML 장애 시 인증된 contextual fallback으로 전환. - `src/api/playlistQueries.ts`: contextual mutation 또는 helper hook 추가. - `app/(tabs)/index.tsx`: featured playlist 카드 선택 시 현재 위치/여행 상태/무드를 body로 서버 API 호출 후 상세 화면으로 이동. - `src/components/home/FeaturedPlaylistSection.tsx`/`FeaturedPlaylistCard.tsx`: 카드 press handler를 주입할 수 있게 변경. @@ -32,5 +32,5 @@ ## 리스크와 처리 - ML 서버가 트랙 title/artist만 반환하므로 실제 스트리밍 id는 없다. 클라이언트의 기존 Spotify search fallback을 활용하도록 platform search URL을 생성한다. -- 게스트/미로그인 사용자는 기존 GET `/v1/playlists/:id` fallback으로 이동한다. +- 미로그인 사용자는 앱 기능에 진입하지 못하며, 인증 오류는 fallback으로 숨기지 않는다. - 기존 앱 travelMode `festival`은 ML 서버 상태에 없으므로 `야경` 또는 기본 `산책`으로 fallback한다. diff --git a/docs/implementation/2026-07-01-server-api-integration-audit-plan.md b/docs/implementation/2026-07-01-server-api-integration-audit-plan.md index d96c167..302ae9c 100644 --- a/docs/implementation/2026-07-01-server-api-integration-audit-plan.md +++ b/docs/implementation/2026-07-01-server-api-integration-audit-plan.md @@ -58,7 +58,7 @@ Make the Soundlog frontend clearly use the deployed server when configured for s - If EC2 is reconfigured back to `USE_MOCK_DB=true`, the frontend cannot fix that alone; the deployed server environment must stay in real DB mode. - HTTP IP API is acceptable for dev/preview testing, but production builds require HTTPS by existing app config. -- If users need guest mode to show sample content while server is down, that should be a deliberate product fallback, not an accidental catch-all mock fallback in server mode. +- If users need sample content while server is down, that should be a deliberate authenticated fallback, not an accidental catch-all mock fallback in server mode. ## 2026-07-01 execution notes diff --git a/docs/implementation/2026-07-02-first-party-login-plan.md b/docs/implementation/2026-07-02-first-party-login-plan.md index ece56f7..11656d7 100644 --- a/docs/implementation/2026-07-02-first-party-login-plan.md +++ b/docs/implementation/2026-07-02-first-party-login-plan.md @@ -2,7 +2,7 @@ ## Goal -Remove Apple/Kakao login from Soundlog and make the app use a first-party email/password account flow only, while preserving guest browsing and the existing Soundlog access/refresh token session model. +Remove Apple/Kakao login from Soundlog and make the app use a first-party email/password account flow only, while preserving the existing Soundlog access/refresh token session model. ## Contract @@ -20,7 +20,7 @@ Remove Apple/Kakao login from Soundlog and make the app use a first-party email/ - The form shows pending state while submitting. - Validation errors stay local when possible. - Server/auth failures render in the existing login error area. - - Guest browsing remains available. + - App browsing requires an authenticated session. ## Implementation @@ -44,7 +44,7 @@ Remove Apple/Kakao login from Soundlog and make the app use a first-party email/ 1. Replace Apple/Kakao buttons in `app/auth/login.tsx` with an email/password form. 2. Add a segmented login/signup mode. 3. Update `src/types/auth.ts`, `src/api/authApi.ts`, `src/api/authQueries.ts`, and `src/mock-server/authHandlers.ts` for first-party login/register requests. -4. Keep `continue as guest`. +4. Remove unauthenticated browsing from the login entry point. 5. Update account provider labels to show a Soundlog account rather than Apple/Kakao. 6. Remove native social login config/dependencies from the primary app config path. diff --git a/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md b/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md index 5846dc0..16d9a1c 100644 --- a/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md +++ b/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md @@ -17,7 +17,7 @@ Make the app behavior honest and testable: - `/api/soundlog/v1/home/mood-recommendations` - `/api/soundlog/v1/playlists/{id}` - The app no longer imports `mockServer` from API facades; the web export check fails if mock handlers are bundled again. -- Main server-backed read APIs use server mode, but authenticated APIs intentionally return empty/no-op values when the user is a guest or has no token. +- Main server-backed read APIs use server mode and require a valid Soundlog access token. - Current playback UI is misleading: `playerStore.setTrack()` sets `isPlaying: true` even when no preview or stream exists. - Spotify auth/playback code remains in the app even though streaming will be removed. diff --git a/docs/implementation/2026-07-05-internal-music-ui-plan.md b/docs/implementation/2026-07-05-internal-music-ui-plan.md index 5fde8cb..9332ae7 100644 --- a/docs/implementation/2026-07-05-internal-music-ui-plan.md +++ b/docs/implementation/2026-07-05-internal-music-ui-plan.md @@ -1,17 +1,21 @@ # Internal music UI policy +> Superseded: 2026-07 현재 제품 방향은 `docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md` +> 및 `docs/product/SOUNDLOG_APP_PLANNING.md`의 "외부 음악 링크 + 기록/Recap" 정책을 따른다. +> 아래 내용은 인앱 재생/Spotify 제어 제거 과정에서 검토한 과거안이며, 현재 자동 검증은 +> Spotify OAuth/재생 제어와 가짜 playback 이벤트만 금지하고 외부 검색 링크는 허용한다. + ## Context -Music actions must stay inside SoundLog. The app does not stream audio and should not send users to YouTube Music, Melon, Spotify, or generated search URLs from the main recommendation, playlist, library, or mini-player flows. +Music actions must not imply in-app streaming or remote playback control. The current MVP may send users to external search/deep links such as Spotify, YouTube Music, YouTube, or Melon while keeping the Soundlog core value in selection, recording, and Recap. ## UI contract - Tapping a recommended track selects it as the current SoundLog music context. -- The mini player shows the selected track as a SoundLog context panel, not as playback or an external link panel. +- The mini player shows the selected track as a SoundLog context panel and may offer explicit external-link actions. - The full music panel offers SoundLog-native actions: previous/next recommendation, like, save, and moment capture with the selected track. -- Track action menus should not include external music app actions. -- Server-provided `externalUrl` and `platformUrls` must be stripped from frontend runtime track state. -- Web export and deployed-web checks must fail if YouTube Music URLs or external platform open helpers are bundled. +- Track action menus may include external music app/search actions, but must not expose play/pause/skip controls unless a real playback integration is implemented. +- Web export and deployed-web checks must fail if Spotify OAuth, playback helpers, fake playback events, or mock API handlers are bundled. ## Verification diff --git a/docs/product/SOUNDLOG_APP_PLANNING.md b/docs/product/SOUNDLOG_APP_PLANNING.md index 60388e4..0813937 100644 --- a/docs/product/SOUNDLOG_APP_PLANNING.md +++ b/docs/product/SOUNDLOG_APP_PLANNING.md @@ -81,7 +81,7 @@ Music Log는 여행 중 선택하거나 저장한 음악을 장소와 함께 보 ### 5.1 온보딩 및 초기 취향 수집 -온보딩은 추천 품질을 높이기 위한 초기 데이터 수집 단계이다. 사용자는 자체 계정 로그인 또는 둘러보기 후 위치 기반 추천 여부를 설정하고, 음악 취향과 여행 성향을 입력한다. +온보딩은 서비스 개념을 이해하고 추천 품질에 필요한 최소 입력을 수집하는 단계이다. 현재 MVP에서는 게스트 사용을 제공하지 않고, 사용자가 Soundlog 계정으로 로그인한 뒤 위치 기반 추천 여부와 음악 취향, 여행 성향을 입력한다. 수집 항목은 다음과 같다. @@ -303,7 +303,7 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 ### P-01 온보딩 -서비스의 핵심 가치인 “음악으로 기록하는 여행”을 전달하고, 가입 전 둘러보기를 제공한다. +서비스의 핵심 가치인 “음악으로 기록하는 여행”을 전달하고, 주요 기능은 Soundlog 계정 로그인 이후 사용할 수 있게 한다. ### P-02 취향 설문 / 권한 연동 diff --git a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md index 18f47b3..787cac5 100644 --- a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md +++ b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md @@ -217,7 +217,7 @@ Recap은 Soundlog의 최종 보상이므로 기능적으로만 맞는 화면이 | 와이어프레임 | 기능 및 화면 명세 | | --- | --- | -|
┌────────────────────┐
│ Soundlog │
│ 지금 장소의 음악을 │
│ 여행 앨범으로 │
│ │
│ [예시 Recap 이미지]│
│ │
│ 시작하기 │
│ 둘러보기 │
└────────────────────┘
| **사용자 목표:** 서비스가 음악 재생 앱이 아니라 여행 사운드트랙 로그 앱이라는 점을 이해한다.

**진입:** 첫 실행, 로그아웃 상태.

**나가기:** 취향/권한 설정 또는 샘플 홈.

**핵심 문구:** "음악은 외부 앱에서 듣고, Soundlog에는 여행의 사운드트랙을 남겨요."

**상태:** 첫 실행 여부, 로그인 여부, 온보딩 완료 여부.

**예외:** 네트워크가 없어도 샘플 이미지와 둘러보기는 가능해야 한다. | +|
┌────────────────────┐
│ Soundlog │
│ 지금 장소의 음악을 │
│ 여행 앨범으로 │
│ │
│ [예시 Recap 이미지]│
│ │
│ 로그인하고 시작하기│
│ 계정 만들기 │
└────────────────────┘
| **사용자 목표:** 서비스가 음악 재생 앱이 아니라 여행 사운드트랙 로그 앱이라는 점을 이해하고 계정 기반 저장 구조를 이해한다.

**진입:** 첫 실행, 로그아웃 상태.

**나가기:** 로그인/가입 후 취향·권한 설정.

**핵심 문구:** "음악은 외부 앱에서 듣고, Soundlog에는 여행의 사운드트랙을 계정에 남겨요."

**상태:** 첫 실행 여부, 로그인 여부, 온보딩 완료 여부.

**예외:** 네트워크가 없어도 샘플 Recap 이미지는 볼 수 있지만 홈/기록/Recap 기능은 로그인 후 사용할 수 있다. | ### P-02. 취향/권한 설정 @@ -501,7 +501,7 @@ POST /v1/community/reports - 위치는 MVP에서 foreground 기준으로만 사용한다. - 카메라는 순간 기록 진입 시점에 요청한다. - 사진 저장 권한은 Recap 이미지 저장 시점에 요청한다. -- 로그아웃 상태에서도 로컬 기록은 가능하게 하고, 로그인 후 동기화할 수 있게 설계한다. +- 로그아웃 상태에서는 앱 주요 기능을 사용하지 못하게 하고, 로그인 후 로컬에 남아 있던 과거 기록이 있으면 서버 동기화를 시도한다. --- diff --git a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html index f71eff1..166f030 100644 --- a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html +++ b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html @@ -1109,8 +1109,8 @@

지금 장소의 음악을 여행 앨범으로

Recap preview
album cover / film / LP
-
시작하기
-
둘러보기
+
로그인하고 시작하기
+
계정 만들기 또는 로그인
@@ -1124,7 +1124,7 @@

기능 설명

진입/이탈 -

첫 실행 또는 로그아웃 상태에서 진입하고, 취향 설정 또는 샘플 홈으로 이동합니다.

+

첫 실행 또는 로그아웃 상태에서 진입하고, 로그인 후 취향 설정으로 이동합니다.

상태 @@ -1132,7 +1132,7 @@

기능 설명

예외 -

네트워크가 없어도 샘플 Recap 이미지를 보여주고 둘러보기로 진입할 수 있어야 합니다.

+

네트워크가 없어도 샘플 Recap 이미지는 보여주되, 주요 기능 진입은 로그인 이후로 제한합니다.

핵심 카피: "외부 앱에서 듣고 Soundlog에는 기록한다"를 첫 화면에서 분명히 말합니다.
diff --git a/package-lock.json b/package-lock.json index e2a17c6..c21a450 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,21 +11,22 @@ "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "2.2.0", "@tanstack/react-query": "^5.100.14", - "expo": "~56.0.12", - "expo-build-properties": "~56.0.19", + "expo": "~56.0.15", + "expo-build-properties": "~56.0.22", "expo-camera": "~56.0.8", "expo-constants": "~56.0.15", - "expo-dev-client": "~56.0.20", + "expo-dev-client": "~56.0.22", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-image": "~56.0.11", + "expo-image-picker": "~56.0.20", "expo-linear-gradient": "~56.0.4", - "expo-linking": "~56.0.14", - "expo-location": "~56.0.18", - "expo-media-library": "~56.0.7", - "expo-router": "~56.2.11", + "expo-linking": "~56.0.15", + "expo-location": "~56.0.20", + "expo-media-library": "~56.0.9", + "expo-router": "~56.2.14", "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.18", + "expo-sharing": "~56.0.21", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", "ol": "^10.9.0", @@ -1225,221 +1226,6 @@ "integrity": "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==", "license": "MIT AND Apache-2.0" }, - "node_modules/@expo/cli": { - "version": "56.1.16", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz", - "integrity": "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==", - "license": "MIT", - "dependencies": { - "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~56.0.9", - "@expo/config-plugins": "~56.0.9", - "@expo/devcert": "^1.2.1", - "@expo/env": "~2.3.0", - "@expo/image-utils": "^0.10.1", - "@expo/inline-modules": "^0.0.12", - "@expo/json-file": "^10.2.0", - "@expo/log-box": "^56.0.13", - "@expo/metro": "~56.0.0", - "@expo/metro-config": "~56.0.14", - "@expo/metro-file-map": "^56.0.3", - "@expo/osascript": "^2.6.0", - "@expo/package-manager": "^1.12.1", - "@expo/plist": "^0.7.0", - "@expo/prebuild-config": "^56.0.16", - "@expo/require-utils": "^56.1.3", - "@expo/router-server": "^56.0.14", - "@expo/schema-utils": "^56.0.0", - "@expo/spawn-async": "^1.8.0", - "@expo/ws-tunnel": "^2.0.0", - "@expo/xcpretty": "^4.4.4", - "@react-native/dev-middleware": "0.85.3", - "accepts": "^1.3.8", - "arg": "^5.0.2", - "bplist-creator": "0.1.0", - "bplist-parser": "^0.3.1", - "chalk": "^4.0.0", - "ci-info": "^3.3.0", - "compression": "^1.7.4", - "connect": "^3.7.0", - "debug": "^4.3.4", - "dnssd-advertise": "^1.1.4", - "expo-server": "^56.0.5", - "fetch-nodeshim": "^0.4.10", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "lan-network": "^0.2.1", - "multitars": "^1.0.0", - "node-forge": "^1.3.3", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "picomatch": "^4.0.4", - "pretty-format": "^29.7.0", - "progress": "^2.0.3", - "prompts": "^2.3.2", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "send": "^0.19.0", - "slugify": "^1.3.4", - "stacktrace-parser": "^0.1.10", - "structured-headers": "^0.4.1", - "terminal-link": "^2.1.1", - "toqr": "^0.1.1", - "wrap-ansi": "^7.0.0", - "ws": "^8.12.1", - "zod": "^3.25.76" - }, - "bin": { - "expo-internal": "main.js" - }, - "peerDependencies": { - "expo": "*", - "expo-router": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "expo-router": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.14.tgz", - "integrity": "sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "@expo/metro-runtime": "^56.0.15", - "expo": "*", - "expo-constants": "^56.0.18", - "expo-font": "^56.0.6", - "expo-router": "*", - "expo-server": "^56.0.5", - "react": "*", - "react-dom": "*", - "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" - }, - "peerDependenciesMeta": { - "@expo/metro-runtime": { - "optional": true - }, - "expo-router": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-server-dom-webpack": { - "optional": true - } - } - }, - "node_modules/@expo/cli/node_modules/@expo/ws-tunnel": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", - "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", - "license": "MIT", - "peerDependencies": { - "ws": "^8.0.0" - } - }, - "node_modules/@expo/cli/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@expo/cli/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/cli/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@expo/cli/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@expo/cli/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@expo/cli/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@expo/cli/node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -1450,15 +1236,15 @@ } }, "node_modules/@expo/config": { - "version": "56.0.9", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", - "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "version": "56.0.11", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.11.tgz", + "integrity": "sha512-Rt3U9Rqr6midz/sDeubFN5fefR0KzHpcEEgAUnV98XsLSSTnfzIxoC1blICb7pUUscKc8TtlVyn7/Gita2wnOQ==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~56.0.8", - "@expo/config-types": "^56.0.5", + "@expo/config-plugins": "~56.0.11", + "@expo/config-types": "^56.0.7", "@expo/json-file": "^10.2.0", - "@expo/require-utils": "^56.1.3", + "@expo/require-utils": "^56.1.4", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -1468,15 +1254,15 @@ } }, "node_modules/@expo/config-plugins": { - "version": "56.0.9", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", - "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", + "version": "56.0.12", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.12.tgz", + "integrity": "sha512-UKjPEOkvxBzTvjghmjUECURDoJLPJIFIevB0JQCe8l9Fg4yfy2fabU5LU3Kfrmmu1/8et/93bucCnABEmoxYEw==", "license": "MIT", "dependencies": { - "@expo/config-types": "^56.0.6", + "@expo/config-types": "^56.0.7", "@expo/json-file": "~10.2.0", "@expo/plist": "^0.7.0", - "@expo/require-utils": "^56.1.3", + "@expo/require-utils": "^56.1.4", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -1489,9 +1275,9 @@ } }, "node_modules/@expo/config-types": { - "version": "56.0.6", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", - "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==", + "version": "56.0.7", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.7.tgz", + "integrity": "sha512-V7bxawNsNned/yMppAHdisIOxniZXgPKRWpIUiQOQBs45/A5MBd2gfDM4Ecq5gnbilnQUTaI6Zxn6JcW7L3TAA==", "license": "MIT" }, "node_modules/@expo/devcert": { @@ -1535,9 +1321,9 @@ } }, "node_modules/@expo/dom-webview": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-56.0.5.tgz", - "integrity": "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew==", + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-56.0.6.tgz", + "integrity": "sha512-DQY3Tj5nrPbpIfEQiD9xbyEFTu25yEORa02Eyzl5rXszDzxDT4VkZDHkyErNQOyE1qjublJHNFhE5bDm4tRfqA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -1546,9 +1332,9 @@ } }, "node_modules/@expo/env": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", - "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.1.tgz", + "integrity": "sha512-3c9Mg9x0HmGPEsVrGAGyEDJsNUOZ55cZvZ47/HLmXh7MHV9Zv7My73wThklKrObaBBoMfE4YqpKjYKDRzojpjQ==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -1566,12 +1352,12 @@ "license": "MIT" }, "node_modules/@expo/fingerprint": { - "version": "0.19.4", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.4.tgz", - "integrity": "sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A==", + "version": "0.19.7", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.7.tgz", + "integrity": "sha512-Q04NyJE0E7qKGXepBjI8e0p983RrQGBWJcSICKyyLczsr5JhNuSmqw604aL7koaXG2ctrUL36qd332XiMS/s6w==", "license": "MIT", "dependencies": { - "@expo/env": "^2.3.0", + "@expo/env": "^2.3.1", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", @@ -1588,12 +1374,12 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz", - "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.2.tgz", + "integrity": "sha512-qQUGaacqXduoFTCUQAMceIWYzlKU0xX4/BKTyh8TdVj0uHv9/W3MfHOy5yXoOqBVrSccXk++Gm4D/NfS5HnCNA==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^56.1.3", + "@expo/require-utils": "^56.1.4", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", @@ -1603,12 +1389,12 @@ } }, "node_modules/@expo/inline-modules": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.12.tgz", - "integrity": "sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==", + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.13.tgz", + "integrity": "sha512-26RllWesRmYsAAo70cRcR9DaqXPKJct9MIGxZteS+Tkg25ljOkFeG7fYEsSazZOLpAslKBiilqtZykVYrxSzCw==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~56.0.9" + "@expo/config-plugins": "~56.0.11" } }, "node_modules/@expo/json-file": { @@ -1622,27 +1408,27 @@ } }, "node_modules/@expo/local-build-cache-provider": { - "version": "56.0.8", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.8.tgz", - "integrity": "sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==", + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.9.tgz", + "integrity": "sha512-VMJC5ul7dXPfD2OO16ObPi6ym4H8vlnCBUuCM/KeKh0OvLRub35X8OB2uYrFQ+vbWkPemAEOWvk0oOptpPxbzA==", "license": "MIT", "dependencies": { - "@expo/config": "~56.0.9", + "@expo/config": "~56.0.11", "chalk": "^4.1.2" } }, "node_modules/@expo/log-box": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-56.0.13.tgz", - "integrity": "sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ==", + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-56.0.14.tgz", + "integrity": "sha512-3Eadcxz0J2NESG2iKe8DnAggsRCWqY0mBLhgQPNzClPVjS6o4NyD0F8kuOvWNngFwpJBC08HhY2o8P1mgSgeJg==", "license": "MIT", "dependencies": { - "@expo/dom-webview": "^56.0.5", + "@expo/dom-webview": "^56.0.6", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { - "@expo/dom-webview": "^56.0.5", + "@expo/dom-webview": "^56.0.6", "expo": "*", "react": "*", "react-native": "*" @@ -1671,19 +1457,19 @@ } }, "node_modules/@expo/metro-config": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.14.tgz", - "integrity": "sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA==", + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.16.tgz", + "integrity": "sha512-mxPZ23exC6kkEpPYQOteamaiWYER3uDk2IqKys7EJwtIKc3F1207Xtsdko/DNNG04DLwpN/WM2UlNl+Ak8uPRg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~56.0.9", - "@expo/env": "~2.3.0", + "@expo/config": "~56.0.11", + "@expo/env": "~2.3.1", "@expo/json-file": "~10.2.0", "@expo/metro": "~56.0.0", - "@expo/require-utils": "^56.1.3", + "@expo/require-utils": "^56.1.4", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", @@ -1709,6 +1495,20 @@ } } }, + "node_modules/@expo/metro-config/node_modules/@expo/env": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.1.tgz", + "integrity": "sha512-JvBdZa5OkTd+dGEtt3fLW9OF6RlIp9SNu9VyMSiWPV5szMDTSAYbX8Uj3cRvZcU1zzaRbqnTSsHk2AE8WXHfxQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, "node_modules/@expo/metro-config/node_modules/hermes-estree": { "version": "0.33.3", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", @@ -1725,9 +1525,9 @@ } }, "node_modules/@expo/metro-config/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -1751,19 +1551,19 @@ } }, "node_modules/@expo/metro-runtime": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.15.tgz", - "integrity": "sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==", + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.16.tgz", + "integrity": "sha512-UsKrBLOCa+kAVHW1S0HdGkM0hkiQXeh3fZIpjcriOmoNg/CVRgdKQhUxUkNoXAoPfT85uW1x2fzNbiYhPQO1SA==", "license": "MIT", "dependencies": { - "@expo/log-box": "^56.0.13", + "@expo/log-box": "^56.0.14", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { - "@expo/log-box": "^56.0.13", + "@expo/log-box": "^56.0.14", "expo": "*", "react": "*", "react-dom": "*", @@ -1776,9 +1576,9 @@ } }, "node_modules/@expo/osascript": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", - "integrity": "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz", + "integrity": "sha512-wKIXL8UtbuX4KwavPasIW3CUcgTbYfjzLcgUhjyKUAYDEqMaf6gmU1bqz3ffBPTokmX+G8/vFG1ZuI9etQWukA==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.8.0" @@ -1788,12 +1588,12 @@ } }, "node_modules/@expo/package-manager": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.1.tgz", - "integrity": "sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.0.tgz", + "integrity": "sha512-s3W3eZafJDEyVL7W/jxj2Nz3eONKxSCU604S5xj8ijrVaRz83x0DnZznLf/UXQEI1w+FyibH68nHeQyk767b1A==", "license": "MIT", "dependencies": { - "@expo/json-file": "^10.2.0", + "@expo/json-file": "^11.0.0", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", @@ -1801,6 +1601,16 @@ "resolve-workspace-root": "^2.0.0" } }, + "node_modules/@expo/package-manager/node_modules/@expo/json-file": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.0.tgz", + "integrity": "sha512-pHJCETqFL5x5BzNV6cEPwjwuECgGmnl0bNmfHIJ6LM1tlh2eVXi5HEdit3zby/JO/B8Otk5cgcqtJXgvvUat3A==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, "node_modules/@expo/plist": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", @@ -1813,27 +1623,27 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "56.0.16", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.16.tgz", - "integrity": "sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.19.tgz", + "integrity": "sha512-aP/7kGDPMmwY9C9cYMK4MfUiZk23KhN4AfnKz0eeRyg3Izw0OGdtzNDLIcchnqpDAwHsI0+LUHZhucGl1jQJ5A==", "license": "MIT", "dependencies": { - "@expo/config": "~56.0.9", - "@expo/config-plugins": "~56.0.9", - "@expo/config-types": "^56.0.6", - "@expo/image-utils": "^0.10.1", + "@expo/config": "~56.0.11", + "@expo/config-plugins": "~56.0.12", + "@expo/config-types": "^56.0.7", + "@expo/image-utils": "^0.10.2", "@expo/json-file": "^10.2.0", "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.1", - "expo-modules-autolinking": "~56.0.16", + "expo-modules-autolinking": "~56.0.19", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "node_modules/@expo/require-utils": { - "version": "56.1.3", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", - "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "version": "56.1.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.4.tgz", + "integrity": "sha512-IX7XXg9obnrH3ni0TClBbVKgqk0nT0bjTEPTBtbD+WgIeZ0hpcgwxMJH3j5uBsUqEAq1jqUAkJWrPcg7ZRSV7g==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -1850,9 +1660,9 @@ } }, "node_modules/@expo/schema-utils": { - "version": "56.0.1", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.1.tgz", - "integrity": "sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w==", + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.2.tgz", + "integrity": "sha512-WcOH1E6rwxRqNwBzPOVhd5GBbMlS04+5n+ZMdhdwKOg/Kt3suV+F8F6An+z8mUJ8eSuMM3HD9Sj09t7vc/Xjkw==", "license": "MIT" }, "node_modules/@expo/sdk-runtime-versions": { @@ -1880,9 +1690,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-56.0.18.tgz", - "integrity": "sha512-2XgH5obigGtXm8zlb/V3g87NSiIcBcJ1xoQOEQYPoExL1DCNsHzaIecTh1XG/f/45ardo4OZNJwpbfYJ9X3qrQ==", + "version": "56.0.21", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-56.0.21.tgz", + "integrity": "sha512-DosrgEiDqxc/tPH39/Nf5BmVxdeJ616xk/ihu7CJ7Xuzkr79E6/fp4IA15KzBiTdJeH3id1adhIwj8RX1jnh6A==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -1894,7 +1704,6 @@ "react": "*", "react-dom": "*", "react-native": "*", - "react-native-reanimated": "*", "react-native-worklets": "*" }, "peerDependenciesMeta": { @@ -1904,9 +1713,6 @@ "react-dom": { "optional": true }, - "react-native-reanimated": { - "optional": true - }, "react-native-worklets": { "optional": true } @@ -2152,21 +1958,21 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", - "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz", + "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", + "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.12", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.7", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", @@ -2188,9 +1994,9 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", + "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", "license": "MIT" }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { @@ -2209,9 +2015,9 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", + "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2242,9 +2048,9 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", + "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" @@ -2265,9 +2071,9 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", - "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.3.0" @@ -2355,16 +2161,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", - "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", + "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", + "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-escape-keydown": "1.1.2" + "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", @@ -2382,9 +2188,9 @@ } }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", + "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", "license": "MIT" }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-compose-refs": { @@ -2403,9 +2209,9 @@ } }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", - "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.3.0" @@ -2440,6 +2246,39 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", @@ -2456,13 +2295,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", - "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", + "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { @@ -2496,9 +2335,9 @@ } }, "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", - "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.3.0" @@ -2552,12 +2391,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", - "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", + "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { @@ -2576,9 +2415,9 @@ } }, "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", - "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.3.0" @@ -2776,47 +2615,14 @@ } } }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", - "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3463,6 +3269,18 @@ "node": ">= 14" } }, + "node_modules/agent-cli-detector": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.2.tgz", + "integrity": "sha512-qdZ/9JFORtTKJNhT/IczMeEfEUbUU0K5umYeiIQHX+AjHs+Y9SXVzSgaYlpZeyNMrvuh2HpZiOTpvS57iPfBkQ==", + "license": "MIT", + "bin": { + "agent-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3810,9 +3628,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.15.tgz", - "integrity": "sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==", + "version": "56.0.17", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.17.tgz", + "integrity": "sha512-yanAlbzaMMNqnju/uMnZsf3ltgssG71UubkfD+2iMaoXGNIj+TODEs9hF12jrTX2lzq/zH0HWSuRLZLNalf8fw==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -3861,7 +3679,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^56.0.18", + "expo-widgets": "^56.0.22", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -4697,31 +4515,31 @@ } }, "node_modules/expo": { - "version": "56.0.12", - "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.12.tgz", - "integrity": "sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==", + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.15.tgz", + "integrity": "sha512-Tnas9Sq1fDY865rhSQ4266Kd4GULEUyBEBEchbNLQsiY6U+AAt4MgCKJUsRvUkNHdaZwnGzexy6hdnOjsgvNcA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^56.1.16", - "@expo/config": "~56.0.9", - "@expo/config-plugins": "~56.0.9", + "@expo/cli": "^56.1.19", + "@expo/config": "~56.0.11", + "@expo/config-plugins": "~56.0.12", "@expo/devtools": "~56.0.2", - "@expo/dom-webview": "~56.0.5", - "@expo/fingerprint": "^0.19.4", - "@expo/local-build-cache-provider": "^56.0.8", - "@expo/log-box": "^56.0.13", + "@expo/dom-webview": "~56.0.6", + "@expo/fingerprint": "^0.19.7", + "@expo/local-build-cache-provider": "^56.0.9", + "@expo/log-box": "^56.0.14", "@expo/metro": "~56.0.0", - "@expo/metro-config": "~56.0.14", + "@expo/metro-config": "~56.0.16", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~56.0.15", - "expo-asset": "~56.0.17", - "expo-constants": "~56.0.18", + "babel-preset-expo": "~56.0.17", + "expo-asset": "~56.0.19", + "expo-constants": "~56.0.20", "expo-file-system": "~56.0.8", "expo-font": "~56.0.7", "expo-keep-awake": "~56.0.3", - "expo-modules-autolinking": "~56.0.16", - "expo-modules-core": "~56.0.17", + "expo-modules-autolinking": "~56.0.19", + "expo-modules-core": "~56.0.20", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -4759,13 +4577,13 @@ } }, "node_modules/expo-asset": { - "version": "56.0.17", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.17.tgz", - "integrity": "sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.19.tgz", + "integrity": "sha512-huGY0bVfYUivNOir+iUEjjW9IbNHzLFNo8d6FGh22u1OsXcFR7Za3vpu5V1gMp0dc31wiqmsXkTazjm+Rro3vA==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.10.1", - "expo-constants": "~56.0.18" + "@expo/image-utils": "^0.10.2", + "expo-constants": "~56.0.20" }, "peerDependencies": { "expo": "*", @@ -4774,12 +4592,12 @@ } }, "node_modules/expo-build-properties": { - "version": "56.0.19", - "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.19.tgz", - "integrity": "sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==", + "version": "56.0.22", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.22.tgz", + "integrity": "sha512-HbWHkpHUJxorDO/7FKtniQVsbylkIW+h1/wIJCNNGF5WAm+VBrF2M9Jnm+Uho5BFQBKJ/QMynyfxr6Ans9UrpQ==", "license": "MIT", "dependencies": { - "@expo/schema-utils": "^56.0.0", + "@expo/schema-utils": "^56.0.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, @@ -4808,26 +4626,40 @@ } }, "node_modules/expo-constants": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz", - "integrity": "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==", + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.20.tgz", + "integrity": "sha512-4HgoVvUiMvcqujr/CUj7L1tumLWj8WX0PLM77OriDPoaJrMEwUHd841m4o4IoUyMPVT8XTiTiPFwJtGali8+9Q==", "license": "MIT", "dependencies": { - "@expo/env": "~2.3.0" + "@expo/env": "~2.3.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, + "node_modules/expo-constants/node_modules/@expo/env": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.1.tgz", + "integrity": "sha512-JvBdZa5OkTd+dGEtt3fLW9OF6RlIp9SNu9VyMSiWPV5szMDTSAYbX8Uj3cRvZcU1zzaRbqnTSsHk2AE8WXHfxQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, "node_modules/expo-dev-client": { - "version": "56.0.20", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.20.tgz", - "integrity": "sha512-KebW4r8HhIiRrPzs6ZqVhp/so8buyglAO1h4No0Ibr5C2XRnlIoGWCN4zC6rW7IsI3iKUXcofLAQV9OjoxjiwQ==", + "version": "56.0.22", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.22.tgz", + "integrity": "sha512-LKhgIlMu8DkmhTtfurpX9YMPsnv4QI1hCcDZqcFn+jlB+s6yE1THB9UWZ+BVtef8kYMzsw+BH3ClXvNkRd0vXw==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "~56.0.20", - "expo-dev-menu": "~56.0.17", + "expo-dev-launcher": "~56.0.23", + "expo-dev-menu": "~56.0.19", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" @@ -4837,13 +4669,13 @@ } }, "node_modules/expo-dev-launcher": { - "version": "56.0.20", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.20.tgz", - "integrity": "sha512-cTuC3GkPl9CTwO3CKnVmEm9qoQ0WairhwvTh6qMlg+zr/QU/tdiU++uDBX67hf9+FuxQOkWGp5khFNosT+0cIg==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.23.tgz", + "integrity": "sha512-OKgLAn6m15Lu+Qyg8WA0HPurFR9pxhsrHUvvafv8pTZHspB0BbOvBwhOJdxgiMfR2LHq4032cnMf8164Vzqeng==", "license": "MIT", "dependencies": { - "@expo/schema-utils": "^56.0.0", - "expo-dev-menu": "~56.0.17", + "@expo/schema-utils": "^56.0.2", + "expo-dev-menu": "~56.0.19", "expo-manifests": "~56.0.4" }, "peerDependencies": { @@ -4852,9 +4684,9 @@ } }, "node_modules/expo-dev-menu": { - "version": "56.0.17", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.17.tgz", - "integrity": "sha512-OofRkOOZnaDriSav3JDN4NP2lsLt2eOa/Ryptr5nMD62SwnFyK4R6n6PkPVaDU3LSsZqndAJHmN6inS+oziayQ==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.19.tgz", + "integrity": "sha512-sm6dfnkq3lgbn39Kd4yjFFm7UQAZqm6Hn3OpjagGtWBoWByQae3nKh1MF5K4Peh172TqUPJTOhjRasnZSkUkOQ==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "~56.0.0" @@ -4928,6 +4760,27 @@ } } }, + "node_modules/expo-image-loader": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-56.0.3.tgz", + "integrity": "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.20.tgz", + "integrity": "sha512-75xP7WA7Z8b4+5wYCu1cISg90Cu9CowneGMFuAgmmpNxCXLnBbrJXO6XqYIM69KAjEv7PApjgQF3h6RdBqus4w==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~56.0.3" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-json-utils": { "version": "56.0.0", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-56.0.0.tgz", @@ -4946,12 +4799,12 @@ } }, "node_modules/expo-linking": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.14.tgz", - "integrity": "sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ==", + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.15.tgz", + "integrity": "sha512-V3tkCxHIWO624ayw2L5Nfi0/bPgYy/0MgmoQR7g3FAKF4nyAayK8IhgAInz4MZ5Ao5Bb+5uZWDdUaS4yyEb+Gw==", "license": "MIT", "dependencies": { - "expo-constants": "~56.0.18", + "expo-constants": "~56.0.20", "invariant": "^2.2.4" }, "peerDependencies": { @@ -4960,12 +4813,12 @@ } }, "node_modules/expo-location": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.18.tgz", - "integrity": "sha512-6xP0UwGy8a7EEHAMeigYAp3HNo3yWHAg05tVPUfwrOWepWPpFXmjsfUBUxQdkpfpjddJ9r+f4PplxZqKI0LtjA==", + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.20.tgz", + "integrity": "sha512-yQhkHgjW47dz7cbRg1UcsGWuWNQ1Yh+nEqmZkAdFdDVw6PXuxV63c8VNyXBzvuOgNuBd2TSstSuOb0t3tFJJZg==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.10.1" + "@expo/image-utils": "^0.10.2" }, "peerDependencies": { "expo": "*" @@ -4984,9 +4837,9 @@ } }, "node_modules/expo-media-library": { - "version": "56.0.7", - "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-56.0.7.tgz", - "integrity": "sha512-lVsXsagjDYuySr+WWvc6BfRHTXttnWeVX4qMP2ls7JyAVSECQOAQpqz47uZiJsEohtpCoDTLRL2mcn/XY8fLoQ==", + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-56.0.9.tgz", + "integrity": "sha512-SD92CiE/UPXpDmvSrOWtMX12TeGhN4w9ZdePNNxa43bC9uTnEAS3YTLAkNaFHHbCSUlWSNVLW4yPZhNgugAO/g==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -4994,12 +4847,12 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "56.0.16", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.16.tgz", - "integrity": "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.19.tgz", + "integrity": "sha512-ztzTzS21fbq4oJQItSgT+WWJMZXGsC0GdX/cDN/GBhCTPiZz8foHkVa14DiLa5CWd7pEZ9EeSMdHDcAEc2dZ5Q==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^56.1.3", + "@expo/require-utils": "^56.1.4", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" @@ -5009,13 +4862,13 @@ } }, "node_modules/expo-modules-core": { - "version": "56.0.17", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.17.tgz", - "integrity": "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ==", + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.20.tgz", + "integrity": "sha512-Xagwt/gC6sV1jiSnlFrYxLooZr90adLrpV64YlkXZjgZ6Kot89FF62e457RyejLtyDm2pOdy6FZphF1ReyK2Jw==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", - "expo-modules-jsi": "~56.0.10", + "expo-modules-jsi": "~56.0.12", "invariant": "^2.2.4" }, "peerDependencies": { @@ -5030,24 +4883,24 @@ } }, "node_modules/expo-modules-jsi": { - "version": "56.0.10", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.10.tgz", - "integrity": "sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA==", + "version": "56.0.12", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.12.tgz", + "integrity": "sha512-OnXiNbXzYybZPh5QnJyIIwQjQfN7PP4Di/2Hz0xQHKLgQmCfn/zAQziOjbUXVeL3cAyZ8+uDnTDYmDAc3B2qhQ==", "license": "MIT", "peerDependencies": { "react-native": "*" } }, "node_modules/expo-router": { - "version": "56.2.11", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.11.tgz", - "integrity": "sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ==", + "version": "56.2.14", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.14.tgz", + "integrity": "sha512-zhJ0YgMxzosxPVTqPJTzTwOCvY06+uFJSW93vFYpF+AyMSZ/DCT+iQew0bwGky6dmWrrIjLBbZ/bv/CdaEdAEg==", "license": "MIT", "dependencies": { - "@expo/log-box": "^56.0.13", - "@expo/metro-runtime": "^56.0.15", - "@expo/schema-utils": "^56.0.0", - "@expo/ui": "^56.0.18", + "@expo/log-box": "^56.0.14", + "@expo/metro-runtime": "^56.0.16", + "@expo/schema-utils": "^56.0.2", + "@expo/ui": "^56.0.21", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -5075,12 +4928,12 @@ "vaul": "^1.1.2" }, "peerDependencies": { - "@expo/log-box": "^56.0.13", - "@expo/metro-runtime": "^56.0.15", + "@expo/log-box": "^56.0.14", + "@expo/metro-runtime": "^56.0.16", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^56.0.18", - "expo-linking": "^56.0.14", + "expo-constants": "^56.0.20", + "expo-linking": "^56.0.15", "react": "*", "react-dom": "*", "react-native": "*", @@ -5167,13 +5020,13 @@ } }, "node_modules/expo-sharing": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.18.tgz", - "integrity": "sha512-45w4BWNFmdTczp+fJX6YfwJrn9sX+VeRWz2VWLhauygcCrym44HtVDXX5yVYPB9TW9ZesLcEI+CCrCBNWL7smQ==", + "version": "56.0.21", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.21.tgz", + "integrity": "sha512-X+NmHbjxF0jIfQRm14DlI9aJTwl2U6sVIEmVST4twFLekv3KHDUn2znzLZsEHOI/MskRevszfXP68rj1a8R4Xg==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "^56.0.9", - "@expo/config-types": "^56.0.6", + "@expo/config-plugins": "^56.0.12", + "@expo/config-types": "^56.0.7", "@expo/plist": "^0.7.0" }, "peerDependencies": { @@ -5218,6 +5071,173 @@ "expo": "*" } }, + "node_modules/expo/node_modules/@expo/cli": { + "version": "56.1.19", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.19.tgz", + "integrity": "sha512-k+oipwbu83WFnACtybvGZvDbbItarY4lfjkKgP9F5lml2ilvL/VvzsPhbhT7nRyFB3zPJq9SbNNKa45VDLoihA==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~56.0.11", + "@expo/config-plugins": "~56.0.12", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.3.1", + "@expo/image-utils": "^0.10.2", + "@expo/inline-modules": "^0.0.13", + "@expo/json-file": "^10.2.0", + "@expo/log-box": "^56.0.14", + "@expo/metro": "~56.0.0", + "@expo/metro-config": "~56.0.16", + "@expo/metro-file-map": "^56.0.3", + "@expo/osascript": "^2.6.0", + "@expo/package-manager": "^1.12.1", + "@expo/plist": "^0.7.0", + "@expo/prebuild-config": "^56.0.19", + "@expo/require-utils": "^56.1.4", + "@expo/router-server": "^56.0.16", + "@expo/schema-utils": "^56.0.2", + "@expo/spawn-async": "^1.8.0", + "@expo/ws-tunnel": "^2.0.0", + "@expo/xcpretty": "^4.4.4", + "@react-native/dev-middleware": "0.85.3", + "accepts": "^1.3.8", + "agent-cli-detector": "^0.1.2", + "arg": "^5.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "dnssd-advertise": "^1.1.6", + "expo-server": "^56.0.5", + "fetch-nodeshim": "^0.4.10", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "multitars": "^1.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.4", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "bin": { + "expo-internal": "main.js" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.16.tgz", + "integrity": "sha512-df5BI5GFnSKqAhuALTPIbuHisd4CUhuYam7pwF9Dt1D6BinPctsteWWDm/sEGXdLVhsub4NPI7tvUHo/HWL7aA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "@expo/metro-runtime": "^56.0.16", + "expo": "*", + "expo-constants": "^56.0.20", + "expo-font": "^56.0.7", + "expo-router": "*", + "expo-server": "^56.0.5", + "react": "*", + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/env": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.1.tgz", + "integrity": "sha512-JvBdZa5OkTd+dGEtt3fLW9OF6RlIp9SNu9VyMSiWPV5szMDTSAYbX8Uj3cRvZcU1zzaRbqnTSsHk2AE8WXHfxQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/expo/node_modules/@expo/ws-tunnel": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", + "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", + "license": "MIT", + "peerDependencies": { + "ws": "^8.0.0" + } + }, + "node_modules/expo/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "56.0.3", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz", @@ -5228,6 +5248,69 @@ "react": "*" } }, + "node_modules/expo/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/expo/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -5985,9 +6068,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 73535b6..3b58980 100644 --- a/package.json +++ b/package.json @@ -6,21 +6,22 @@ "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "2.2.0", "@tanstack/react-query": "^5.100.14", - "expo": "~56.0.12", - "expo-build-properties": "~56.0.19", + "expo": "~56.0.15", + "expo-build-properties": "~56.0.22", "expo-camera": "~56.0.8", "expo-constants": "~56.0.15", - "expo-dev-client": "~56.0.20", + "expo-dev-client": "~56.0.22", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-image": "~56.0.11", + "expo-image-picker": "~56.0.20", "expo-linear-gradient": "~56.0.4", - "expo-linking": "~56.0.14", - "expo-location": "~56.0.18", - "expo-media-library": "~56.0.7", - "expo-router": "~56.2.11", + "expo-linking": "~56.0.15", + "expo-location": "~56.0.20", + "expo-media-library": "~56.0.9", + "expo-router": "~56.2.14", "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.18", + "expo-sharing": "~56.0.21", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", "ol": "^10.9.0", diff --git a/scripts/check-api-origin.js b/scripts/check-api-origin.js index 8c31bff..2337705 100644 --- a/scripts/check-api-origin.js +++ b/scripts/check-api-origin.js @@ -2,6 +2,7 @@ const errors = []; const apiOrigin = (process.argv[2] || process.env.SOUNDLOG_API_ORIGIN || '').replace(/\/+$/, ''); +let authHeaderPromise; if (!apiOrigin) { console.error('Usage: npm run check:api-origin -- http://:4000'); @@ -16,16 +17,25 @@ function withOrigin(path) { return `${apiOrigin}${path.startsWith('/') ? path : `/${path}`}`; } -async function fetchText(path) { +function createSmokeCredentials() { + const email = + process.env.SOUNDLOG_CHECK_EMAIL || + `soundlog-check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@soundlog.test`; + const password = process.env.SOUNDLOG_CHECK_PASSWORD || 'soundlog-check-password'; + + return { email, password }; +} + +async function fetchText(path, options = {}) { const url = withOrigin(path); - const response = await fetch(url, { redirect: 'manual' }); + const response = await fetch(url, { ...options, redirect: 'manual' }); const text = await response.text(); return { response, text, url }; } -async function fetchJson(path) { - const { response, text, url } = await fetchText(path); +async function fetchJson(path, options) { + const { response, text, url } = await fetchText(path, options); if (!response.ok) { throw new Error(`${url} returned HTTP ${response.status}: ${text.slice(0, 180)}`); @@ -34,6 +44,53 @@ async function fetchJson(path) { return JSON.parse(text); } +async function postJson(path, body) { + return fetchJson(path, { + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }); +} + +async function createAuthHeader() { + const { email, password } = createSmokeCredentials(); + + if (process.env.SOUNDLOG_CHECK_EMAIL && process.env.SOUNDLOG_CHECK_PASSWORD) { + try { + const login = await postJson('/v1/auth/login', { email, password }); + return `Bearer ${login.data.accessToken}`; + } catch { + // The configured smoke account may not exist yet; try to create it once. + } + } + + const register = await postJson('/v1/auth/register', { + displayName: 'Soundlog API Check', + email, + password, + }); + + return `Bearer ${register.data.accessToken}`; +} + +async function getAuthHeader() { + authHeaderPromise ??= createAuthHeader(); + + return authHeaderPromise; +} + +async function fetchAuthenticatedJson(path) { + const authHeader = await getAuthHeader(); + + return fetchJson(path, { + headers: { + Authorization: authHeader, + }, + }); +} + async function verifyHealth() { try { const payload = await fetchJson('/v1/health'); @@ -64,7 +121,7 @@ async function verifyOpenApi() { async function verifyNearbyPlaces() { try { - const payload = await fetchJson( + const payload = await fetchAuthenticatedJson( '/v1/tour/nearby-places?lat=35.1595&lng=129.1604&radiusMeters=2000&limit=1', ); const place = payload?.data?.[0]; @@ -88,7 +145,7 @@ async function verifyNearbyPlaces() { async function verifyMusicMetadata() { try { - const payload = await fetchJson( + const payload = await fetchAuthenticatedJson( '/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', ); const serialized = JSON.stringify(payload?.data ?? {}); diff --git a/scripts/check-deployed-web.js b/scripts/check-deployed-web.js index 2527ed8..cea7b2e 100644 --- a/scripts/check-deployed-web.js +++ b/scripts/check-deployed-web.js @@ -5,6 +5,7 @@ const errors = []; const baseUrl = (process.argv[2] || process.env.SOUNDLOG_WEB_BASE_URL || '').replace(/\/+$/, ''); const bypassSecret = process.env.SOUNDLOG_VERCEL_PROTECTION_BYPASS || process.env.VERCEL_PROTECTION_BYPASS; +let authHeaderPromise; if (!baseUrl) { console.error( @@ -21,8 +22,11 @@ function withBase(path) { return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; } -function getHeaders() { - return bypassSecret ? { 'x-vercel-protection-bypass': bypassSecret } : {}; +function getHeaders(extraHeaders = {}) { + return { + ...(bypassSecret ? { 'x-vercel-protection-bypass': bypassSecret } : {}), + ...extraHeaders, + }; } function getLocation(response) { @@ -41,9 +45,19 @@ function protectionMessage(url) { ].join(' '); } -async function fetchText(url) { +function createSmokeCredentials() { + const email = + process.env.SOUNDLOG_CHECK_EMAIL || + `soundlog-check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@soundlog.test`; + const password = process.env.SOUNDLOG_CHECK_PASSWORD || 'soundlog-check-password'; + + return { email, password }; +} + +async function fetchText(url, options = {}) { const response = await fetch(url, { - headers: getHeaders(), + ...options, + headers: getHeaders(options.headers), redirect: 'manual', }); @@ -81,30 +95,120 @@ async function fetchJson(path) { return JSON.parse(text); } +async function postJson(path, body) { + const url = withBase(path); + const { contentType, response, text } = await fetchText(url, { + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }); + + if (!response.ok) { + throw new Error(`${url} returned HTTP ${response.status}: ${text.slice(0, 180)}`); + } + + if (!contentType.includes('application/json') && !text.trim().startsWith('{')) { + throw new Error( + `${url} did not return JSON. content-type=${contentType}; sample=${text + .slice(0, 180) + .replace(/\s+/g, ' ')}`, + ); + } + + return JSON.parse(text); +} + +async function createAuthHeader() { + const { email, password } = createSmokeCredentials(); + + if (process.env.SOUNDLOG_CHECK_EMAIL && process.env.SOUNDLOG_CHECK_PASSWORD) { + try { + const login = await postJson('/api/soundlog/v1/auth/login', { email, password }); + return `Bearer ${login.data.accessToken}`; + } catch { + // The configured smoke account may not exist yet; try to create it once. + } + } + + const register = await postJson('/api/soundlog/v1/auth/register', { + displayName: 'Soundlog Web Check', + email, + password, + }); + + return `Bearer ${register.data.accessToken}`; +} + +async function getAuthHeader() { + authHeaderPromise ??= createAuthHeader(); + + return authHeaderPromise; +} + +async function fetchAuthenticatedJson(path) { + const url = withBase(path); + const authHeader = await getAuthHeader(); + const { contentType, response, text } = await fetchText(url, { + headers: { + Authorization: authHeader, + }, + }); + + if (!response.ok) { + throw new Error(`${url} returned HTTP ${response.status}: ${text.slice(0, 180)}`); + } + + if (!contentType.includes('application/json') && !text.trim().startsWith('{')) { + throw new Error( + `${url} did not return JSON. content-type=${contentType}; sample=${text + .slice(0, 180) + .replace(/\s+/g, ' ')}`, + ); + } + + return JSON.parse(text); +} + async function verifyApiProxy() { const endpoints = [ - '/api/soundlog/v1/health', - '/api/soundlog/v1/home/featured-playlists?limit=3&locationRecommendationEnabled=false&recommendationMode=everyday', - '/api/soundlog/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', - '/api/soundlog/v1/playlists/geoje-ocean', + { + auth: false, + path: '/api/soundlog/v1/health', + }, + { + auth: true, + path: '/api/soundlog/v1/home/featured-playlists?limit=3&locationRecommendationEnabled=false&recommendationMode=everyday', + }, + { + auth: true, + path: '/api/soundlog/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', + }, + { + auth: true, + path: '/api/soundlog/v1/playlists/geoje-ocean', + }, ]; for (const endpoint of endpoints) { try { - const payload = await fetchJson(endpoint); + const payload = endpoint.auth + ? await fetchAuthenticatedJson(endpoint.path) + : await fetchJson(endpoint.path); const data = payload.data; - if (endpoint.includes('/health')) { + if (endpoint.path.includes('/health')) { if (data?.status !== 'ok') { - addError(`${endpoint} returned unexpected health status: ${JSON.stringify(data)}`); + addError(`${endpoint.path} returned unexpected health status: ${JSON.stringify(data)}`); } - } else if (endpoint.includes('/home/')) { + } else if (endpoint.path.includes('/home/')) { if (!Array.isArray(data)) { - addError(`${endpoint} did not return an array data payload.`); + addError(`${endpoint.path} did not return an array data payload.`); } - } else if (endpoint.includes('/playlists/')) { + } else if (endpoint.path.includes('/playlists/')) { if (!data?.id || !Array.isArray(data.tracks)) { - addError(`${endpoint} did not return a playlist with tracks.`); + addError(`${endpoint.path} did not return a playlist with tracks.`); } } } catch (error) { @@ -130,7 +234,7 @@ async function verifyServerContract() { } try { - const payload = await fetchJson( + const payload = await fetchAuthenticatedJson( '/api/soundlog/v1/tour/nearby-places?lat=35.1595&lng=129.1604&radiusMeters=2000&limit=1', ); @@ -154,7 +258,7 @@ async function verifyServerContract() { } try { - const payload = await fetchJson( + const payload = await fetchAuthenticatedJson( '/api/soundlog/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', ); const serialized = JSON.stringify(payload?.data ?? {}); @@ -264,13 +368,6 @@ async function verifyBundle() { ['track_resume', 'Deployed bundle must not include fake resume events.'], ['track_skip', 'Deployed bundle must not include fake skip playback events.'], ['spotify-auth', 'Deployed bundle must not include the removed Spotify auth route.'], - ['open.spotify.com', 'Deployed bundle must not include Spotify external search URLs.'], - ['music.youtube.com', 'Deployed bundle must not include YouTube Music URLs.'], - ['YouTube Music', 'Deployed bundle must not include YouTube Music action copy.'], - [ - 'openMusicPlatformUrl', - 'Deployed bundle must not include external music platform open helpers.', - ], [ 'playSelectedSpotifyOrFallback', 'Deployed bundle must not include removed Spotify playback helpers.', diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index efa1413..756fd4e 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -90,12 +90,7 @@ function verifyHonestMusicActionSourceFiles() { ['track_resume', 'Frontend source must not include fake resume events.'], ['track_skip', 'Frontend source must not include fake skip playback events.'], ['spotify-auth', 'Frontend source must not include the removed Spotify auth route.'], - ['open.spotify.com', 'Frontend source must not include Spotify external search URLs.'], ['playSelectedSpotifyOrFallback', 'Frontend source must not include Spotify playback helpers.'], - ['openMusicPlatformUrl', 'Frontend source must not open external music platform URLs.'], - ['musicPlatformLinks', 'Frontend source must not import external music platform link helpers.'], - ['music.youtube.com', 'Frontend source must not include YouTube Music URLs.'], - ['YouTube Music', 'Frontend source must not mention YouTube Music actions.'], ]; sourceDirs @@ -242,26 +237,6 @@ function verifyBundle(bundleText) { 'playSelectedSpotifyOrFallback', 'Server web export must not include removed Spotify playback helpers.', ); - assertExcludes( - bundleText, - 'open.spotify.com', - 'Server web export must not include Spotify external search URLs.', - ); - assertExcludes( - bundleText, - 'music.youtube.com', - 'Server web export must not include YouTube Music URLs.', - ); - assertExcludes( - bundleText, - 'YouTube Music', - 'Server web export must not include YouTube Music action copy.', - ); - assertExcludes( - bundleText, - 'openMusicPlatformUrl', - 'Server web export must not include external music platform open helpers.', - ); } try { diff --git a/src/api/communityApi.ts b/src/api/communityApi.ts index 1b2f212..e14934a 100644 --- a/src/api/communityApi.ts +++ b/src/api/communityApi.ts @@ -24,6 +24,18 @@ type CreateTravelRoomInput = { visibility?: 'companions' | 'invite_only'; }; +type JoinTravelRoomByInviteCodeInput = { + displayName?: string; + inviteCode: string; +}; + +type TravelRoomListQuery = { + limit?: number; + sessionId?: string; +}; + +type TravelRoomMomentComment = NonNullable[number]; + type AddTravelRoomMomentInput = { artistName?: string; momentLogId?: string; @@ -56,6 +68,12 @@ type SoundMapQuery = { visibility?: Exclude; }; +type TravelMateRequestQuery = { + box?: 'all' | 'inbox' | 'sent'; + limit?: number; + status?: TravelMateRequest['status']; +}; + function sanitizeSoundMapPin(pin: SoundMapPin): SoundMapPin { return { ...pin, @@ -96,6 +114,15 @@ function trackMomentInput(track?: Track, placeName?: string): AddTravelRoomMomen } export const communityApi = { + getTravelRooms: async (query: TravelRoomListQuery = {}) => { + if (!shouldAttemptAuthenticatedApi()) { + return []; + } + + const rooms = await requestApi('/v1/travel-rooms', { query }); + + return rooms.map(sanitizeTravelRoom); + }, createTravelRoom: async (input: CreateTravelRoomInput) => { if (!shouldAttemptAuthenticatedApi()) { return undefined; @@ -108,6 +135,29 @@ export const communityApi = { return sanitizeTravelRoom(room); }, + joinTravelRoomByInviteCode: async (input: JoinTravelRoomByInviteCodeInput) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + const room = await requestApi('/v1/travel-rooms/join', { + body: input, + method: 'POST', + }); + + return sanitizeTravelRoom(room); + }, + getTravelRoom: async (roomId: string) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + const room = await requestApi( + `/v1/travel-rooms/${encodeURIComponent(roomId)}`, + ); + + return sanitizeTravelRoom(room); + }, addCurrentTrackMoment: async (roomId: string, track?: Track, placeName?: string) => { if (!shouldAttemptAuthenticatedApi()) { return undefined; @@ -123,6 +173,38 @@ export const communityApi = { return sanitizeRoomMoment(moment); }, + updateTravelRoomMomentStatus: async ( + roomId: string, + momentId: string, + status: TravelRoomMoment['status'], + ) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + const moment = await requestApi( + `/v1/travel-rooms/${encodeURIComponent(roomId)}/moments/${encodeURIComponent(momentId)}`, + { + body: { status }, + method: 'PATCH', + }, + ); + + return sanitizeRoomMoment(moment); + }, + addTravelRoomMomentComment: async (roomId: string, momentId: string, body: string) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + return requestApi( + `/v1/travel-rooms/${encodeURIComponent(roomId)}/moments/${encodeURIComponent(momentId)}/comments`, + { + body: { body }, + method: 'POST', + }, + ); + }, createTravelRoomRecap: async ( roomId: string, input: { representativeTrackId?: string; templateId?: 'album' | 'film' | 'lp'; title?: string }, @@ -197,6 +279,29 @@ export const communityApi = { method: 'POST', }); }, + getTravelMateRequests: async (query: TravelMateRequestQuery = {}) => { + if (!shouldAttemptAuthenticatedApi()) { + return []; + } + + return requestApi('/v1/travel-mate-requests', { query }); + }, + updateTravelMateRequest: async ( + requestId: string, + action: 'accept' | 'cancel' | 'decline' | 'expire', + ) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + return requestApi( + `/v1/travel-mate-requests/${encodeURIComponent(requestId)}`, + { + body: { action }, + method: 'PATCH', + }, + ); + }, blockUser: async (input: { targetPinId?: string; targetUserId?: string }) => { if (!shouldAttemptAuthenticatedApi()) { return { accepted: false }; diff --git a/src/api/homeApi.ts b/src/api/homeApi.ts index b05d80d..c96811a 100644 --- a/src/api/homeApi.ts +++ b/src/api/homeApi.ts @@ -7,16 +7,21 @@ import type { MusicRecommendationMode, PlaceContext, } from '@/types/domain'; +import { + createFeaturedPlaylistsCacheKey, + createMoodRecommendationsCacheKey, + useRecommendationCacheStore, +} from '@/store/recommendationCacheStore'; import { sanitizeMoodRecommendation } from '@/utils/trackSanitizer'; -type FeaturedPlaylistParams = { +export type FeaturedPlaylistParams = { location?: GeoPoint; locationRecommendationEnabled?: boolean; recommendationMode?: MusicRecommendationMode; place?: PlaceContext; }; -type MoodRecommendationParams = { +export type MoodRecommendationParams = { currentPlace?: PlaceContext; moodFilter?: string; recommendationMode?: MusicRecommendationMode; @@ -28,31 +33,68 @@ type MoodRecommendationParams = { export const homeApi = { getFeaturedPlaylists: async (params?: FeaturedPlaylistParams) => { - return requestApi('/v1/home/featured-playlists', { - query: { - lat: params?.location?.lat, - limit: 10, - lng: params?.location?.lng, - locationRecommendationEnabled: params?.locationRecommendationEnabled ?? false, - placeId: params?.place?.id, - recommendationMode: params?.recommendationMode ?? 'everyday', - }, - }); + const cacheKey = createFeaturedPlaylistsCacheKey(params); + const cacheStore = useRecommendationCacheStore.getState(); + + try { + const playlists = await requestApi('/v1/home/featured-playlists', { + query: { + lat: params?.location?.lat, + limit: 10, + lng: params?.location?.lng, + locationRecommendationEnabled: params?.locationRecommendationEnabled ?? false, + placeId: params?.place?.id, + recommendationMode: params?.recommendationMode ?? 'everyday', + }, + }); + + cacheStore.setFeaturedPlaylists(cacheKey, playlists); + + return playlists; + } catch (error) { + const cached = cacheStore.getFeaturedPlaylists(cacheKey); + + if (!cached) { + throw error; + } + + cacheStore.markFeaturedFallback(cacheKey, cached.cachedAt); + + return cached.data; + } }, getMoodRecommendations: async (params?: MoodRecommendationParams) => { - const recommendations = await requestApi('/v1/home/mood-recommendations', { - query: { - limit: 10, - moodFilter: params?.moodFilter ?? '전체', - preferredGenres: params?.preferredGenres, - preferredMoods: params?.preferredMoods, - recommendationMode: params?.recommendationMode ?? 'everyday', - topFilter: params?.topFilter ?? '전체', - travelStyles: params?.travelStyles, - }, - }); + const cacheKey = createMoodRecommendationsCacheKey(params); + const cacheStore = useRecommendationCacheStore.getState(); + + try { + const recommendations = await requestApi('/v1/home/mood-recommendations', { + query: { + limit: 10, + moodFilter: params?.moodFilter ?? '전체', + preferredGenres: params?.preferredGenres, + preferredMoods: params?.preferredMoods, + recommendationMode: params?.recommendationMode ?? 'everyday', + topFilter: params?.topFilter ?? '전체', + travelStyles: params?.travelStyles, + }, + }); + const sanitizedRecommendations = recommendations.map(sanitizeMoodRecommendation); + + cacheStore.setMoodRecommendations(cacheKey, sanitizedRecommendations); + + return sanitizedRecommendations; + } catch (error) { + const cached = cacheStore.getMoodRecommendations(cacheKey); - return recommendations.map(sanitizeMoodRecommendation); + if (!cached) { + throw error; + } + + cacheStore.markMoodFallback(cacheKey, cached.cachedAt); + + return cached.data; + } }, getRecentMusicLogs: async () => { if (!shouldAttemptAuthenticatedApi()) { diff --git a/src/api/homeQueries.ts b/src/api/homeQueries.ts index b654194..3ebe5b1 100644 --- a/src/api/homeQueries.ts +++ b/src/api/homeQueries.ts @@ -1,45 +1,40 @@ import { useQuery } from '@tanstack/react-query'; -import { homeApi } from '@/api/homeApi'; -import type { - GeoPoint, - MusicRecommendationMode, - PlaceContext, -} from '@/types/domain'; +import { + homeApi, + type FeaturedPlaylistParams, + type MoodRecommendationParams, +} from '@/api/homeApi'; -type FeaturedPlaylistParams = { - location?: GeoPoint; - locationRecommendationEnabled: boolean; - recommendationMode: MusicRecommendationMode; - place?: PlaceContext; +type HomeQueryOptions = { + enabled?: boolean; }; -type MoodRecommendationParams = { - currentPlace?: PlaceContext; - moodFilter: string; - recommendationMode: MusicRecommendationMode; - preferredGenres?: string[]; - preferredMoods?: string[]; - topFilter: string; - travelStyles?: string[]; -}; - -export function useFeaturedPlaylistsQuery(params: FeaturedPlaylistParams) { +export function useFeaturedPlaylistsQuery( + params: FeaturedPlaylistParams, + options: HomeQueryOptions = {}, +) { return useQuery({ + enabled: options.enabled ?? true, queryFn: () => homeApi.getFeaturedPlaylists(params), queryKey: ['home', 'featured-playlists', params], }); } -export function useMoodRecommendationsQuery(params: MoodRecommendationParams) { +export function useMoodRecommendationsQuery( + params: MoodRecommendationParams, + options: HomeQueryOptions = {}, +) { return useQuery({ + enabled: options.enabled ?? true, queryFn: () => homeApi.getMoodRecommendations(params), queryKey: ['home', 'mood-recommendations', params], }); } -export function useRecentMusicLogsQuery() { +export function useRecentMusicLogsQuery(options: HomeQueryOptions = {}) { return useQuery({ + enabled: options.enabled ?? true, queryFn: homeApi.getRecentMusicLogs, queryKey: ['home', 'recent-music-logs'], }); diff --git a/src/api/libraryApi.ts b/src/api/libraryApi.ts index 774a541..db88ace 100644 --- a/src/api/libraryApi.ts +++ b/src/api/libraryApi.ts @@ -4,7 +4,7 @@ import { shouldAttemptAuthenticatedApi, } from '@/api/client'; import { RecommendationEventContext } from '@/store/recommendationEventStore'; -import { Track } from '@/types/domain'; +import { LibraryPlaylistSummary, Track } from '@/types/domain'; import { sanitizeTrack } from '@/utils/trackSanitizer'; type LibraryTrackAction = 'like' | 'save' | 'unlike' | 'unsave'; @@ -20,6 +20,7 @@ export type RemoteLibraryTrackRecord = { createdAt: string; id: string; kind: 'liked' | 'saved'; + playlist?: LibraryPlaylistSummary; playlistId?: string; track: Track; }; diff --git a/src/api/momentLogApi.ts b/src/api/momentLogApi.ts index bc0bfb0..250c130 100644 --- a/src/api/momentLogApi.ts +++ b/src/api/momentLogApi.ts @@ -6,12 +6,13 @@ import { import { GeoPoint, MomentLog, MoodTag, Track, TravelMode } from '@/types/domain'; import { sanitizeTrack } from '@/utils/trackSanitizer'; -type CreateMomentLogInput = { +export type CreateMomentLogInput = { createdAt: string; idempotencyKey?: string; location?: GeoPoint; moodTags: MoodTag[]; - photoUri: string; + note?: string; + photoUri?: string; placeCategory?: string; placeId?: string; placeName?: string; @@ -20,6 +21,25 @@ type CreateMomentLogInput = { travelMode?: TravelMode; }; +export type MomentLogListParams = { + cursor?: string; + limit?: number; + sessionId?: string; +}; + +export type UpdateMomentLogInput = { + createdAt?: string; + location?: GeoPoint | null; + moodTags?: MoodTag[]; + note?: string | null; + placeCategory?: string | null; + placeId?: string | null; + placeName?: string | null; + sessionId?: string | null; + track?: Track; + travelMode?: TravelMode | null; +}; + function appendIfDefined(formData: FormData, key: string, value?: number | string) { if (value === undefined || value === null || value === '') { return; @@ -35,17 +55,20 @@ function getPhotoName(photoUri: string) { function toFormData(input: CreateMomentLogInput) { const formData = new FormData(); - formData.append('photo', { - name: getPhotoName(input.photoUri), - type: 'image/jpeg', - uri: input.photoUri, - } as unknown as Blob); + if (input.photoUri) { + formData.append('photo', { + name: getPhotoName(input.photoUri), + type: 'image/jpeg', + uri: input.photoUri, + } as unknown as Blob); + } formData.append('createdAt', input.createdAt); formData.append('moodTags', input.moodTags.join(',')); appendIfDefined(formData, 'artistName', input.track?.artist); appendIfDefined(formData, 'lat', input.location?.lat); appendIfDefined(formData, 'lng', input.location?.lng); + appendIfDefined(formData, 'note', input.note); appendIfDefined(formData, 'placeCategory', input.placeCategory); appendIfDefined(formData, 'placeId', input.placeId); appendIfDefined(formData, 'placeName', input.placeName); @@ -57,6 +80,74 @@ function toFormData(input: CreateMomentLogInput) { return formData; } +function toPhotoFormData(photoUri: string) { + const formData = new FormData(); + + formData.append('photo', { + name: getPhotoName(photoUri), + type: 'image/jpeg', + uri: photoUri, + } as unknown as Blob); + + return formData; +} + +function toUpdateBody(input: UpdateMomentLogInput) { + const body: Record = {}; + + if ('createdAt' in input) { + body.createdAt = input.createdAt; + } + + if ('location' in input) { + body.lat = input.location?.lat ?? null; + body.lng = input.location?.lng ?? null; + } + + if ('moodTags' in input) { + body.moodTags = input.moodTags; + } + + if ('note' in input) { + body.note = input.note; + } + + if ('placeCategory' in input) { + body.placeCategory = input.placeCategory; + } + + if ('placeId' in input) { + body.placeId = input.placeId; + } + + if ('placeName' in input) { + body.placeName = input.placeName; + } + + if ('sessionId' in input) { + body.sessionId = input.sessionId; + } + + if ('track' in input) { + body.artistName = input.track?.artist; + body.trackId = input.track?.id; + body.trackTitle = input.track?.title; + } + + if ('travelMode' in input) { + body.travelMode = input.travelMode; + } + + return body; +} + +function sanitizeMomentLog(log: MomentLog) { + return { + ...log, + track: log.track ? sanitizeTrack(log.track) : undefined, + }; +} + export const momentLogApi = { createMomentLog: (input: CreateMomentLogInput) => { if (!shouldAttemptAuthenticatedApi()) { @@ -67,9 +158,55 @@ export const momentLogApi = { body: toFormData(input), idempotencyKey: input.idempotencyKey ?? createIdempotencyKey('moment-log'), method: 'POST', - }).then((log) => ({ - ...log, - track: log.track ? sanitizeTrack(log.track) : undefined, - })); + }).then(sanitizeMomentLog); + }, + getMomentLogs: async (params: MomentLogListParams = {}) => { + if (!shouldAttemptAuthenticatedApi()) { + return Promise.resolve([]); + } + + const logs = await requestApi('/v1/moment-logs', { + query: params, + }); + + return logs.map(sanitizeMomentLog); + }, + deleteMomentLog: (momentLogId: string) => { + if (!shouldAttemptAuthenticatedApi()) { + return Promise.resolve(undefined); + } + + return requestApi<{ accepted: boolean }>(`/v1/moment-logs/${momentLogId}`, { + method: 'DELETE', + }).then((response) => response.accepted); + }, + updateMomentLog: (momentLogId: string, input: UpdateMomentLogInput) => { + if (!shouldAttemptAuthenticatedApi()) { + return Promise.resolve(undefined); + } + + return requestApi(`/v1/moment-logs/${momentLogId}`, { + body: toUpdateBody(input), + method: 'PATCH', + }).then(sanitizeMomentLog); + }, + updateMomentLogPhoto: (momentLogId: string, photoUri: string) => { + if (!shouldAttemptAuthenticatedApi()) { + return Promise.resolve(undefined); + } + + return requestApi(`/v1/moment-logs/${momentLogId}/photo`, { + body: toPhotoFormData(photoUri), + method: 'PUT', + }).then(sanitizeMomentLog); + }, + deleteMomentLogPhoto: (momentLogId: string) => { + if (!shouldAttemptAuthenticatedApi()) { + return Promise.resolve(undefined); + } + + return requestApi(`/v1/moment-logs/${momentLogId}/photo`, { + method: 'DELETE', + }).then(sanitizeMomentLog); }, }; diff --git a/src/api/momentLogQueries.ts b/src/api/momentLogQueries.ts new file mode 100644 index 0000000..2b4b3dc --- /dev/null +++ b/src/api/momentLogQueries.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; + +import { momentLogApi, type MomentLogListParams } from '@/api/momentLogApi'; + +export const momentLogQueryKeys = { + all: ['moment-logs'] as const, + list: (params: MomentLogListParams = {}) => ['moment-logs', 'list', params] as const, +}; + +type MomentLogListQueryOptions = { + enabled?: boolean; +}; + +export function useMomentLogListQuery( + params: MomentLogListParams = {}, + options: MomentLogListQueryOptions = {}, +) { + return useQuery({ + enabled: options.enabled ?? true, + queryFn: () => momentLogApi.getMomentLogs(params), + queryKey: momentLogQueryKeys.list(params), + staleTime: 60 * 1000, + }); +} diff --git a/src/api/playlistApi.ts b/src/api/playlistApi.ts index 191a91c..6d8617a 100644 --- a/src/api/playlistApi.ts +++ b/src/api/playlistApi.ts @@ -1,9 +1,15 @@ import { + ApiError, createIdempotencyKey, requestApi, - shouldAttemptAuthenticatedApi, } from '@/api/client'; -import type { GeoPoint, MoodTag, PlaylistCuration, TravelMode } from '@/types/domain'; +import type { + GeoPoint, + MoodTag, + PlaylistCuration, + PlaylistRecommendationSource, + TravelMode, +} from '@/types/domain'; import { sanitizePlaylistCuration } from '@/utils/trackSanitizer'; export type PlaylistMlMood = '감성적인' | '설레는' | '시원한' | '신나는' | '잔잔한'; @@ -20,6 +26,29 @@ export type ContextualPlaylistInput = { travelMode?: TravelMode; }; +export type RecommendedPlaylistInput = { + location: GeoPoint; + mood: PlaylistMlMood; + state: PlaylistMlState; +}; + +function withRecommendationSource( + playlist: PlaylistCuration, + source: PlaylistRecommendationSource, +) { + if (playlist.context?.source) { + return playlist; + } + + return { + ...playlist, + context: { + ...playlist.context, + source, + }, + }; +} + export const playlistApi = { getPlaylist: async (id?: string) => { const playlist = await requestApi( @@ -28,20 +57,41 @@ export const playlistApi = { return sanitizePlaylistCuration(playlist); }, - createContextualPlaylist: async ( - input: ContextualPlaylistInput, - fallbackPlaylistId?: string, - ) => { - if (!shouldAttemptAuthenticatedApi()) { - return playlistApi.getPlaylist(fallbackPlaylistId); + getRecommendedPlaylist: async (input: ContextualPlaylistInput) => { + if (!input.location) { + const playlist = await playlistApi.createContextualPlaylist(input); + + return withRecommendationSource(playlist, 'seed-fallback'); } + try { + const playlist = await requestApi('/v1/recommendations/playlists', { + query: { + mood: input.mood ?? '잔잔한', + state: input.state ?? '산책', + x: input.location.lng, + y: input.location.lat, + }, + }); + + return sanitizePlaylistCuration(playlist); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + throw error; + } + + const playlist = await playlistApi.createContextualPlaylist(input); + + return withRecommendationSource(playlist, 'seed-fallback'); + } + }, + createContextualPlaylist: async (input: ContextualPlaylistInput) => { const playlist = await requestApi('/v1/playlists/contextual', { body: input, idempotencyKey: createIdempotencyKey('playlist-contextual'), method: 'POST', }); - return sanitizePlaylistCuration(playlist); + return withRecommendationSource(sanitizePlaylistCuration(playlist), 'server-contextual'); }, }; diff --git a/src/api/recapApi.ts b/src/api/recapApi.ts index 0aaf368..078626e 100644 --- a/src/api/recapApi.ts +++ b/src/api/recapApi.ts @@ -60,7 +60,7 @@ export const recapApi = { }, getRecapShare: async (id?: string) => { if (!id || !shouldAttemptAuthenticatedApi()) { - return Promise.resolve(undefined); + return Promise.resolve(null); } return requestApi(`/v1/recaps/${encodeURIComponent(id)}/share`); diff --git a/src/api/tourApi.ts b/src/api/tourApi.ts index aa70c4d..8ffa02d 100644 --- a/src/api/tourApi.ts +++ b/src/api/tourApi.ts @@ -11,7 +11,6 @@ const DEFAULT_RADIUS_METERS = 2000; export const tourApi = { async getNearbyPlaces(params: NearbyPlacesParams): Promise { return requestApi('/v1/tour/nearby-places', { - auth: false, query: { lat: params.location.lat, limit: 10, diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 645434a..444be0e 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'expo-router'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useState } from 'react'; -import { Modal, Platform, Pressable, View } from 'react-native'; +import { Modal, Platform, Pressable, ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { libraryApi } from '@/api/libraryApi'; @@ -14,6 +14,11 @@ import { getMiniPlayerBottom } from '@/constants/layout'; import { useLibraryStore } from '@/store/libraryStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; +import { + getExternalMusicLinks, + openExternalMusicLink, + type ExternalMusicLink, +} from '@/utils/externalMusicLinks'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals'; @@ -29,6 +34,7 @@ export function MiniPlayer() { currentTrack, playNext, playPrevious, + playlist, playlistId, queue, } = usePlayerStore(); @@ -48,11 +54,12 @@ export function MiniPlayer() { const playerGlow = hexToRgba(keyColor, 0.72); const playerSoftGlow = hexToRgba(keyColor, 0.24); const canSkip = queue.length > 1; + const externalLinks = getExternalMusicLinks(currentTrack); const handleToggleLike = () => { const context = createRecommendationEventContext(); setActionMessage(undefined); - setLikeState(currentTrack, !liked, playlistId); + setLikeState(currentTrack, !liked, playlistId, playlist); void libraryApi .updateTrackState(currentTrack.id, { action: liked ? 'unlike' : 'like', @@ -60,7 +67,7 @@ export function MiniPlayer() { playlistId, }) .catch(() => { - setLikeState(currentTrack, liked, playlistId); + setLikeState(currentTrack, liked, playlistId, playlist); setActionMessage('서버 저장에 실패해서 좋아요 상태를 되돌렸어요.'); }); syncRecommendationEvent( @@ -76,7 +83,7 @@ export function MiniPlayer() { const context = createRecommendationEventContext(); setActionMessage(undefined); - setSaveState(currentTrack, !saved, playlistId); + setSaveState(currentTrack, !saved, playlistId, playlist); void libraryApi .updateTrackState(currentTrack.id, { action: saved ? 'unsave' : 'save', @@ -84,7 +91,7 @@ export function MiniPlayer() { playlistId, }) .catch(() => { - setSaveState(currentTrack, saved, playlistId); + setSaveState(currentTrack, saved, playlistId, playlist); setActionMessage('서버 저장에 실패해서 저장 상태를 되돌렸어요.'); }); syncRecommendationEvent( @@ -114,6 +121,40 @@ export function MiniPlayer() { setIsFullPlayerVisible(false); router.push('/camera'); }; + const handleOpenLiveSoundMap = () => { + setIsFullPlayerVisible(false); + router.push('/travel'); + }; + const handleOpenExternalLink = async (link: ExternalMusicLink) => { + const context = createRecommendationEventContext(); + + setActionMessage(undefined); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId, + trackId: currentTrack.id, + type: 'track_external_open', + value: link.id, + }), + ); + + try { + await openExternalMusicLink(link); + setActionMessage(`${link.label} 링크를 열었어요. 돌아오면 이 곡을 기록할 수 있어요.`); + } catch { + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId, + trackId: currentTrack.id, + type: 'external_music_open_failed', + value: link.id, + }), + ); + setActionMessage('외부 음악 링크를 열지 못했어요. 웹 검색을 다시 시도해보세요.'); + } + }; const renderCover = (sizeClassName: string, radiusClassName: string) => ( {currentTrack.artist}
- + SoundLog 음악으로 선택됨
@@ -289,7 +330,16 @@ export function MiniPlayer() { }} /> - + + + 선택한 곡 + {currentTrack.title} @@ -338,7 +391,31 @@ export function MiniPlayer() { - + + + 어디에서 들을까요? + + + {externalLinks.slice(0, 3).map((link) => ( + void handleOpenExternalLink(link)} + > + + + + {link.label} + + + + + ))} + + + + - 이 곡으로 순간 기록 + 이 곡으로 기록 + + + + + + Live Sound Map 열기 - 음원을 재생하지 않고 곡 정보와 여행 순간을 SoundLog 안에 기록해요. + 음원을 재생하지 않고 곡 정보와 여행 순간을 SoundLog 안에 기록하거나, 여행 모드에서 지도에 공개해요. @@ -416,7 +503,7 @@ export function MiniPlayer() { ) : null} - + diff --git a/src/components/auth/AppEntryGate.tsx b/src/components/auth/AppEntryGate.tsx index 7fc1b4c..034e8f7 100644 --- a/src/components/auth/AppEntryGate.tsx +++ b/src/components/auth/AppEntryGate.tsx @@ -15,10 +15,10 @@ export function AppEntryGate() { const isAuthRoute = pathname.startsWith('/auth'); const isLegalRoute = pathname.startsWith('/legal'); const isOnboardingRoute = pathname.startsWith('/onboarding'); - const hasAppSession = status === 'authenticated' || status === 'guest'; + const hasAppSession = status === 'authenticated'; - if (!hasAppSession && !isAuthRoute && !isLegalRoute) { - return ; + if (!hasAppSession && !isAuthRoute && !isLegalRoute && !isOnboardingRoute) { + return ; } if (isAuthRoute && status === 'authenticated') { diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index 199ff59..89b51a0 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -220,8 +220,7 @@ function DevTestManagerContent() { const setPlace = useTravelSessionStore((state) => state.setPlace); const startSession = useTravelSessionStore((state) => state.startSession); const { completeOnboarding, resetOnboarding } = useUserProfileStore(); - const { continueAsGuest, finishLogin, logoutLocal, status: authStatus, user: authUser } = - useAuthStore(); + const { finishLogin, logoutLocal, status: authStatus, user: authUser } = useAuthStore(); const { logs, addLog, removeLog } = useMomentLogStore(); const { likedTracks, savedTracks, removeLikedTrack, removeSavedTrack, toggleLike, toggleSave } = useLibraryStore(); @@ -250,11 +249,6 @@ function DevTestManagerContent() { setIsOpen(false); router.replace('/' as never); }; - const applyGuestSession = () => { - continueAsGuest(); - setIsOpen(false); - router.replace('/' as never); - }; const applyLogout = () => { logoutLocal(); setIsOpen(false); @@ -444,7 +438,7 @@ function DevTestManagerContent() { navigate('/camera')} /> - + {authProviderOptions.map((provider) => ( applyMockLogin(provider)} /> ))} - diff --git a/src/components/home/CurrentSoundtrackCard.tsx b/src/components/home/CurrentSoundtrackCard.tsx new file mode 100644 index 0000000..be8824c --- /dev/null +++ b/src/components/home/CurrentSoundtrackCard.tsx @@ -0,0 +1,165 @@ +import { Feather } from '@expo/vector-icons'; +import { Pressable, View } from 'react-native'; + +import { AppText } from '@/components/AppText'; +import type { FeaturedPlaylist, PlaceContext } from '@/types/domain'; + +type CurrentSoundtrackCardProps = { + cachedAt?: string; + currentPlace?: PlaceContext; + isCached?: boolean; + isError?: boolean; + isLoading?: boolean; + moodLabel: string; + onCaptureMoment: () => void; + onOpenPlaylist: (playlist: FeaturedPlaylist) => void; + onRetry?: () => void; + playlist?: FeaturedPlaylist; + travelLabel: string; +}; + +export function CurrentSoundtrackCard({ + cachedAt, + currentPlace, + isCached = false, + isError = false, + isLoading = false, + moodLabel, + onCaptureMoment, + onOpenPlaylist, + onRetry, + playlist, + travelLabel, +}: CurrentSoundtrackCardProps) { + const placeTitle = currentPlace?.title ?? '지금 위치 주변'; + const placeCaption = currentPlace?.address ?? currentPlace?.category ?? '장소를 확인하며 추천을 준비 중'; + const playlistTitle = playlist?.regionName ?? `${travelLabel} 사운드트랙`; + const playlistDescription = + playlist?.description ?? + '현재 장소와 무드를 기준으로 오늘 들을 곡 목록을 준비하고 있어요.'; + const trackMeta = playlist + ? `${playlist.trackCount}곡 · ${playlist.durationText}` + : isLoading + ? '추천 준비 중' + : '샘플 추천 사용 가능'; + const cacheCaption = cachedAt + ? `최근 추천 · ${new Date(cachedAt).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + })}` + : '최근 추천'; + + const handleOpenPlaylist = () => { + if (playlist) { + onOpenPlaylist(playlist); + return; + } + + onRetry?.(); + }; + + return ( + + + + + + + + 상태 + + {travelLabel} + + + 무드 + {moodLabel} + + {isCached ? ( + + + {cacheCaption} + + + ) : null} + + + 오늘의 사운드트랙 + + + {placeTitle} + + + + + + + + + + + + + + + {playlistTitle} + + + {playlistDescription} + + + + + + + + {trackMeta} + + + {isError ? '추천을 다시 시도할 수 있어요' : placeCaption} + + + + + + 곡 보기 + + + + + + + + + 기록 + + + + 다시 추천 + + + + + ); +} diff --git a/src/components/home/FeaturedPlaylistSection.tsx b/src/components/home/FeaturedPlaylistSection.tsx index f17d0c4..9b04625 100644 --- a/src/components/home/FeaturedPlaylistSection.tsx +++ b/src/components/home/FeaturedPlaylistSection.tsx @@ -6,7 +6,9 @@ import { FeaturedPlaylistCard } from '@/components/home/FeaturedPlaylistCard'; import { FeaturedPlaylist } from '@/types/domain'; type FeaturedPlaylistSectionProps = { + cachedAt?: string; data?: FeaturedPlaylist[]; + isCached?: boolean; isError?: boolean; isLoading?: boolean; onSelectPlaylist?: (playlist: FeaturedPlaylist) => void; @@ -27,15 +29,33 @@ function FeaturedPlaylistSkeleton() { } export function FeaturedPlaylistSection({ + cachedAt, data = [], + isCached = false, isError = false, isLoading = false, onSelectPlaylist, onRetry, }: FeaturedPlaylistSectionProps) { + const cacheLabel = cachedAt + ? `최근 추천 · ${new Date(cachedAt).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + })}` + : '최근 추천'; + return ( - Music Playlist + + Music Playlist + {isCached ? ( + + + {cacheLabel} + + + ) : null} + {isLoading ? ( diff --git a/src/components/home/LocationContextCard.tsx b/src/components/home/LocationContextCard.tsx index 967ff33..d2620ea 100644 --- a/src/components/home/LocationContextCard.tsx +++ b/src/components/home/LocationContextCard.tsx @@ -72,46 +72,43 @@ export function LocationContextCard({ title: '위치 추천이 꺼져 있어요', }; const buttonLabel = enabled ? (location ? '위치 새로고침' : '위치 설정') : '위치 추천 켜기'; + const placeTitle = location ? (place?.title ?? formatPlaceLabel(location)) : copy.title; + const placeMeta = place + ? [place.category ?? place.contentType, place.distanceMeters ? `${Math.round(place.distanceMeters)}m` : undefined] + .filter(Boolean) + .join(' · ') + : undefined; + const statusText = isPlaceLoading + ? '주변 관광지를 확인 중이에요' + : placeCount > 0 + ? `주변 장소 ${placeCount}곳 반영` + : location + ? updatedAt + ? `${formatRecapRecordedAt(updatedAt)} 갱신` + : '현재 위치 기반 추천 중' + : copy.description; return ( - - + + - + - {copy.title} - {copy.description} - - {enabled && location ? ( - - - {place?.title ?? formatPlaceLabel(location)} - - {place ? ( - - {[place.category ?? place.contentType, place.distanceMeters ? `${Math.round(place.distanceMeters)}m` : undefined] - .filter(Boolean) - .join(' · ') || '장소 컨텍스트 확인됨'} - - ) : null} - {updatedAt ? ( - - {formatRecapRecordedAt(updatedAt)} 갱신 - - ) : null} - {isPlaceLoading ? ( - 주변 관광지를 확인 중이에요 - ) : placeCount > 0 ? ( - - 주변 장소 {placeCount}곳을 추천 맥락으로 반영해요 - - ) : null} - {placeInfoMessage ? ( - {placeInfoMessage} - ) : null} - + + 현재 위치 + + + {placeTitle} + + + {placeMeta ? `${placeMeta} · ${statusText}` : statusText} + + {placeInfoMessage ? ( + + {placeInfoMessage} + ) : null} @@ -129,7 +126,7 @@ export function LocationContextCard({ void; @@ -41,18 +43,36 @@ function MoodRecommendationSkeleton() { } export function MoodRecommendationSection({ + cachedAt, data = [], + isCached = false, isError = false, isLoading = false, onSelectMoodFilter, onSelectRecommendation, selectedMoodFilter, }: MoodRecommendationSectionProps) { + const cacheLabel = cachedAt + ? `최근 추천 · ${new Date(cachedAt).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + })}` + : '최근 추천'; + return ( - - 나의 무드에 맞는 음악 추천 - + + + 나의 무드에 맞는 음악 추천 + + {isCached ? ( + + + {cacheLabel} + + + ) : null} + diff --git a/src/components/library/LibraryScreen.tsx b/src/components/library/LibraryScreen.tsx index 7a6d034..78de8ee 100644 --- a/src/components/library/LibraryScreen.tsx +++ b/src/components/library/LibraryScreen.tsx @@ -1,4 +1,7 @@ import { useQuery } from '@tanstack/react-query'; +import { Feather } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import { router } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { Pressable, ScrollView, View } from 'react-native'; @@ -7,33 +10,52 @@ import { syncRecommendationEvent } from '@/api/recommendationEventApi'; import { AppText } from '@/components/AppText'; import { LibraryEmptyState } from '@/components/library/LibraryEmptyState'; import { LibraryTrackRow } from '@/components/library/LibraryTrackRow'; -import { TrackActionMenu } from '@/components/playlist/TrackActionMenu'; import { Screen } from '@/components/Screen'; import { LibraryTrackRecord, useLibraryStore } from '@/store/libraryStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; +import type { LibraryPlaylistSummary } from '@/types/domain'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; type LibraryTab = 'liked' | 'playlists' | 'saved'; type LibraryPlaylistRecord = { createdAt: string; + playlist?: LibraryPlaylistSummary; playlistId: string; records: LibraryTrackRecord[]; }; const tabs: Array<{ id: LibraryTab; label: string }> = [ { id: 'liked', label: '좋아요' }, - { id: 'saved', label: '저장한 곡' }, - { id: 'playlists', label: '저장한 PL' }, + { id: 'saved', label: '저장곡' }, + { id: 'playlists', label: '플레이리스트' }, ]; +function getPlaylistTitle(playlist?: LibraryPlaylistSummary, playlistId?: string) { + const placeLabel = playlist?.placeName ?? playlist?.regionName; + + if (placeLabel) { + return `${placeLabel} 사운드트랙`; + } + + return playlistId ? '저장한 사운드트랙' : '저장한 플레이리스트'; +} + +function getPlaylistDescription(playlist?: LibraryPlaylistSummary) { + return ( + playlist?.reason || + playlist?.description || + [playlist?.durationText, playlist?.trackCount ? `${playlist.trackCount}곡` : undefined] + .filter(Boolean) + .join(' · ') || + '다시 듣고 싶은 곡을 모아둔 추천 목록' + ); +} + export function LibraryScreen() { const [selectedTab, setSelectedTab] = useState('liked'); - const [selectedRecord, setSelectedRecord] = useState(); const [actionMessage, setActionMessage] = useState(); const { - isLiked, - isSaved, likedTracks, savedTracks, setLikeState, @@ -63,11 +85,13 @@ export function LibraryScreen() { if (record.createdAt > existingGroup.createdAt) { existingGroup.createdAt = record.createdAt; } + existingGroup.playlist ??= record.playlist; return; } groupMap.set(playlistId, { createdAt: record.createdAt, + playlist: record.playlist, playlistId, records: [record], }); @@ -78,98 +102,81 @@ export function LibraryScreen() { ); }, [savedTracks]); const records = selectedTab === 'liked' ? likedTracks : savedTracks; - const selectedTrack = selectedRecord?.track; - const selectedTrackLiked = isLiked(selectedTrack?.id); - const selectedTrackSaved = isSaved(selectedTrack?.id); useEffect(() => { remoteLibraryRecords.forEach((record) => { if (record.track.isLiked || record.kind === 'liked') { - setLikeState(record.track, true, record.playlistId); + setLikeState(record.track, true, record.playlistId, record.playlist); } if (record.track.isSaved || record.kind === 'saved') { - setSaveState(record.track, true, record.playlistId); + setSaveState(record.track, true, record.playlistId, record.playlist); } }); }, [remoteLibraryRecords, setLikeState, setSaveState]); - const closeMenu = () => { - setSelectedRecord(undefined); + const clearActionMessage = () => { setActionMessage(undefined); }; const selectTab = (tab: LibraryTab) => { setSelectedTab(tab); - closeMenu(); + clearActionMessage(); }; const playRecord = (record: LibraryTrackRecord) => { setActionMessage(undefined); - setTrack(record.track, record.playlistId); + setTrack(record.track, record.playlistId, undefined, record.playlist); setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); }; - const toggleSelectedLike = () => { - if (!selectedRecord) { - return; - } - - const nextLiked = !isLiked(selectedRecord.track.id); + const removeRecord = (record: LibraryTrackRecord) => { const context = createRecommendationEventContext(); + const isRemovingLikedTrack = selectedTab === 'liked'; + setActionMessage(undefined); - setLikeState(selectedRecord.track, nextLiked, selectedRecord.playlistId); - void libraryApi - .updateTrackState(selectedRecord.track.id, { - action: nextLiked ? 'like' : 'unlike', - context, - playlistId: selectedRecord.playlistId, - }) - .catch(() => { - setLikeState(selectedRecord.track, !nextLiked, selectedRecord.playlistId); - setActionMessage('서버 저장에 실패해서 좋아요 상태를 되돌렸어요.'); - }); - syncRecommendationEvent( - addRecommendationEvent({ - context, - playlistId: selectedRecord.playlistId, - trackId: selectedRecord.track.id, - type: nextLiked ? 'track_like' : 'track_unlike', - }), - ); - if (selectedTab === 'liked' && !nextLiked) { - closeMenu(); - } - }; - const toggleSelectedSave = () => { - if (!selectedRecord) { + if (isRemovingLikedTrack) { + setLikeState(record.track, false, record.playlistId); + void libraryApi + .updateTrackState(record.track.id, { + action: 'unlike', + context, + playlistId: record.playlistId, + }) + .catch(() => { + setLikeState(record.track, true, record.playlistId, record.playlist); + setActionMessage('서버 저장에 실패해서 좋아요 상태를 되돌렸어요.'); + }); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: record.playlistId, + trackId: record.track.id, + type: 'track_unlike', + }), + ); + setActionMessage('좋아요 목록에서 삭제했어요.'); return; } - const nextSaved = !isSaved(selectedRecord.track.id); - const context = createRecommendationEventContext(); - setActionMessage(undefined); - setSaveState(selectedRecord.track, nextSaved, selectedRecord.playlistId); + setSaveState(record.track, false, record.playlistId); void libraryApi - .updateTrackState(selectedRecord.track.id, { - action: nextSaved ? 'save' : 'unsave', + .updateTrackState(record.track.id, { + action: 'unsave', context, - playlistId: selectedRecord.playlistId, + playlistId: record.playlistId, }) .catch(() => { - setSaveState(selectedRecord.track, !nextSaved, selectedRecord.playlistId); + setSaveState(record.track, true, record.playlistId, record.playlist); setActionMessage('서버 저장에 실패해서 저장 상태를 되돌렸어요.'); }); syncRecommendationEvent( addRecommendationEvent({ context, - playlistId: selectedRecord.playlistId, - trackId: selectedRecord.track.id, - type: nextSaved ? 'track_save' : 'track_unsave', + playlistId: record.playlistId, + trackId: record.track.id, + type: 'track_unsave', }), ); - - if (selectedTab === 'saved' && !nextSaved) { - closeMenu(); - } + setActionMessage('저장곡 목록에서 삭제했어요.'); }; return ( @@ -179,9 +186,9 @@ export function LibraryScreen() { showsVerticalScrollIndicator={false} > - Library + 보관함 - 좋아요한 음악과 다시 듣고 싶은 곡을 모아둬요. + 좋아요한 음악, 저장곡, 플레이리스트를 모아둬요. @@ -207,6 +214,14 @@ export function LibraryScreen() { })} + {actionMessage ? ( + + + {actionMessage} + + + ) : null} + {selectedTab === 'playlists' ? ( playlistRecords.length === 0 ? ( {playlistRecords.map((playlist) => { const representativeRecord = playlist.records[0]; + const imageUrl = + playlist.playlist?.coverImageUrl ?? + playlist.playlist?.backgroundImageUrl ?? + representativeRecord?.track.albumImageUrl; + const title = getPlaylistTitle(playlist.playlist, playlist.playlistId); + const description = getPlaylistDescription(playlist.playlist); return ( representativeRecord && playRecord(representativeRecord)} > - - {playlist.playlistId} - - - 저장한 곡 {playlist.records.length}개 - - {representativeRecord ? ( - - {representativeRecord.track.title} · {representativeRecord.track.artist} + + {imageUrl ? ( + + ) : null} + + + + + + 저장곡 {playlist.records.length} + + + {playlist.playlist?.durationText ? ( + + {playlist.playlist.durationText} + + ) : null} + + + {title} - ) : null} + + {description} + + {representativeRecord ? ( + + 대표곡 · {representativeRecord.track.title} / {representativeRecord.track.artist} + + ) : null} + + ); })} @@ -257,25 +301,55 @@ export function LibraryScreen() { {records.map((record) => ( setSelectedRecord(record)} onPress={() => playRecord(record)} + onRemove={() => removeRecord(record)} record={record} /> ))} )} -
- + + 마이 + + router.push({ + pathname: '/onboarding', + params: { mode: 'edit' }, + } as never) + } + > + + + + + 취향 수정 + + 장르 · 무드 · 여행 스타일 + + + + + router.push('/my' as never)} + > + + + + + 권한 설정 + + 위치 · 카메라 · 사진 + + + + + +
); } diff --git a/src/components/library/LibraryTrackRow.tsx b/src/components/library/LibraryTrackRow.tsx index fefd644..5855ea9 100644 --- a/src/components/library/LibraryTrackRow.tsx +++ b/src/components/library/LibraryTrackRow.tsx @@ -1,4 +1,3 @@ -import { Feather } from '@expo/vector-icons'; import { Image } from 'expo-image'; import { Pressable, View } from 'react-native'; @@ -7,57 +6,66 @@ import { LibraryTrackRecord } from '@/store/libraryStore'; import { formatRecapRecordedAt } from '@/utils/dateFormat'; type LibraryTrackRowProps = { - onOpenActions: () => void; onPress: () => void; + onRemove: () => void; record: LibraryTrackRecord; }; export function LibraryTrackRow({ - onOpenActions, onPress, + onRemove, record, }: LibraryTrackRowProps) { const { track } = record; return ( - - - {track.albumImageUrl ? ( - - ) : null} - - - - - {track.title} - - - {track.artist} - - - {formatRecapRecordedAt(record.createdAt)} - - - + { - event.stopPropagation(); - onOpenActions(); - }} + className="min-w-0 flex-1 flex-row items-center" + onPress={onPress} > - + + {track.albumImageUrl ? ( + + ) : null} + + + + + {track.title} + + + {track.artist} + + + {formatRecapRecordedAt(record.createdAt)} + + - + + + + 열기 + + + 삭제 + + +
); } diff --git a/src/components/moment-capture/CameraCaptureView.tsx b/src/components/moment-capture/CameraCaptureView.tsx index d123564..38c717a 100644 --- a/src/components/moment-capture/CameraCaptureView.tsx +++ b/src/components/moment-capture/CameraCaptureView.tsx @@ -16,6 +16,7 @@ type CameraCaptureViewProps = { locationStatus: LocationStatus; onCapture: () => void; onClose: () => void; + onSkipPhoto: () => void; }; export function CameraCaptureView({ @@ -25,6 +26,7 @@ export function CameraCaptureView({ locationStatus, onCapture, onClose, + onSkipPhoto, }: CameraCaptureViewProps) { return ( @@ -63,6 +65,16 @@ export function CameraCaptureView({ )} + + + 사진 없이 기록하기 + + ); diff --git a/src/components/moment-capture/CameraPermissionState.tsx b/src/components/moment-capture/CameraPermissionState.tsx index c4fc675..9cd7de7 100644 --- a/src/components/moment-capture/CameraPermissionState.tsx +++ b/src/components/moment-capture/CameraPermissionState.tsx @@ -6,12 +6,14 @@ import { AppText } from '@/components/AppText'; type CameraPermissionStateProps = { canAskAgain?: boolean; onRequest: () => void; + onSkipPhoto: () => void; status?: string; }; export function CameraPermissionState({ canAskAgain = true, onRequest, + onSkipPhoto, status, }: CameraPermissionStateProps) { const isDenied = status === 'denied'; @@ -36,6 +38,14 @@ export function CameraPermissionState({ {isDenied && !canAskAgain ? '설정 열기' : '카메라 권한 허용'} + + + 사진 없이 기록하기 + ); } diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx index 3701be2..003a51c 100644 --- a/src/components/moment-capture/MomentCaptureScreen.tsx +++ b/src/components/moment-capture/MomentCaptureScreen.tsx @@ -4,20 +4,26 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Platform, Pressable, View } from 'react-native'; import { momentLogApi } from '@/api/momentLogApi'; +import { syncRecommendationEvent } from '@/api/recommendationEventApi'; import { AppText } from '@/components/AppText'; import { CameraCaptureView } from '@/components/moment-capture/CameraCaptureView'; import { CameraPermissionState } from '@/components/moment-capture/CameraPermissionState'; import { MomentReviewPanel } from '@/components/moment-capture/MomentReviewPanel'; import { Screen } from '@/components/Screen'; import { useHomeFilterStore } from '@/store/homeFilterStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; +import { + useMomentLogStore, + type MomentLogCreateQueuePayload, +} from '@/store/momentLogStore'; import { usePlayerStore } from '@/store/playerStore'; +import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import { GeoPoint, MomentLog } from '@/types/domain'; import { getForegroundLocationWithTimeout } from '@/utils/location'; import { getMoodTagsFromFilter } from '@/utils/moodTags'; import { persistMomentPhoto } from '@/utils/momentFiles'; import { formatPlaceLabel } from '@/utils/placeLabel'; +import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; type LocationStatus = 'denied' | 'granted' | 'idle' | 'loading' | 'unavailable'; @@ -26,7 +32,9 @@ export function MomentCaptureScreen() { const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [capturedPhotoUri, setCapturedPhotoUri] = useState(); const [errorMessage, setErrorMessage] = useState(); + const [isReviewing, setIsReviewing] = useState(false); const [reviewMoodTags, setReviewMoodTags] = useState([]); + const [reviewNote, setReviewNote] = useState(''); const [reviewPlaceName, setReviewPlaceName] = useState(''); const [shouldSaveMusic, setShouldSaveMusic] = useState(true); const [isCapturing, setIsCapturing] = useState(false); @@ -34,14 +42,27 @@ export function MomentCaptureScreen() { const [locationStatus, setLocationStatus] = useState('idle'); const addLog = useMomentLogStore((state) => state.addLog); - const removeLog = useMomentLogStore((state) => state.removeLog); + const queueCreate = useMomentLogStore((state) => state.queueCreate); + const resolveLocalLog = useMomentLogStore((state) => state.resolveLocalLog); + const updateLog = useMomentLogStore((state) => state.updateLog); + const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); const { selectedMoodFilter } = useHomeFilterStore(); - const { currentTrack } = usePlayerStore(); + const { currentTrack, playlistId } = usePlayerStore(); const { currentLocation, currentPlace, selectedMode, session, setLocation, startSession } = useTravelSessionStore(); const moodTags = useMemo(() => getMoodTagsFromFilter(selectedMoodFilter), [selectedMoodFilter]); + const prepareReview = (photoUri?: string) => { + setCapturedPhotoUri(photoUri); + setReviewPlaceName(currentPlace?.title ?? formatPlaceLabel(currentLocation)); + setReviewMoodTags(moodTags); + setReviewNote(''); + setShouldSaveMusic(Boolean(currentTrack)); + setIsReviewing(true); + setErrorMessage(undefined); + }; + useEffect(() => { if (Platform.OS === 'web' || session.status !== 'active') { return; @@ -99,10 +120,7 @@ export function MomentCaptureScreen() { throw new Error('capture_failed'); } - setCapturedPhotoUri(photo.uri); - setReviewPlaceName(currentPlace?.title ?? formatPlaceLabel(currentLocation)); - setReviewMoodTags(moodTags); - setShouldSaveMusic(Boolean(currentTrack)); + prepareReview(photo.uri); } catch { setErrorMessage('사진을 촬영하지 못했어요. 다시 시도해주세요.'); } finally { @@ -111,7 +129,7 @@ export function MomentCaptureScreen() { }; const handleSave = async () => { - if (!capturedPhotoUri || isSaving) { + if (!isReviewing || isSaving) { return; } @@ -121,15 +139,25 @@ export function MomentCaptureScreen() { try { const id = `moment-${Date.now()}`; const locationSnapshot: GeoPoint | undefined = currentLocation; - const photoUri = await persistMomentPhoto(capturedPhotoUri, id); + const photoUri = capturedPhotoUri + ? await persistMomentPhoto(capturedPhotoUri, id) + : undefined; const createdAt = new Date().toISOString(); const placeName = reviewPlaceName.trim() || formatPlaceLabel(locationSnapshot); + const note = reviewNote.trim() || undefined; const trackSnapshot = shouldSaveMusic ? currentTrack : undefined; + const recommendationContext = createRecommendationEventContext({ + placeCategory: currentPlace?.category, + placeId: currentPlace?.id, + placeName, + travelMode: selectedMode, + }); const localLog: MomentLog = { createdAt, id, location: locationSnapshot, moodTags: reviewMoodTags, + note, placeCategory: currentPlace?.category, placeId: currentPlace?.id, photoUri, @@ -140,33 +168,56 @@ export function MomentCaptureScreen() { track: trackSnapshot, travelMode: selectedMode, }; + const createPayload: MomentLogCreateQueuePayload = { + createdAt, + location: locationSnapshot, + moodTags: reviewMoodTags, + note, + photoUri, + placeCategory: currentPlace?.category, + placeId: currentPlace?.id, + placeName, + sessionId: session.id, + track: trackSnapshot, + travelMode: selectedMode, + }; addLog(localLog); + queueCreate(id, createPayload); + syncRecommendationEvent( + addRecommendationEvent({ + context: recommendationContext, + playlistId, + trackId: trackSnapshot?.id, + type: 'moment_log_saved', + value: localLog.syncStatus, + }), + ); + void momentLogApi .createMomentLog({ - createdAt, + ...createPayload, idempotencyKey: id, - location: locationSnapshot, - moodTags: reviewMoodTags, - photoUri, - placeCategory: currentPlace?.category, - placeId: currentPlace?.id, - placeName, - sessionId: session.id, - track: trackSnapshot, - travelMode: selectedMode, }) .then((serverLog) => { if (!serverLog) { - addLog({ ...localLog, syncStatus: 'local' }); + updateLog(id, { syncStatus: 'local' }); return; } - removeLog(id); - addLog(serverLog); + resolveLocalLog(id, serverLog); }) .catch(() => { - addLog({ ...localLog, syncStatus: 'failed' }); + updateLog(id, { syncStatus: 'failed' }); + syncRecommendationEvent( + addRecommendationEvent({ + context: recommendationContext, + playlistId, + trackId: trackSnapshot?.id, + type: 'moment_log_sync_failed', + value: 'network_error', + }), + ); }); router.replace('/'); @@ -208,6 +259,33 @@ export function MomentCaptureScreen() { ); } + if (isReviewing) { + return ( + { + setCapturedPhotoUri(undefined); + setIsReviewing(false); + setErrorMessage(undefined); + }} + onSave={handleSave} + onToggleMusic={() => setShouldSaveMusic((value) => !value)} + photoUri={capturedPhotoUri} + placeName={reviewPlaceName} + track={currentTrack} + travelMode={selectedMode} + /> + ); + } + if (Platform.OS === 'web') { return ( @@ -216,13 +294,23 @@ export function MomentCaptureScreen() { 앱에서 사용할 수 있어요 - 순간 저장 카메라는 모바일 앱에서 카메라 권한과 함께 사용할 수 있어요. + 카메라 촬영은 모바일 앱에서 사용할 수 있어요. 대신 사진 없이 음악과 장소만 먼저 기록할 수 있습니다. prepareReview()} + > + + 사진 없이 기록하기 + + + router.back()} > - 돌아가기 + 돌아가기 @@ -235,36 +323,13 @@ export function MomentCaptureScreen() { void requestCameraPermission()} + onSkipPhoto={() => prepareReview()} status={cameraPermission?.status} /> ); } - if (capturedPhotoUri) { - return ( - { - setCapturedPhotoUri(undefined); - setErrorMessage(undefined); - }} - onSave={handleSave} - onToggleMusic={() => setShouldSaveMusic((value) => !value)} - photoUri={capturedPhotoUri} - placeName={reviewPlaceName} - track={currentTrack} - travelMode={selectedMode} - /> - ); - } - return ( router.back()} + onSkipPhoto={() => prepareReview()} /> ); } diff --git a/src/components/moment-capture/MomentReviewPanel.tsx b/src/components/moment-capture/MomentReviewPanel.tsx index e9c4d17..55dacef 100644 --- a/src/components/moment-capture/MomentReviewPanel.tsx +++ b/src/components/moment-capture/MomentReviewPanel.tsx @@ -37,12 +37,14 @@ type MomentReviewPanelProps = { isSaving: boolean; location?: GeoPoint; moodTags: MoodTag[]; + note: string; onChangeMoodTags: (moodTags: MoodTag[]) => void; + onChangeNote: (note: string) => void; onChangePlaceName: (placeName: string) => void; onRetake: () => void; onSave: () => void; onToggleMusic: () => void; - photoUri: string; + photoUri?: string; placeName: string; track?: Track; travelMode?: TravelMode; @@ -56,7 +58,9 @@ export function MomentReviewPanel({ isSaving, location, moodTags, + note, onChangeMoodTags, + onChangeNote, onChangePlaceName, onRetake, onSave, @@ -93,16 +97,33 @@ export function MomentReviewPanel({ > - 순간 저장 + 저장 확인 - + {photoUri ? ( + + ) : ( + + + + + + 사진 없이 음악만 남겨요 + + + 장소, 곡, 무드, 메모만으로도 여행 사운드트랙 로그를 만들 수 있어요. + + + )} @@ -187,6 +208,22 @@ export function MomentReviewPanel({ })} + + + + + 메모 + + + @@ -209,7 +246,9 @@ export function MomentReviewPanel({ disabled={isSaving} onPress={onRetake} > - 다시 찍기 + + {photoUri ? '다시 촬영하기' : '사진 촬영하기'} + diff --git a/src/components/my/AuthAccountCard.tsx b/src/components/my/AuthAccountCard.tsx index dd473a3..13ac306 100644 --- a/src/components/my/AuthAccountCard.tsx +++ b/src/components/my/AuthAccountCard.tsx @@ -1,21 +1,13 @@ import { Feather } from '@expo/vector-icons'; import { router } from 'expo-router'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { Alert, Linking, Pressable, View } from 'react-native'; -import { - useLocalDataMigrationMutation, - useLogoutMutation, -} from '@/api/authQueries'; +import { useLogoutMutation } from '@/api/authQueries'; import { AppText } from '@/components/AppText'; import { SOUNDLOG_SUPPORT_EMAIL } from '@/constants/legal'; import { useAuthStore } from '@/store/authStore'; -import { useLibraryStore } from '@/store/libraryStore'; -import { useMomentLogStore } from '@/store/momentLogStore'; - -function createMigrationKey() { - return `migration-${Date.now()}`; -} +import { migrateLocalDataToAccount } from '@/utils/localDataMigration'; function openAccountDeletionEmail(userId?: string, email?: string) { const subject = encodeURIComponent('Soundlog 계정 삭제 요청'); @@ -34,22 +26,10 @@ function openAccountDeletionEmail(userId?: string, email?: string) { } export function AuthAccountCard() { + const [isMigratingLocalData, setIsMigratingLocalData] = useState(false); const [migrationMessage, setMigrationMessage] = useState(); const logoutMutation = useLogoutMutation(); - const migrationMutation = useLocalDataMigrationMutation(); const { logoutLocal, refreshToken, status, user } = useAuthStore(); - const momentLogs = useMomentLogStore((state) => state.logs); - const likedTracks = useLibraryStore((state) => state.likedTracks); - const savedTracks = useLibraryStore((state) => state.savedTracks); - - const localDataSummary = useMemo( - () => ({ - libraryTrackCount: likedTracks.length + savedTracks.length, - momentLogCount: momentLogs.length, - recapDraftCount: Math.max(0, momentLogs.length > 0 ? 1 : 0), - }), - [likedTracks.length, momentLogs.length, savedTracks.length], - ); const handleLogout = async () => { try { @@ -62,18 +42,21 @@ export function AuthAccountCard() { const handleMigrate = async () => { setMigrationMessage(undefined); + setIsMigratingLocalData(true); try { - const result = await migrationMutation.mutateAsync({ - ...localDataSummary, - idempotencyKey: createMigrationKey(), - }); + const result = await migrateLocalDataToAccount(); + const failedCount = result.momentLogFailedCount + result.libraryFailedCount; setMigrationMessage( - `순간 ${result.migrated.momentLogCount}개, 보관함 ${result.migrated.libraryTrackCount}개를 동기화 큐에 올렸어요.`, + failedCount > 0 + ? `순간 ${result.momentLogSyncedCount}/${result.summary.momentLogCount}개, 보관함 ${result.librarySyncedCount}/${result.summary.libraryTrackCount}개를 동기화했어요. 실패한 항목은 다시 시도할 수 있어요.` + : `순간 ${result.summary.momentLogCount}개, 보관함 ${result.summary.libraryTrackCount}개를 서버 동기화에 반영했어요.`, ); } catch { setMigrationMessage('동기화 요청에 실패했어요. 네트워크 상태를 확인해주세요.'); + } finally { + setIsMigratingLocalData(false); } }; @@ -126,11 +109,11 @@ export function AuthAccountCard() { - {migrationMutation.isPending ? '동기화 중' : '로컬 기록 동기화'} + {isMigratingLocalData ? '동기화 중' : '로컬 기록 동기화'} - + 계정 - {status === 'guest' ? '게스트로 둘러보는 중' : '로그인이 필요해요'} + 로그인이 필요해요 - 로그인하면 이 기기의 취향과 여행 기록을 서버 계정으로 이어갈 수 있어요. + Soundlog의 추천, 기록, Recap은 계정에 저장된 상태로 사용할 수 있어요. diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index 6160ac0..e12edb9 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -132,6 +132,13 @@ export function BottomNavigation() { title: '여행', }} /> + = { - preferredGenres: [ - 'K-POP', - '팝', - '인디', - '발라드', - '힙합', - 'R&B', - 'OST', - '시티팝', - ], - preferredMoods: [ - '잔잔한', - '신나는', - '시원한', - '설레는', - '감성적인', - '몽환적인', - '드라이브', - ], - travelStyles: [ - '산책', - '드라이브', - '카페 투어', - '바다 보기', - '야경 감상', - ], -}; +const moodOptions = ['잔잔한', '신나는', '시원한', '설레는', '감성적인']; +const defaultTravelLabel = '산책'; +const defaultMood = '잔잔한'; + +function getTravelLabelFromProfile(profile: UserProfileInput) { + const selectedTravel = travelOptions.find((option) => + profile.travelStyles.includes(option.profileValue), + ); + + return selectedTravel?.label ?? defaultTravelLabel; +} -const companionOptions = ['혼자', '친구', '연인', '가족']; - -const stepAccents = [ - ['#1DB954', '#7CFF8A'], - ['#27D3FF', '#B66BFF'], - ['#FFB84D', '#FF4FD8'], - ['#1DB954', '#2CE6B8'], -] as const; - -const stepHints = [ - 'Genre seed', - 'Mood tempo', - 'Travel context', - 'Social mix', -] as const; - -function toggleValue(values: string[], value: string) { - return values.includes(value) - ? values.filter((item) => item !== value) - : [...values, value]; +function getMoodFromProfile(profile: UserProfileInput) { + return moodOptions.find((mood) => profile.preferredMoods.includes(mood)) ?? defaultMood; } -function getInitialDraft(profile: UserProfileInput): UserProfileInput { +function buildProfileInput({ + baseProfile, + locationRecommendationEnabled, + selectedMood, + selectedTravelLabel, +}: { + baseProfile: UserProfileInput; + locationRecommendationEnabled: boolean; + selectedMood: string; + selectedTravelLabel: string; +}): UserProfileInput { + const selectedTravel = + travelOptions.find((option) => option.label === selectedTravelLabel) ?? travelOptions[2]; + return { - companionType: profile.companionType, - locationRecommendationEnabled: profile.locationRecommendationEnabled, - preferredGenres: profile.preferredGenres, - preferredMoods: profile.preferredMoods, - travelStyles: profile.travelStyles, + companionType: baseProfile.companionType, + locationRecommendationEnabled, + preferredGenres: baseProfile.preferredGenres, + preferredMoods: selectedMood ? [selectedMood] : [defaultMood], + travelStyles: selectedTravel ? [selectedTravel.profileValue] : ['산책'], }; } @@ -109,45 +72,31 @@ export function OnboardingScreen() { const params = useLocalSearchParams<{ mode?: string | string[] }>(); const mode = Array.isArray(params.mode) ? params.mode[0] : params.mode; const isEditMode = mode === 'edit'; - const { completeOnboarding, profile, skipOnboarding, updateProfile } = - useUserProfileStore(); + const { completeOnboarding, profile, updateProfile } = useUserProfileStore(); + const { status } = useAuthStore(); const { setSelectedMoodFilter, setSelectedTopFilter } = useHomeFilterStore(); - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [draft, setDraft] = useState(() => - getInitialDraft(profile), + const [currentStep, setCurrentStep] = useState(isEditMode ? 'setup' : 'intro'); + const [selectedTravelLabel, setSelectedTravelLabel] = useState(() => + getTravelLabelFromProfile(profile), + ); + const [selectedMood, setSelectedMood] = useState(() => getMoodFromProfile(profile)); + const [locationRecommendationEnabled, setLocationRecommendationEnabled] = useState( + profile.locationRecommendationEnabled, ); const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(); - const currentStep = steps[currentStepIndex]; - const isLastStep = currentStepIndex === steps.length - 1; - const progressLabel = `${currentStepIndex + 1}/${steps.length}`; - const currentAccent = stepAccents[currentStepIndex]; - const currentHint = stepHints[currentStepIndex]; - - const selectedCount = useMemo( + const draft = useMemo( () => - draft.preferredGenres.length + - draft.preferredMoods.length + - draft.travelStyles.length + - (draft.companionType ? 1 : 0), - [draft], + buildProfileInput({ + baseProfile: profile, + locationRecommendationEnabled, + selectedMood, + selectedTravelLabel, + }), + [locationRecommendationEnabled, profile, selectedMood, selectedTravelLabel], ); - const currentStepSelectedCount = useMemo(() => { - if (currentStep.key === 'companion') { - return draft.companionType ? 1 : 0; - } - - return draft[currentStep.key].length; - }, [currentStep.key, draft]); - - const canProceed = currentStepSelectedCount > 0; - - const updateMultiSelect = (key: MultiSelectKey, value: string) => { - setDraft((prev) => ({ ...prev, [key]: toggleValue(prev[key], value) })); - }; - const applyHomeFilters = (input: UserProfileInput) => { setSelectedTopFilter('전체'); setSelectedMoodFilter(input.preferredMoods[0] ?? '전체'); @@ -168,7 +117,13 @@ export function OnboardingScreen() { } }; - const finish = async (input: UserProfileInput) => { + const enterHome = async (input: UserProfileInput) => { + if (status !== 'authenticated') { + setSaveErrorMessage('Soundlog를 시작하려면 먼저 로그인해주세요.'); + router.push('/auth/login' as never); + return; + } + const didSave = await saveProfile(input); if (!didSave) { @@ -187,314 +142,295 @@ export function OnboardingScreen() { router.replace('/'); }; - const handlePrimaryPress = () => { - if (!canProceed || isSaving) { + const handlePrimarySetup = (enabledLocation: boolean) => { + if (isSaving) { return; } - if (!isLastStep) { - setCurrentStepIndex((prev) => prev + 1); - return; - } + const input = { + ...draft, + locationRecommendationEnabled: enabledLocation, + }; - void finish(draft); + void enterHome(input); }; - const handleSkip = async () => { - if (isSaving) { - return; - } + const renderIntro = () => ( + <> + + + + Soundlog + + router.push('/auth/login' as never)} + style={{ opacity: isSaving ? 0.45 : 1 }} + > + 로그인 + + - if (isEditMode) { - router.replace('/my' as never); - return; - } + + + 지금 장소의 음악을{'\n'}여행 앨범으로 + + + 음악은 외부 앱에서 듣고, Soundlog에는 여행의 사운드트랙을 남겨요. + + - const skippedProfile = { - companionType: undefined, - locationRecommendationEnabled: true, - preferredGenres: [], - preferredMoods: [], - travelStyles: [], - }; - const didSave = await saveProfile(skippedProfile); + + + + + + Recap preview + + + 서울숲 산책{'\n'}사운드트랙 + + + + + + - if (!didSave) { - return; - } + + + ALBUM + 대표 곡 + 사진과 장소를 한 장으로 + + + FILM + + {[0, 1, 2, 3].map((item) => ( + + ))} + + + - skipOnboarding(); - setSelectedTopFilter('전체'); - setSelectedMoodFilter('전체'); - router.replace('/'); - }; + + + 앱 안에서는 음원을 재생하지 않고, 곡 정보와 외부 링크, 여행 기록을 이어 붙입니다. + + + + + + + {saveErrorMessage ? ( + + + {saveErrorMessage} + + + ) : null} + + { + if (status !== 'authenticated') { + router.push('/auth/login' as never); + return; + } + + setCurrentStep('setup'); + }} + style={{ opacity: isSaving ? 0.55 : 1 }} + > + + {status === 'authenticated' ? '시작하기' : '로그인하고 시작하기'} + + + router.push('/auth/login' as never)} + style={{ opacity: isSaving ? 0.55 : 1 }} + > + + 계정 만들기 또는 로그인 + + + + + ); + + const renderSetup = () => ( + <> + + (isEditMode ? router.replace('/my' as never) : setCurrentStep('intro'))} + > + + + handlePrimarySetup(false)} + style={{ opacity: isSaving ? 0.45 : 1 }} + > + + {isEditMode ? '변경 없이 나가기' : '나중에 하기'} + + + + + + + 어떤 여행 중인가요? + + + 지금 장면과 듣고 싶은 분위기만 고르면 첫 추천을 바로 시작할게요. + + + + + {travelOptions.map((option) => { + const selected = selectedTravelLabel === option.label; + + return ( + setSelectedTravelLabel(option.label)} + > + + {option.label} + + + ); + })} + - const renderStepBody = () => { - if (currentStep.key === 'companion') { - return ( - - - {companionOptions.map((option) => ( + + + 어떤 분위기인가요? + + + {moodOptions.map((mood) => { + const selected = selectedMood === mood; + + return ( - setDraft((prev) => ({ ...prev, companionType: option })) - } + key={mood} + onPress={() => setSelectedMood(mood)} > - {option} + {mood} - ))} - + ); + })} + + - - - - - 위치 기반 추천 - - - 현재 장소와 가까운 관광 맥락을 음악 추천에 반영해요. - - - - setDraft((prev) => ({ - ...prev, - locationRecommendationEnabled: value, - })) - } - thumbColor="#ffffff" - trackColor={{ - false: 'rgba(255,255,255,0.18)', - true: '#1DB954', - }} - value={draft.locationRecommendationEnabled} - /> + + 권한 + + + + + 현재 위치로 추천받기 + + + 거부해도 산책·잔잔한 기본 추천과 수동 탐색은 계속 가능해요. + + - ); - } - - const multiSelectKey = currentStep.key; + - return ( - - {options[multiSelectKey].map((option) => ( - updateMultiSelect(multiSelectKey, option)} - > - - {option} + + {saveErrorMessage ? ( + + + {saveErrorMessage} - - ))} + + ) : null} + + handlePrimarySetup(true)} + style={{ opacity: isSaving ? 0.55 : 1 }} + > + + {isSaving ? '저장 중...' : '위치 추천 켜기'} + + + handlePrimarySetup(false)} + style={{ opacity: isSaving ? 0.55 : 1 }} + > + 나중에 하기 + - ); - }; + + ); return ( - - - void handleSkip()} - style={{ opacity: isSaving ? 0.5 : 1 }} - > - - {isEditMode ? '변경 없이 나가기' : '나중에 하기'} - - - - - - - - {isEditMode ? 'Soundlog profile' : 'Soundlog taste scan'} - - - - {isEditMode - ? '지금 여행 취향에 맞게\n추천을 다시 맞춰요' - : '내 여행을 닮은\n믹스를 만들어볼게요'} - - - {isEditMode - ? '수정한 값은 홈 추천과 무드 필터의 기본값으로 다시 반영돼요.' - : '장르, 무드, 여행 장면을 조합해 위치 기반 추천의 첫 플레이리스트를 세팅해요.'} - - - - - - - - - {currentHint} - - - {progressLabel} - - - - - 전체 선택 {selectedCount}개 - - - - - - - - - - {[22, 34, 18, 42, 28, 50, 24, 38].map((height, index) => ( - - ))} - - - - {currentStep.title} - - - {currentStep.description} - - - {renderStepBody()} - - {!canProceed ? ( - - 하나 이상 선택하면 다음 믹스로 넘어갈 수 있어요. - - ) : null} - - - - - {saveErrorMessage ? ( - - - {saveErrorMessage} - - - ) : null} - - - - {isSaving - ? '저장 중...' - : isLastStep - ? isEditMode - ? '수정 완료' - : '완료하고 시작하기' - : '다음'} - - - - setCurrentStepIndex((prev) => prev - 1)} - > - - 이전 - - - + {currentStep === 'intro' ? renderIntro() : renderSetup()} ); diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx index 1cbe213..c7d48e6 100644 --- a/src/components/playlist/PlaylistCurationScreen.tsx +++ b/src/components/playlist/PlaylistCurationScreen.tsx @@ -15,13 +15,14 @@ import { PlaylistErrorState, PlaylistLoadingState, } from '@/components/playlist/PlaylistState'; -import { TrackActionMenu } from '@/components/playlist/TrackActionMenu'; import { TrackList } from '@/components/playlist/TrackList'; import { getCurationListBottomPadding, getMiniPlayerBottom } from '@/constants/layout'; +import { useHomeFilterStore } from '@/store/homeFilterStore'; import { useLibraryStore } from '@/store/libraryStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import { Track } from '@/types/domain'; +import { toLibraryPlaylistSummary } from '@/utils/libraryPlaylistSummary'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; type PlaylistCurationScreenProps = { @@ -32,6 +33,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro const insets = useSafeAreaInsets(); const { height } = useWindowDimensions(); const { currentTrack, setTrack } = usePlayerStore(); + const { selectedMoodFilter, setSelectedMoodFilter } = useHomeFilterStore(); const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); const { isLiked, @@ -44,21 +46,20 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro } = useLibraryStore(); const { data: playlist, isError, isLoading, refetch } = usePlaylistCurationQuery(playlistId); - const [selectedTrackId, setSelectedTrackId] = useState(); const [actionMessage, setActionMessage] = useState(); + const playlistSummary = useMemo( + () => (playlist ? toLibraryPlaylistSummary(playlist) : undefined), + [playlist], + ); useEffect(() => { - if (!playlist) { + if (!playlist || !playlistSummary) { return; } - seedFromPlaylist(playlist.id, playlist.tracks); - }, [playlist, seedFromPlaylist]); + seedFromPlaylist(playlist.id, playlist.tracks, playlistSummary); + }, [playlist, playlistSummary, seedFromPlaylist]); - const selectedTrack = useMemo( - () => playlist?.tracks.find((track) => track.id === selectedTrackId), - [playlist?.tracks, selectedTrackId], - ); const likedTrackIds = useMemo( () => new Set( @@ -87,8 +88,19 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro return; } + const context = createRecommendationEventContext(); + setActionMessage(undefined); - setTrack(track, playlist.id, playlist.tracks); + setTrack(track, playlist.id, playlist.tracks, playlistSummary); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: playlist.id, + trackId: track.id, + type: 'track_selected', + value: 'playlist_detail', + }), + ); setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); }; @@ -102,67 +114,71 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro selectTrackForSoundlog(firstTrack); }; - const toggleLiked = () => { - if (!selectedTrack) { - return; - } - - const nextLiked = !isLiked(selectedTrack.id); + const toggleLikedTrack = (track: Track) => { + const nextLiked = !isLiked(track.id); const context = createRecommendationEventContext(); + setActionMessage(undefined); - setLikeState(selectedTrack, nextLiked, playlist?.id); + setLikeState(track, nextLiked, playlist?.id, playlistSummary); void libraryApi - .updateTrackState(selectedTrack.id, { + .updateTrackState(track.id, { action: nextLiked ? 'like' : 'unlike', context, playlistId: playlist?.id, }) .catch(() => { - setLikeState(selectedTrack, !nextLiked, playlist?.id); + setLikeState(track, !nextLiked, playlist?.id, playlistSummary); setActionMessage('서버 저장에 실패해서 좋아요 상태를 되돌렸어요.'); }); syncRecommendationEvent( addRecommendationEvent({ context, playlistId: playlist?.id, - trackId: selectedTrack.id, + trackId: track.id, type: nextLiked ? 'track_like' : 'track_unlike', }), ); }; - const toggleSaved = () => { - if (!selectedTrack) { - return; - } - - const nextSaved = !isSaved(selectedTrack.id); + const toggleSavedTrack = (track: Track) => { + const nextSaved = !isSaved(track.id); const context = createRecommendationEventContext(); + setActionMessage(undefined); - setSaveState(selectedTrack, nextSaved, playlist?.id); + setSaveState(track, nextSaved, playlist?.id, playlistSummary); void libraryApi - .updateTrackState(selectedTrack.id, { + .updateTrackState(track.id, { action: nextSaved ? 'save' : 'unsave', context, playlistId: playlist?.id, }) .catch(() => { - setSaveState(selectedTrack, !nextSaved, playlist?.id); + setSaveState(track, !nextSaved, playlist?.id, playlistSummary); setActionMessage('서버 저장에 실패해서 저장 상태를 되돌렸어요.'); }); syncRecommendationEvent( addRecommendationEvent({ context, playlistId: playlist?.id, - trackId: selectedTrack.id, + trackId: track.id, type: nextSaved ? 'track_save' : 'track_unsave', }), ); }; - const closeMenu = () => { - setSelectedTrackId(undefined); - setActionMessage(undefined); + const handleAdjustMood = (filter: string) => { + const context = createRecommendationEventContext({ moodFilter: filter }); + + setSelectedMoodFilter(filter); + setActionMessage(`${filter} 무드로 다음 추천 방향을 조정했어요.`); + syncRecommendationEvent( + addRecommendationEvent({ + context, + playlistId: playlist?.id, + type: 'mood_adjusted', + value: filter, + }), + ); }; const playlistContent = isLoading ? ( @@ -175,8 +191,9 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro bottomPadding={listBottomPadding} currentTrackId={currentTrack?.id} likedTrackIds={likedTrackIds} - onOpenMenu={(track) => setSelectedTrackId(track.id)} onSelectTrack={selectTrackForSoundlog} + onToggleLike={toggleLikedTrack} + onToggleSave={toggleSavedTrack} savedTrackIds={savedTrackIds} tracks={playlist.tracks} /> @@ -196,8 +213,10 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro {playlist ? ( ) : null} {playlistContent} @@ -208,8 +227,10 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro playlist ? ( ) : undefined } @@ -218,17 +239,6 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro )} - - {actionMessage ? ( void; onOpenFirstTrack: () => void; playlist: PlaylistCuration; + selectedMoodFilter: string; }; +function getRecommendationSourceCopy(source?: string) { + if (source === 'ml-recommendation') { + return { + badgeClassName: 'border-[#B7E628]/20 bg-[#B7E628]/15', + description: '현재 위치, 여행 상태, 무드를 기준으로 추천했어요.', + label: 'ML 추천', + textClassName: 'text-[#B7E628]', + }; + } + + if (source === 'seed-fallback') { + return { + badgeClassName: 'border-amber-300/20 bg-amber-300/15', + description: '추천 서버 응답이 없어서 기본 사운드트랙을 보여줘요.', + label: '기본 추천', + textClassName: 'text-amber-100', + }; + } + + if (source === 'server-contextual') { + return { + badgeClassName: 'border-white/15 bg-white/10', + description: '현재 선택한 장소와 무드를 서버에서 정리한 추천이에요.', + label: '맞춤 추천', + textClassName: 'text-white/80', + }; + } + + return undefined; +} + export function PlaylistHeroInfo({ disabled = false, + onAdjustMood, onOpenFirstTrack, playlist, + selectedMoodFilter, }: PlaylistHeroInfoProps) { + const sourceCopy = getRecommendationSourceCopy(playlist.context?.source); + return ( @@ -31,14 +68,26 @@ export function PlaylistHeroInfo({ {playlist.reason} - + {playlist.trackCount}곡 {playlist.durationText} + {sourceCopy ? ( + + + {sourceCopy.label} + + + ) : null} + {sourceCopy?.description ? ( + + {sourceCopy.description} + + ) : null} - + ); diff --git a/src/components/playlist/PlaylistMusicFilter.tsx b/src/components/playlist/PlaylistMusicFilter.tsx index 04ab68b..1233860 100644 --- a/src/components/playlist/PlaylistMusicFilter.tsx +++ b/src/components/playlist/PlaylistMusicFilter.tsx @@ -1,13 +1,21 @@ -import { useState } from 'react'; import { Pressable, ScrollView, View } from 'react-native'; import { AppText } from '@/components/AppText'; -const musicFilters = ['전체', '드라이브', '산책', '시원한 바람', '신나는']; +const moodAdjustments = [ + { filter: '잔잔한', label: '더 잔잔하게' }, + { filter: '신나는', label: '더 신나게' }, +]; -export function PlaylistMusicFilter() { - const [selectedFilter, setSelectedFilter] = useState(musicFilters[0]); +type PlaylistMusicFilterProps = { + onSelectMoodFilter: (filter: string) => void; + selectedMoodFilter: string; +}; +export function PlaylistMusicFilter({ + onSelectMoodFilter, + selectedMoodFilter, +}: PlaylistMusicFilterProps) { return ( - {musicFilters.map((filter) => { - const isSelected = selectedFilter === filter; + {moodAdjustments.map(({ filter, label }) => { + const isSelected = selectedMoodFilter === filter; return ( setSelectedFilter(filter)} + onPress={() => onSelectMoodFilter(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)', + backgroundColor: isSelected ? '#B7E628' : 'rgba(255, 255, 255, 0.09)', + borderColor: isSelected ? '#B7E628' : 'rgba(255, 255, 255, 0.22)', }} > - {filter} + {label} ); diff --git a/src/components/playlist/TrackList.tsx b/src/components/playlist/TrackList.tsx index 0017643..0434fff 100644 --- a/src/components/playlist/TrackList.tsx +++ b/src/components/playlist/TrackList.tsx @@ -8,8 +8,9 @@ type TrackListProps = { bottomPadding: number; currentTrackId?: string; likedTrackIds: Set; - onOpenMenu: (track: Track) => void; onSelectTrack: (track: Track) => void; + onToggleLike: (track: Track) => void; + onToggleSave: (track: Track) => void; savedTrackIds: Set; tracks: Track[]; }; @@ -18,8 +19,9 @@ export function TrackList({ bottomPadding, currentTrackId, likedTrackIds, - onOpenMenu, onSelectTrack, + onToggleLike, + onToggleSave, savedTrackIds, tracks, }: TrackListProps) { @@ -38,14 +40,21 @@ export function TrackList({ return ( + + 추천 곡 + + 좋아요 · 저장 · 열기로 지금 장소의 사운드트랙을 골라보세요. + + {tracks.map((item) => ( ))} diff --git a/src/components/playlist/TrackRow.tsx b/src/components/playlist/TrackRow.tsx index cbc9279..157a82c 100644 --- a/src/components/playlist/TrackRow.tsx +++ b/src/components/playlist/TrackRow.tsx @@ -11,12 +11,21 @@ type TrackRowProps = { isActive: boolean; isLiked: boolean; isSaved: boolean; - onMore: (track: Track) => void; onPress: (track: Track) => void; + onToggleLike: (track: Track) => void; + onToggleSave: (track: Track) => void; track: Track; }; -export function TrackRow({ isActive, isLiked, isSaved, onMore, onPress, track }: TrackRowProps) { +export function TrackRow({ + isActive, + isLiked, + isSaved, + onPress, + onToggleLike, + onToggleSave, + track, +}: TrackRowProps) { const keyColor = getTrackKeyColor(track); const activeBackground = hexToRgba(keyColor, 0.78); const activeGlow = hexToRgba(keyColor, 0.24); @@ -80,14 +89,42 @@ export function TrackRow({ isActive, isLiked, isSaved, onMore, onPress, track }: - onMore(track)} - > - - + + onToggleLike(track)} + > + + + + onToggleSave(track)} + > + + {isSaved ? '저장됨' : '저장'} + + + + onPress(track)} + > + + 열기 + + + ); diff --git a/src/components/recap-share/RecapCaptureFrame.tsx b/src/components/recap-share/RecapCaptureFrame.tsx index e1b30fb..9724a08 100644 --- a/src/components/recap-share/RecapCaptureFrame.tsx +++ b/src/components/recap-share/RecapCaptureFrame.tsx @@ -5,10 +5,12 @@ export type RecapCaptureFrameHandle = { capture: () => Promise; }; -type RecapCaptureFrameProps = PropsWithChildren; +type RecapCaptureFrameProps = PropsWithChildren<{ + aspectRatio?: number; +}>; export const RecapCaptureFrame = forwardRef( - function RecapCaptureFrame({ children }, ref) { + function RecapCaptureFrame({ aspectRatio = 3 / 4, children }, ref) { const viewShotRef = useRef>(null); useImperativeHandle(ref, () => ({ @@ -19,7 +21,7 @@ export const RecapCaptureFrame = forwardRef {children} diff --git a/src/components/recap-share/RecapMusicSummary.tsx b/src/components/recap-share/RecapMusicSummary.tsx index 6107aa1..709a292 100644 --- a/src/components/recap-share/RecapMusicSummary.tsx +++ b/src/components/recap-share/RecapMusicSummary.tsx @@ -34,6 +34,10 @@ function createTrackKey(moment: RecapShareMoment) { return `${moment.trackTitle.trim()}::${moment.artistName.trim()}`; } +function createPhotoCount(moments: RecapShareMoment[]) { + return moments.filter((moment) => Boolean(moment.imageUrl)).length; +} + function createPlaceFlow(moments: RecapShareMoment[], fallbackPlaceName: string) { const firstPlace = moments[0]?.placeName?.trim() || fallbackPlaceName; const lastPlace = moments[moments.length - 1]?.placeName?.trim() || fallbackPlaceName; @@ -50,41 +54,26 @@ function createRecordedRange(moments: RecapShareMoment[], fallbackRecordedAt: st return firstLabel === lastLabel ? firstLabel : `${firstLabel} -> ${lastLabel}`; } -function SummaryRow({ icon, label, value }: SummaryRowProps) { - return ( - - - - - - {label} - - {value} - - - - ); -} - export function RecapMusicSummary({ recap }: { recap: RecapShare }) { const moments = getMoments(recap); + const photoCount = createPhotoCount(moments); const placeCount = createUniqueCount(moments.map((moment) => moment.placeName)); const trackCount = createUniqueCount(moments.map(createTrackKey)); const placeFlow = createPlaceFlow(moments, recap.placeName); const recordedRange = createRecordedRange(moments, recap.recordedAt); return ( - + - - MUSIC RECAP + + 대표 정보 - {recap.trackTitle} + {recap.placeName} · {recap.trackTitle} - {recap.artistName} + 사진 {photoCount}장 · 곡 {trackCount || 1}개 · {recap.artistName} @@ -92,12 +81,9 @@ export function RecapMusicSummary({ recap }: { recap: RecapShare }) { - - - - - - + + {moments.length}개 Moment · {placeCount || 1}곳 · {placeFlow} · {recordedRange} + ); } diff --git a/src/components/recap-share/RecapPreviewCard.tsx b/src/components/recap-share/RecapPreviewCard.tsx index 0648795..69ca41a 100644 --- a/src/components/recap-share/RecapPreviewCard.tsx +++ b/src/components/recap-share/RecapPreviewCard.tsx @@ -260,6 +260,109 @@ function RecapFilmTemplate({ recap }: { recap: RecapShare }) { ); } +function RecapMapTemplate({ recap }: { recap: RecapShare }) { + const moments = ( + recap.moments?.length ? recap.moments : [createFallbackMoment(recap)] + ).slice(0, 4); + const pinPositions = [ + { left: 54, top: 126 }, + { left: 190, top: 92 }, + { left: 226, top: 220 }, + { left: 104, top: 270 }, + ]; + + return ( + <> + + + {Array.from({ length: 5 }).map((_, index) => ( + + ))} + {Array.from({ length: 4 }).map((_, index) => ( + + ))} + + + + + + + SOUNDLOG MAP + + + {recap.placeName} + + + + {moments.map((moment, index) => ( + + + + {index + 1} + + + + + {moment.placeName} + + + + ))} + + + + 대표 사운드 + + + {recap.trackTitle} + + + {moments.length}개 장소 · {recap.artistName} + + + + ); +} + export function RecapPreviewCard({ recap, template = 'lp', @@ -269,6 +372,7 @@ export function RecapPreviewCard({ {template === 'album' ? : null} {template === 'lp' ? : null} {template === 'film' ? : null} + {template === 'map' ? : null} ); } diff --git a/src/components/recap-share/RecapShareScreen.tsx b/src/components/recap-share/RecapShareScreen.tsx index cc052be..19bc35c 100644 --- a/src/components/recap-share/RecapShareScreen.tsx +++ b/src/components/recap-share/RecapShareScreen.tsx @@ -21,7 +21,7 @@ import { Screen } from '@/components/Screen'; import { getTabBarHeight } from '@/constants/layout'; import { useRecapShareActions } from '@/hooks/useRecapShareActions'; import { useMomentLogStore } from '@/store/momentLogStore'; -import { RecapTemplateId } from '@/types/domain'; +import { RecapShare, RecapTemplateId } from '@/types/domain'; import { formatRecapRecordedAt } from '@/utils/dateFormat'; import { createMomentLogGroups, @@ -34,9 +34,18 @@ type RecapShareScreenProps = { recapId?: string; }; +function createRecapTitle(recap?: RecapShare | null) { + if (!recap) { + return 'Recap'; + } + + return `${recap.placeName} 사운드`; +} + export function RecapShareScreen({ recapId }: RecapShareScreenProps) { const insets = useSafeAreaInsets(); - const [selectedTemplate, setSelectedTemplate] = useState('lp'); + const [selectedTemplate, setSelectedTemplate] = useState('album'); + const captureAspectRatio = selectedTemplate === 'album' ? 1 : 3 / 4; const momentLogs = useMomentLogStore((state) => state.logs); const sessionId = extractSessionIdFromRecapId(recapId); const localMomentGroup = useMemo( @@ -54,6 +63,7 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { : localMomentLog ? momentLogToRecapShare(localMomentLog) : undefined; + const isLocalRecap = Boolean(localRecap); const { data: remoteRecap, isError, @@ -64,7 +74,7 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { const captureRef = useRef(null); const { activeAction, message, save, share } = useRecapShareActions({ capture: () => captureRef.current?.capture() ?? Promise.resolve(undefined), - recapId, + recapId: isLocalRecap ? undefined : recapId, }); return ( @@ -74,12 +84,17 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { alignItems: 'center', paddingBottom: getTabBarHeight(insets.bottom) + 28, paddingHorizontal: 20, - paddingTop: 72, + paddingTop: 32, }} showsVerticalScrollIndicator={false} > - - Share Your Music + + {createRecapTitle(recap)} + + + {recap + ? '사운드트랙 앨범 결과물을 저장하거나 공유해요.' + : '여행의 사운드트랙 앨범을 불러오고 있어요.'} @@ -91,21 +106,36 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) { ) : ( <> - - - + + - + + {isLocalRecap ? ( + + + 서버 동기화 전 로컬 Recap이에요. 이미지 저장과 공유는 가능하고, Moment 동기화 후 서버 Recap으로 저장할 수 있어요. + + + ) : null} + + + + + + {formatRecapRecordedAt(recap.recordedAt)} - - + + + + + void; + textColor: string; }; export function ShareActionButton({ - color, + backgroundColor, disabled = false, icon, + iconColor, isActive = false, label, + loadingLabel, onPress, + textColor, }: ShareActionButtonProps) { return ( - - + + - - {isActive ? '처리 중' : label} + + {isActive ? loadingLabel : label} ); diff --git a/src/components/recap-share/ShareActionList.tsx b/src/components/recap-share/ShareActionList.tsx index 0a64315..a77008a 100644 --- a/src/components/recap-share/ShareActionList.tsx +++ b/src/components/recap-share/ShareActionList.tsx @@ -1,15 +1,34 @@ -import { ScrollView, View } from 'react-native'; +import { View } from 'react-native'; import { ShareActionButton, ShareActionId } from '@/components/recap-share/ShareActionButton'; const actions: Array<{ - color: string; + backgroundColor: string; icon: 'download' | 'share-2'; + iconColor: string; id: ShareActionId; label: string; + loadingLabel: string; + textColor: string; }> = [ - { color: '#2B2B2F', icon: 'download', id: 'save', label: 'Save' }, - { color: '#20146F', icon: 'share-2', id: 'share', label: 'Share' }, + { + backgroundColor: '#B7E628', + icon: 'download', + iconColor: '#050916', + id: 'save', + label: '이미지 저장', + loadingLabel: '저장 중', + textColor: '#050916', + }, + { + backgroundColor: 'rgba(255,255,255,0.1)', + icon: 'share-2', + iconColor: '#FFFFFF', + id: 'share', + label: '공유하기', + loadingLabel: '공유 중', + textColor: '#FFFFFF', + }, ]; type ShareActionListProps = { @@ -20,20 +39,21 @@ type ShareActionListProps = { export function ShareActionList({ activeAction, isBusy = false, onAction }: ShareActionListProps) { return ( - - - {actions.map((action) => ( - onAction(action.id)} - /> - ))} - - + + {actions.map((action) => ( + onAction(action.id)} + textColor={action.textColor} + /> + ))} + ); } diff --git a/src/components/recap/RecapListCard.tsx b/src/components/recap/RecapListCard.tsx index 722bdad..065683d 100644 --- a/src/components/recap/RecapListCard.tsx +++ b/src/components/recap/RecapListCard.tsx @@ -83,7 +83,7 @@ export function RecapListCard({ imageUrl, item, onPress }: RecapListCardProps) { {item.representativeTrack.artist} diff --git a/src/components/recap/RecapListScreen.tsx b/src/components/recap/RecapListScreen.tsx index 18d3bdf..54a8b91 100644 --- a/src/components/recap/RecapListScreen.tsx +++ b/src/components/recap/RecapListScreen.tsx @@ -1,15 +1,22 @@ +import { useQueryClient } from '@tanstack/react-query'; import { router } from 'expo-router'; import { LinearGradient } from 'expo-linear-gradient'; -import { ScrollView, View } from 'react-native'; +import { useMemo, useState } from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; -import { useRecapListQuery } from '@/api/recapQueries'; +import { recapApi } from '@/api/recapApi'; +import { recapQueryKeys, useRecapListQuery } from '@/api/recapQueries'; import { AppText } from '@/components/AppText'; import { RecapEmptyState } from '@/components/recap/RecapEmptyState'; import { RecapListCard } from '@/components/recap/RecapListCard'; import { Screen } from '@/components/Screen'; import { useMomentLogStore } from '@/store/momentLogStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; +import type { MomentLog } from '@/types/domain'; import { createMomentLogGroups, + createSessionRecapId, + extractSessionIdFromRecapId, momentLogGroupToRecapItem, } from '@/utils/recapMappers'; @@ -19,13 +26,170 @@ type RecapListEntry = { shareId: string; }; +type CurrentTripRecapCardProps = { + isCreating: boolean; + message?: string; + momentCount: number; + onPress: () => void; + representativeLog?: MomentLog; + status: 'empty' | 'failed' | 'local' | 'ready' | 'synced'; + trackCount: number; + unsyncedMomentCount: number; +}; + +function getUniqueTrackCount(logs: MomentLog[]) { + return new Set(logs.map((log) => log.track?.id).filter(Boolean)).size; +} + +function getNewestMomentLog(logs: MomentLog[]) { + return [...logs].sort( + (first, second) => + new Date(second.createdAt).getTime() - new Date(first.createdAt).getTime(), + )[0]; +} + +function CurrentTripRecapCard({ + isCreating, + message, + momentCount, + onPress, + representativeLog, + status, + trackCount, + unsyncedMomentCount, +}: CurrentTripRecapCardProps) { + const statusLabel = isCreating + ? '생성 중...' + : status === 'failed' + ? '실패 · 재시도 가능' + : status === 'synced' + ? '완료' + : status === 'local' + ? '로컬 미리보기' + : status === 'ready' + ? '생성 가능' + : '생성 전'; + const buttonLabel = isCreating + ? '생성 중...' + : status === 'failed' + ? '다시 시도' + : status === 'synced' + ? 'Recap 열기' + : status === 'local' + ? unsyncedMomentCount > 0 + ? '로컬 Recap 열기' + : '서버 Recap으로 저장' + : status === 'empty' + ? '첫 Moment 저장' + : 'Recap 만들기'; + const representativeTrackLabel = representativeLog?.track + ? `${representativeLog.track.title} - ${representativeLog.track.artist}` + : '대표 곡 없음'; + + return ( + + + + + 이번 여행 + + + {statusLabel} + + + {momentCount}개 Moment · {trackCount}곡 + + {status === 'local' && !message ? ( + + {unsyncedMomentCount > 0 + ? `서버에 올라가지 않은 Moment ${unsyncedMomentCount}개가 있어 로컬 결과물로 먼저 볼 수 있어요.` + : '모든 Moment가 동기화됐어요. 서버 Recap으로 저장할 수 있어요.'} + + ) : null} + + + Recap + + + + + + {representativeLog?.placeName ?? '아직 대표 장소가 없어요'} + + + {representativeTrackLabel} + + + + {message ? ( + + {message} + + ) : null} + + + + {buttonLabel} + + + + ); +} + export function RecapListScreen() { + const queryClient = useQueryClient(); const momentLogs = useMomentLogStore((state) => state.logs); + const { session, setSessionRecapId } = useTravelSessionStore(); + const [currentRecapStatus, setCurrentRecapStatus] = + useState<'empty' | 'failed' | 'local' | 'ready' | 'synced'>('empty'); + const [currentRecapMessage, setCurrentRecapMessage] = useState(); + const [isCreatingCurrentRecap, setIsCreatingCurrentRecap] = useState(false); const { data: serverRecapItems = [], isError, isLoading, } = useRecapListQuery(); + const currentTripLogs = useMemo( + () => + session.status === 'idle' + ? [] + : momentLogs.filter((log) => log.sessionId === session.id), + [momentLogs, session.id, session.status], + ); + const currentTripRepresentativeLog = useMemo( + () => getNewestMomentLog(currentTripLogs), + [currentTripLogs], + ); + const currentTripTrackCount = useMemo( + () => getUniqueTrackCount(currentTripLogs), + [currentTripLogs], + ); + const currentTripSyncedLogs = useMemo( + () => currentTripLogs.filter((log) => log.syncStatus === 'synced'), + [currentTripLogs], + ); + const currentTripUnsyncedMomentCount = + currentTripLogs.length - currentTripSyncedLogs.length; + const isLocalSessionRecap = Boolean(extractSessionIdFromRecapId(session.recapId)); + const serverSessionRecapId = session.recapId && !isLocalSessionRecap + ? session.recapId + : undefined; + const currentTripStatus = + isCreatingCurrentRecap || currentRecapStatus === 'failed' + ? currentRecapStatus + : serverSessionRecapId + ? 'synced' + : isLocalSessionRecap + ? 'local' + : currentTripLogs.length > 0 + ? 'ready' + : 'empty'; const serverRecaps: RecapListEntry[] = serverRecapItems.map((item) => ({ imageUrl: item.representativeTrack.albumImageUrl, item, @@ -45,6 +209,72 @@ export function RecapListScreen() { (sum, { item }) => sum + (item.momentCount ?? 1), 0, ); + const handleCreateRecap = () => { + const latestLocalRecap = localRecaps[0]; + + if (latestLocalRecap) { + router.push(`/recap-share/${latestLocalRecap.shareId}`); + return; + } + + router.push('/camera'); + }; + const handleCurrentTripRecap = async () => { + if (isCreatingCurrentRecap) { + return; + } + + if (currentTripLogs.length === 0) { + router.push('/camera'); + return; + } + + const localRecapId = createSessionRecapId(session.id); + + if (serverSessionRecapId && currentRecapStatus !== 'failed') { + router.push(`/recap-share/${serverSessionRecapId}`); + return; + } + + const hasOnlySyncedLogs = currentTripUnsyncedMomentCount === 0; + const representativeTrackId = currentTripLogs.find((log) => log.track?.id)?.track?.id; + + if (!hasOnlySyncedLogs) { + setSessionRecapId(localRecapId); + setCurrentRecapStatus('local'); + setCurrentRecapMessage( + `아직 동기화되지 않은 Moment ${currentTripUnsyncedMomentCount}개가 있어 로컬 Recap으로 먼저 보여드릴게요.`, + ); + router.push(`/recap-share/${localRecapId}`); + return; + } + + setIsCreatingCurrentRecap(true); + setCurrentRecapMessage(undefined); + + try { + const serverRecap = await recapApi.createRecap({ + momentLogIds: currentTripSyncedLogs.map((log) => log.id), + representativeTrackId, + sessionId: session.id, + templateId: 'lp', + title: `${currentTripRepresentativeLog?.placeName ?? '이번 여행'} Recap`, + }); + const recapId = serverRecap?.id ?? localRecapId; + + setSessionRecapId(recapId); + setCurrentRecapStatus('synced'); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + router.push(`/recap-share/${recapId}`); + } catch { + setSessionRecapId(localRecapId); + setCurrentRecapStatus('failed'); + setCurrentRecapMessage('서버 Recap 생성에 실패해서 로컬 Recap으로 먼저 보여드릴게요.'); + router.push(`/recap-share/${localRecapId}`); + } finally { + setIsCreatingCurrentRecap(false); + } + }; return ( @@ -74,10 +304,10 @@ export function RecapListScreen() { - 여행이 끝난 뒤에도{'\n'}음악은 남아요. + Recap - - 저장한 장소, 사진, 음악을 하나의 앨범처럼 다시 꺼내보세요. + + 여행별 사운드트랙 앨범 생성 상태를 확인하고 다시 꺼내보세요. @@ -98,9 +328,30 @@ export function RecapListScreen() { + + + + {hasRecaps ? '새 Recap 생성' : '첫 Moment로 Recap 만들기'} + + + + {isLoading ? ( Recap 데이터를 불러오는 중이에요. diff --git a/src/components/travel/CommunityRecapCard.tsx b/src/components/travel/CommunityRecapCard.tsx index c00c1eb..57ad79c 100644 --- a/src/components/travel/CommunityRecapCard.tsx +++ b/src/components/travel/CommunityRecapCard.tsx @@ -1,11 +1,15 @@ import { Feather } from '@expo/vector-icons'; -import { useState } from 'react'; -import { View } from 'react-native'; +import { router } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { Pressable, TextInput, View } from 'react-native'; import { communityApi } from '@/api/communityApi'; import { AppText } from '@/components/AppText'; import { SoundlogButton, SoundlogMetric, SoundlogSurface } from '@/design-system'; -import type { PlaceContext, RecapItem, Track, TravelRoom } from '@/types/domain'; +import { useAuthStore } from '@/store/authStore'; +import { useTravelRoomStore } from '@/store/travelRoomStore'; +import type { PlaceContext, RecapItem, Track, TravelRoom, TravelRoomMoment } from '@/types/domain'; +import { shareTravelRoomInvite } from '@/utils/travelRoomInvite'; type CommunityRecapCardProps = { currentPlace?: PlaceContext; @@ -14,8 +18,246 @@ type CommunityRecapCardProps = { onRecapCreated: (recap: RecapItem) => void; sessionId: string; sessionStatus: 'active' | 'ended' | 'idle'; + trackCount: number; }; +const previewMembers = ['나', '수', '재', '채']; +const previewContributors = ['수경', '재걸', '채린']; +const COLLAPSED_MOMENT_COUNT = 2; +const MAX_MEMBER_AVATAR_COUNT = 5; + +type TravelRoomMomentComment = NonNullable[number]; +type TravelRoomMember = TravelRoom['members'][number]; + +function createPreviewCandidateMoments(placeName: string, currentTrack?: Track): TravelRoomMoment[] { + const fallbackTrack: Track = currentTrack ?? { + artist: 'Soundlog', + fallbackColor: '#2B176C', + id: 'preview-community-track', + title: '곡명 A', + }; + + return [ + { + commentCount: 1, + createdAt: new Date().toISOString(), + id: 'preview-candidate-ocean', + placeName: placeName === '이번 여행' ? '주문진 바다 컷' : `${placeName} 컷`, + status: 'candidate', + track: fallbackTrack, + userId: '수경', + }, + { + commentCount: 2, + createdAt: new Date().toISOString(), + id: 'preview-candidate-cafe', + note: '카페 거리 컷', + placeName: '카페 거리 컷', + status: 'candidate', + track: currentTrack + ? { + ...currentTrack, + id: `${currentTrack.id}-community-alt`, + title: currentTrack.title, + } + : { + artist: 'Soundlog', + fallbackColor: '#B7E628', + id: 'preview-community-track-b', + title: '곡명 B', + }, + userId: '재걸', + }, + ]; +} + +function getUniqueTrackCount(moments: TravelRoomMoment[]) { + return new Set(moments.map((moment) => moment.track?.id).filter(Boolean)).size; +} + +function getMemberLabel(member: TravelRoomMember, index: number, currentUserId?: string) { + if (member.userId === currentUserId) { + return '나'; + } + + const displayName = member.displayName ?? member.userId; + return Array.from(displayName.trim())[0] ?? String(index + 1); +} + +function getMemberCaption(member: TravelRoomMember, currentUserId?: string) { + const name = member.userId === currentUserId ? '나' : member.displayName ?? '동행자'; + const role = member.role === 'owner' ? '방장' : '참여자'; + + return `${name} · ${role}`; +} + +function CandidateMomentRow({ + canComment, + canModerate, + commentDraft, + commentsExpanded, + index, + moment, + onChangeComment, + onSubmitComment, + onToggleComments, + onToggleStatus, + pendingComment, + pendingStatus, +}: { + canComment: boolean; + canModerate: boolean; + commentDraft: string; + commentsExpanded: boolean; + index: number; + moment: TravelRoomMoment; + onChangeComment: (value: string) => void; + onSubmitComment: () => void; + onToggleComments: () => void; + onToggleStatus: () => void; + pendingComment: boolean; + pendingStatus: boolean; +}) { + const contributor = moment.userId || previewContributors[index] || '동행자'; + const trackLabel = moment.track + ? `${contributor} 추가 · ${moment.track.title}` + : `${contributor} 추가 · ${moment.note ?? '곡 없이 남긴 후보'}`; + const latestComment = moment.comments?.at(-1); + const commentCount = moment.commentCount ?? moment.comments?.length ?? 0; + const visibleComments = commentsExpanded ? moment.comments ?? [] : latestComment ? [latestComment] : []; + const nextStatusLabel = moment.status === 'accepted' ? '후보로' : '채택'; + + return ( + + + + + {moment.placeName ?? '장소 미정'} + + + {trackLabel} + + + {canModerate ? ( + + + {pendingStatus ? '변경 중' : nextStatusLabel} + + + ) : null} + + + + + + {moment.status === 'accepted' ? '채택됨' : '채택 후보'} + + + + + 댓글 {commentCount} + + + + + {commentCount > 0 ? ( + + + + {commentsExpanded ? '댓글 접기' : `댓글 ${commentCount}개 보기`} + + + {visibleComments.length > 0 ? ( + visibleComments.map((comment) => ( + + {comment.userId} · {comment.body} + + )) + ) : ( + + 댓글 목록은 여행방을 다시 열면 불러올 수 있어요. + + )} + + ) : null} + + {canComment ? ( + + + + + {pendingComment ? '댓글 저장 중' : '댓글 추가'} + + + + ) : null} + + ); +} + +function replaceRoomMoment(room: TravelRoom, updatedMoment: TravelRoomMoment): TravelRoom { + return { + ...room, + moments: room.moments.map((moment) => + moment.id === updatedMoment.id ? updatedMoment : moment, + ), + }; +} + +function appendRoomMomentComment( + room: TravelRoom, + momentId: string, + comment: TravelRoomMomentComment, +): TravelRoom { + return { + ...room, + moments: room.moments.map((moment) => { + if (moment.id !== momentId) { + return moment; + } + + const comments = [...(moment.comments ?? []), comment]; + + return { + ...moment, + commentCount: comments.length, + comments, + }; + }), + }; +} + export function CommunityRecapCard({ currentPlace, currentTrack, @@ -23,15 +265,85 @@ export function CommunityRecapCard({ onRecapCreated, sessionId, sessionStatus, + trackCount, }: CommunityRecapCardProps) { - const [room, setRoom] = useState(); const [isCreatingRoom, setIsCreatingRoom] = useState(false); const [isAddingMoment, setIsAddingMoment] = useState(false); const [isCreatingRecap, setIsCreatingRecap] = useState(false); + const [isJoiningRoom, setIsJoiningRoom] = useState(false); + const [isRestoringRoom, setIsRestoringRoom] = useState(false); + const [isSharingInvite, setIsSharingInvite] = useState(false); + const [commentDrafts, setCommentDrafts] = useState>({}); + const [expandedCommentIds, setExpandedCommentIds] = useState>({}); + const [joinDisplayName, setJoinDisplayName] = useState(''); + const [joinInviteCode, setJoinInviteCode] = useState(''); const [message, setMessage] = useState(); + const [pendingCommentMomentId, setPendingCommentMomentId] = useState(); + const [pendingStatusMomentId, setPendingStatusMomentId] = useState(); + const [showAllMoments, setShowAllMoments] = useState(false); + const authStatus = useAuthStore((state) => state.status); + const currentUserId = useAuthStore((state) => state.user?.id); + const room = useTravelRoomStore((state) => state.roomsBySessionId[sessionId]); + const setTravelRoom = useTravelRoomStore((state) => state.setRoom); const placeName = currentPlace?.title ?? '이번 여행'; const canUseRoom = sessionStatus !== 'idle'; + const normalizedInviteCode = joinInviteCode.trim().toUpperCase(); + const previewCandidateMoments = createPreviewCandidateMoments(placeName, currentTrack); + const allCandidateMoments = room?.moments ?? previewCandidateMoments; + const candidateMoments = showAllMoments + ? allCandidateMoments + : allCandidateMoments.slice(0, COLLAPSED_MOMENT_COUNT); + const hiddenMomentCount = Math.max(allCandidateMoments.length - candidateMoments.length, 0); + const displayMemberCount = room?.memberCount ?? 4; + const displayMomentCount = room?.momentCount ?? Math.max(momentCount, candidateMoments.length); + const displayTrackCount = room + ? Math.max(getUniqueTrackCount(room.moments), 1) + : Math.max(trackCount, currentTrack ? 1 : candidateMoments.length); + const displayRoomTitle = room?.title ?? `${placeName} 여행방`; + const myMemberRole = room?.members.find((member) => member.userId === currentUserId)?.role; + const canCommentRoom = Boolean(room && authStatus === 'authenticated'); + const canModerateRoom = myMemberRole === 'owner'; + const visibleMembers = room?.members.slice(0, MAX_MEMBER_AVATAR_COUNT); + const memberOverflowCount = Math.max((room?.memberCount ?? 0) - (visibleMembers?.length ?? 0), 0); + + useEffect(() => { + if (!canUseRoom || room || authStatus !== 'authenticated' || !sessionId) { + return; + } + + let ignore = false; + + setIsRestoringRoom(true); + communityApi + .getTravelRooms({ limit: 1, sessionId }) + .then((rooms) => { + if (ignore) { + return; + } + + const restoredRoom = rooms[0]; + + if (restoredRoom) { + setTravelRoom(sessionId, restoredRoom); + setMessage(undefined); + } + }) + .catch(() => { + if (!ignore) { + setMessage('기존 공동 여행방을 불러오지 못했어요. 초대 코드는 직접 입력할 수 있어요.'); + } + }) + .finally(() => { + if (!ignore) { + setIsRestoringRoom(false); + } + }); + + return () => { + ignore = true; + }; + }, [authStatus, canUseRoom, room, sessionId, setTravelRoom]); const handleCreateRoom = async () => { if (!canUseRoom || isCreatingRoom) { @@ -53,7 +365,7 @@ export function CommunityRecapCard({ return; } - setRoom(nextRoom); + setTravelRoom(sessionId, nextRoom); setMessage('초대 코드를 공유하면 동행자가 Moment 후보를 함께 올릴 수 있어요.'); } catch { setMessage('공동 여행방을 만들지 못했어요. 잠시 후 다시 시도해주세요.'); @@ -61,6 +373,30 @@ export function CommunityRecapCard({ setIsCreatingRoom(false); } }; + const handleOpenRoomDetail = () => { + if (!room) { + return; + } + + router.push(`/travel-room/${room.id}` as never); + }; + const handleShareInvite = async () => { + if (!room || isSharingInvite) { + return; + } + + setIsSharingInvite(true); + setMessage(undefined); + + try { + const result = await shareTravelRoomInvite(room); + setMessage(result === 'copied' ? '초대 메시지를 클립보드에 복사했어요.' : '초대 메시지를 공유했어요.'); + } catch { + setMessage('초대 메시지를 공유하지 못했어요. 초대 코드를 직접 전달해주세요.'); + } finally { + setIsSharingInvite(false); + } + }; const handleAddCurrentTrack = async () => { if (!room || isAddingMoment) { @@ -82,11 +418,13 @@ export function CommunityRecapCard({ return; } - setRoom({ + const nextRoom = { ...room, momentCount: room.momentCount + 1, moments: [moment, ...room.moments], - }); + }; + + setTravelRoom(sessionId, nextRoom); setMessage('현재 선택 곡을 공동 Recap 후보에 추가했어요.'); } catch { setMessage('후보 추가에 실패했어요. 현재 곡이나 네트워크 상태를 확인해주세요.'); @@ -123,6 +461,95 @@ export function CommunityRecapCard({ setIsCreatingRecap(false); } }; + const handleJoinRoom = async () => { + if (!canUseRoom || isJoiningRoom || !normalizedInviteCode) { + return; + } + + setIsJoiningRoom(true); + setMessage(undefined); + + try { + const nextRoom = await communityApi.joinTravelRoomByInviteCode({ + displayName: joinDisplayName.trim() || undefined, + inviteCode: normalizedInviteCode, + }); + + if (!nextRoom) { + setMessage('로그인 후 초대 코드로 공동 여행방에 참여할 수 있어요.'); + return; + } + + setTravelRoom(sessionId, nextRoom); + setJoinDisplayName(''); + setJoinInviteCode(''); + setMessage('공동 여행방에 참여했어요. 이제 현재 곡을 Recap 후보로 올릴 수 있어요.'); + } catch { + setMessage('초대 코드로 여행방에 참여하지 못했어요. 코드를 다시 확인해주세요.'); + } finally { + setIsJoiningRoom(false); + } + }; + const handleToggleMomentStatus = async (moment: TravelRoomMoment) => { + if (!room || !canModerateRoom || pendingStatusMomentId) { + return; + } + + setPendingStatusMomentId(moment.id); + setMessage(undefined); + + try { + const nextStatus = moment.status === 'accepted' ? 'candidate' : 'accepted'; + const updatedMoment = await communityApi.updateTravelRoomMomentStatus( + room.id, + moment.id, + nextStatus, + ); + + if (!updatedMoment) { + setMessage('방장 계정으로 로그인하면 후보 채택 상태를 바꿀 수 있어요.'); + return; + } + + setTravelRoom(sessionId, replaceRoomMoment(room, updatedMoment)); + setMessage(nextStatus === 'accepted' ? '후보 Moment를 채택했어요.' : '후보 상태로 되돌렸어요.'); + } catch { + setMessage('후보 상태를 변경하지 못했어요. 방장 권한이나 네트워크 상태를 확인해주세요.'); + } finally { + setPendingStatusMomentId(undefined); + } + }; + const handleSubmitMomentComment = async (moment: TravelRoomMoment) => { + if (!room || pendingCommentMomentId) { + return; + } + + const body = commentDrafts[moment.id]?.trim(); + + if (!body) { + return; + } + + setPendingCommentMomentId(moment.id); + setMessage(undefined); + + try { + const comment = await communityApi.addTravelRoomMomentComment(room.id, moment.id, body); + + if (!comment) { + setMessage('로그인 후 후보 Moment에 댓글을 남길 수 있어요.'); + return; + } + + setTravelRoom(sessionId, appendRoomMomentComment(room, moment.id, comment)); + setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: '' })); + setMessage('후보 Moment에 댓글을 남겼어요.'); + } catch { + setMessage('댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.'); + } finally { + setPendingCommentMomentId(undefined); + } + }; return ( @@ -135,38 +562,207 @@ export function CommunityRecapCard({ Collaborative Recap - 같이 간 사람과 사운드트랙을 모아요 + {displayRoomTitle} - - 한 명이 만든 플레이리스트가 아니라, 각자 고른 곡과 순간을 하나의 Recap 후보로 - 합칠 수 있어요. + + {displayMemberCount}명이 함께 만드는 사운드트랙 앨범 + + {room + ? visibleMembers?.map((member, index) => ( + + + {getMemberLabel(member, index, currentUserId)} + + + )) + : previewMembers.slice(0, displayMemberCount).map((member, index) => ( + + + {member} + + + ))} + {memberOverflowCount > 0 ? ( + + +{memberOverflowCount} + + ) : null} + + {room && visibleMembers?.length + ? visibleMembers.map((member) => getMemberCaption(member, currentUserId)).join(' · ') + : '초대 코드로 함께 참여'} + + + - - - - + + + + {isRestoringRoom ? ( + + + 서버에서 현재 여행의 공동 여행방을 확인하고 있어요. + + + ) : null} + {message ? ( - {message} + {message} + + ) : null} + + + + Recap 후보 + + {room?.inviteCode ? `초대 코드 ${room.inviteCode}` : '초대 코드 기반 참여'} + + + + {candidateMoments.length === 0 ? ( + + + 아직 후보가 없어요. 현재 곡을 먼저 올리거나 초대 코드로 동행자를 초대해보세요. + + + ) : ( + candidateMoments.map((moment, index) => ( + + setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: value })) + } + onSubmitComment={() => void handleSubmitMomentComment(moment)} + onToggleComments={() => + setExpandedCommentIds((state) => { + if (state[moment.id]) { + const nextState = { ...state }; + delete nextState[moment.id]; + return nextState; + } + + return { ...state, [moment.id]: true }; + }) + } + onToggleStatus={() => void handleToggleMomentStatus(moment)} + pendingComment={pendingCommentMomentId === moment.id} + pendingStatus={pendingStatusMomentId === moment.id} + commentsExpanded={Boolean(expandedCommentIds[moment.id])} + /> + )) + )} + {allCandidateMoments.length > COLLAPSED_MOMENT_COUNT ? ( + setShowAllMoments((value) => !value)} + > + + {showAllMoments ? '후보 접기' : `후보 ${hiddenMomentCount}개 더 보기`} + + + ) : null} + + + {!room ? ( + + + 서버 여행방을 만들면 이 미리보기 후보 대신 동행자가 올린 사진, 곡, 메모가 실시간으로 쌓여요. + ) : null} {!room ? ( - void handleCreateRoom()} - /> + <> + + setJoinInviteCode(text.toUpperCase())} + placeholder="초대 코드" + placeholderTextColor="rgba(255,255,255,0.35)" + value={joinInviteCode} + /> + + void handleJoinRoom()} + variant="ghost" + /> + + void handleCreateRoom()} + style={{ opacity: !canUseRoom || isCreatingRoom ? 0.55 : 1 }} + > + + + {isCreatingRoom ? '여행방 생성 중' : '새 여행방 만들기'} + + + ) : ( + + + void handleShareInvite()} + variant="ghost" + /> + void handleCreateRecap()} /> diff --git a/src/components/travel/MomentCard.tsx b/src/components/travel/MomentCard.tsx index f39102a..a6f29f5 100644 --- a/src/components/travel/MomentCard.tsx +++ b/src/components/travel/MomentCard.tsx @@ -26,14 +26,16 @@ export function MomentCard({ item, onPress, onRetry }: MomentCardProps) { : undefined; return ( - - + {item.photoUri ? ( ) : ( @@ -41,16 +43,21 @@ export function MomentCard({ item, onPress, onRetry }: MomentCardProps) { )} - + - + Moment {syncLabel ? ( - + {syncLabel} @@ -74,7 +81,16 @@ export function MomentCard({ item, onPress, onRetry }: MomentCardProps) { {item.track ? `${item.track.title} - ${item.track.artist}` : '음악 없음'} - + + {item.note ? ( + + + + {item.note} + + + ) : null} + @@ -97,6 +113,6 @@ export function MomentCard({ item, onPress, onRetry }: MomentCardProps) { ) : null} - + ); } diff --git a/src/components/travel/TravelReportModal.tsx b/src/components/travel/TravelReportModal.tsx index 2c42982..7147162 100644 --- a/src/components/travel/TravelReportModal.tsx +++ b/src/components/travel/TravelReportModal.tsx @@ -418,11 +418,23 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP className="flex-1 overflow-hidden rounded-[22px]" style={{ height: 154 }} > - + {moment.photoUri ? ( + + ) : ( + + + + 음악 기록 + + + )} ))} diff --git a/src/components/travel/TravelRoomDetailScreen.tsx b/src/components/travel/TravelRoomDetailScreen.tsx new file mode 100644 index 0000000..dbe58ad --- /dev/null +++ b/src/components/travel/TravelRoomDetailScreen.tsx @@ -0,0 +1,553 @@ +import { Feather } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { Pressable, ScrollView, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { communityApi } from '@/api/communityApi'; +import { AppText } from '@/components/AppText'; +import { Screen } from '@/components/Screen'; +import { getTabBarHeight } from '@/constants/layout'; +import { SoundlogButton, SoundlogMetric, SoundlogSurface } from '@/design-system'; +import { useAuthStore } from '@/store/authStore'; +import { usePlayerStore } from '@/store/playerStore'; +import { useTravelRoomStore } from '@/store/travelRoomStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; +import type { TravelRoom, TravelRoomMoment } from '@/types/domain'; +import { shareTravelRoomInvite } from '@/utils/travelRoomInvite'; + +type TravelRoomDetailScreenProps = { + roomId: string; +}; + +type TravelRoomMomentComment = NonNullable[number]; + +function replaceRoomMoment(room: TravelRoom, updatedMoment: TravelRoomMoment): TravelRoom { + return { + ...room, + moments: room.moments.map((moment) => + moment.id === updatedMoment.id ? updatedMoment : moment, + ), + }; +} + +function appendRoomMomentComment( + room: TravelRoom, + momentId: string, + comment: TravelRoomMomentComment, +): TravelRoom { + return { + ...room, + moments: room.moments.map((moment) => { + if (moment.id !== momentId) { + return moment; + } + + const comments = [...(moment.comments ?? []), comment]; + + return { + ...moment, + commentCount: comments.length, + comments, + }; + }), + }; +} + +function createMemberLabel(member: TravelRoom['members'][number], currentUserId?: string) { + if (member.userId === currentUserId) { + return '나'; + } + + return member.displayName ?? '동행자'; +} + +function createMomentTrackLabel(moment: TravelRoomMoment) { + if (!moment.track) { + return moment.note ?? '음악 없이 남긴 후보'; + } + + return `${moment.track.title} - ${moment.track.artist}`; +} + +function createStatusLabel(status: TravelRoomMoment['status']) { + if (status === 'accepted') { + return '채택됨'; + } + + if (status === 'rejected') { + return '보류'; + } + + return '채택 후보'; +} + +function sortMoments(moments: TravelRoomMoment[]) { + return [...moments].sort((first, second) => { + if (first.status === second.status) { + return new Date(second.createdAt).getTime() - new Date(first.createdAt).getTime(); + } + + return first.status === 'accepted' ? -1 : 1; + }); +} + +export function TravelRoomDetailScreen({ roomId }: TravelRoomDetailScreenProps) { + const insets = useSafeAreaInsets(); + const authStatus = useAuthStore((state) => state.status); + const currentUserId = useAuthStore((state) => state.user?.id); + const cachedRoom = useTravelRoomStore((state) => state.roomsById[roomId]); + const setRoomById = useTravelRoomStore((state) => state.setRoomById); + const { currentTrack } = usePlayerStore(); + const currentPlace = useTravelSessionStore((state) => state.currentPlace); + const [commentDrafts, setCommentDrafts] = useState>({}); + const [isAddingMoment, setIsAddingMoment] = useState(false); + const [isCreatingRecap, setIsCreatingRecap] = useState(false); + const [isLoading, setIsLoading] = useState(!cachedRoom); + const [isSharingInvite, setIsSharingInvite] = useState(false); + const [message, setMessage] = useState(); + const [pendingCommentMomentId, setPendingCommentMomentId] = useState(); + const [pendingStatusMomentId, setPendingStatusMomentId] = useState(); + const room = cachedRoom; + const hasCachedRoom = Boolean(cachedRoom); + const sortedMoments = useMemo(() => sortMoments(room?.moments ?? []), [room?.moments]); + const acceptedMomentCount = sortedMoments.filter((moment) => moment.status === 'accepted').length; + const myMemberRole = room?.members.find((member) => member.userId === currentUserId)?.role; + const canModerate = myMemberRole === 'owner'; + const canUseServerRoom = authStatus === 'authenticated'; + + useEffect(() => { + if (!canUseServerRoom || !roomId) { + setIsLoading(false); + return; + } + + let ignore = false; + + setIsLoading(!hasCachedRoom); + communityApi + .getTravelRoom(roomId) + .then((nextRoom) => { + if (!ignore && nextRoom) { + setRoomById(nextRoom); + setMessage(undefined); + } + }) + .catch(() => { + if (!ignore && !hasCachedRoom) { + setMessage('여행방을 불러오지 못했어요. 초대 코드나 로그인 상태를 확인해주세요.'); + } + }) + .finally(() => { + if (!ignore) { + setIsLoading(false); + } + }); + + return () => { + ignore = true; + }; + }, [canUseServerRoom, hasCachedRoom, roomId, setRoomById]); + + const handleShareInvite = async () => { + if (!room || isSharingInvite) { + return; + } + + setIsSharingInvite(true); + setMessage(undefined); + + try { + const result = await shareTravelRoomInvite(room); + setMessage( + result === 'copied' + ? '초대 메시지를 클립보드에 복사했어요.' + : '초대 메시지를 공유했어요.', + ); + } catch { + setMessage('초대 메시지를 공유하지 못했어요. 초대 코드를 직접 전달해주세요.'); + } finally { + setIsSharingInvite(false); + } + }; + + const handleAddCurrentTrack = async () => { + if (!room || isAddingMoment) { + return; + } + + setIsAddingMoment(true); + setMessage(undefined); + + try { + const moment = await communityApi.addCurrentTrackMoment( + room.id, + currentTrack, + currentPlace?.title, + ); + + if (!moment) { + setMessage('로그인된 서버 세션에서 후보를 추가할 수 있어요.'); + return; + } + + setRoomById({ + ...room, + momentCount: room.momentCount + 1, + moments: [moment, ...room.moments], + }); + setMessage('현재 곡을 공동 Recap 후보에 추가했어요.'); + } catch { + setMessage('후보를 추가하지 못했어요. 현재 곡이나 네트워크 상태를 확인해주세요.'); + } finally { + setIsAddingMoment(false); + } + }; + + const handleToggleMomentStatus = async (moment: TravelRoomMoment) => { + if (!room || !canModerate || pendingStatusMomentId) { + return; + } + + const nextStatus = moment.status === 'accepted' ? 'candidate' : 'accepted'; + + setPendingStatusMomentId(moment.id); + setMessage(undefined); + + try { + const updatedMoment = await communityApi.updateTravelRoomMomentStatus( + room.id, + moment.id, + nextStatus, + ); + + if (!updatedMoment) { + setMessage('방장 계정으로 로그인하면 후보 채택 상태를 바꿀 수 있어요.'); + return; + } + + setRoomById(replaceRoomMoment(room, updatedMoment)); + setMessage(nextStatus === 'accepted' ? '후보 Moment를 채택했어요.' : '후보로 되돌렸어요.'); + } catch { + setMessage('후보 상태를 변경하지 못했어요. 방장 권한이나 네트워크 상태를 확인해주세요.'); + } finally { + setPendingStatusMomentId(undefined); + } + }; + + const handleSubmitComment = async (moment: TravelRoomMoment) => { + if (!room || pendingCommentMomentId) { + return; + } + + const body = commentDrafts[moment.id]?.trim(); + + if (!body) { + return; + } + + setPendingCommentMomentId(moment.id); + setMessage(undefined); + + try { + const comment = await communityApi.addTravelRoomMomentComment(room.id, moment.id, body); + + if (!comment) { + setMessage('로그인 후 후보 Moment에 댓글을 남길 수 있어요.'); + return; + } + + setRoomById(appendRoomMomentComment(room, moment.id, comment)); + setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: '' })); + setMessage('후보 Moment에 댓글을 남겼어요.'); + } catch { + setMessage('댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.'); + } finally { + setPendingCommentMomentId(undefined); + } + }; + + const handleCreateRecap = async () => { + if (!room || isCreatingRecap) { + return; + } + + setIsCreatingRecap(true); + setMessage(undefined); + + try { + const representativeTrackId = + sortedMoments.find((moment) => moment.status === 'accepted' && moment.track)?.track?.id ?? + sortedMoments.find((moment) => moment.track)?.track?.id; + const recap = await communityApi.createTravelRoomRecap(room.id, { + representativeTrackId, + templateId: 'album', + title: `${room.title} 공동 Recap`, + }); + + if (!recap) { + setMessage('공동 Recap 생성은 로그인된 서버 세션에서 사용할 수 있어요.'); + return; + } + + router.push(`/recap-share/${recap.id}` as never); + } catch { + setMessage('공동 Recap 생성에 실패했어요. 채택 후보나 대표 곡을 확인해주세요.'); + } finally { + setIsCreatingRecap(false); + } + }; + + return ( + + + + router.back()} + > + + + 공동 Recap + + + {isLoading ? ( + + 여행방을 불러오고 있어요 + + 동행자가 올린 후보와 댓글을 최신 상태로 확인하고 있어요. + + + ) : !room ? ( + + 여행방을 찾지 못했어요 + + 초대 코드로 먼저 참여하거나 네트워크 상태를 확인해주세요. + + {message ? ( + {message} + ) : null} + + ) : ( + <> + + + + + Travel Room + + + {room.title} + + + 초대 코드로 모인 동행자의 사진, 곡, 메모를 하나의 사운드트랙 앨범으로 정리해요. + + + + + {canModerate ? '방장' : '참여자'} + + + + + + + + + + + + 초대 코드 + + + {room.inviteCode} + + void handleShareInvite()} + size="compact" + variant="ghost" + /> + + + + {message ? ( + + {message} + + ) : null} + + + void handleAddCurrentTrack()} + variant="ghost" + /> + void handleCreateRecap()} + /> + + + + + 참여자 + + {room.members.map((member) => ( + + + + {createMemberLabel(member, currentUserId)} + + + {member.role === 'owner' ? '방장' : '참여자'} + + + {member.userId === currentUserId ? ( + + + 나 + + + ) : null} + + ))} + + + + + + + Recap 후보 + + 채택된 후보가 있으면 채택 후보만 Recap에 우선 반영돼요. + + + + + + {sortedMoments.length === 0 ? ( + + 아직 후보가 없어요 + + 현재 곡을 먼저 올리거나 초대 코드로 동행자를 초대해보세요. + + + ) : ( + sortedMoments.map((moment) => { + const isPendingStatus = pendingStatusMomentId === moment.id; + const isPendingComment = pendingCommentMomentId === moment.id; + const commentDraft = commentDrafts[moment.id] ?? ''; + + return ( + + + + + {moment.placeName ?? '장소 미정'} + + + {createMomentTrackLabel(moment)} + {moment.note ? ` · ${moment.note}` : ''} + + + + + {createStatusLabel(moment.status)} + + + + + {moment.comments?.length ? ( + + {moment.comments.map((comment) => ( + + {comment.userId} · {comment.body} + + ))} + + ) : null} + + + setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: value })) + } + placeholder="후보에 댓글 남기기" + placeholderTextColor="rgba(255,255,255,0.35)" + value={commentDraft} + /> + + + {canModerate ? ( + void handleToggleMomentStatus(moment)} + size="compact" + variant={moment.status === 'accepted' ? 'ghost' : 'primary'} + /> + ) : null} + void handleSubmitComment(moment)} + size="compact" + variant="ghost" + /> + + + ); + }) + )} + + + + )} + + + ); +} diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx index e30e1c5..a437900 100644 --- a/src/components/travel/TravelScreen.tsx +++ b/src/components/travel/TravelScreen.tsx @@ -1,10 +1,13 @@ import { useQueryClient } from '@tanstack/react-query'; import { router } from 'expo-router'; +import { Image } from 'expo-image'; import { useEffect, useMemo, useState } from 'react'; -import { Pressable, ScrollView, View } from 'react-native'; +import { Pressable, ScrollView, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ApiError } from '@/api/client'; import { momentLogApi } from '@/api/momentLogApi'; +import { momentLogQueryKeys, useMomentLogListQuery } from '@/api/momentLogQueries'; import { recapApi } from '@/api/recapApi'; import { recapQueryKeys } from '@/api/recapQueries'; import { travelSessionApi } from '@/api/travelSessionApi'; @@ -13,10 +16,16 @@ import { RecapListCard } from '@/components/recap/RecapListCard'; import { Screen } from '@/components/Screen'; import { AppText } from '@/components/AppText'; import { getHomeContentBottomPadding } from '@/constants/layout'; -import { useMomentLogStore } from '@/store/momentLogStore'; +import { useAuthStore } from '@/store/authStore'; +import { + useMomentLogStore, + type MomentLogCreateQueuePayload, + type MomentLogEditQueuePayload, + type MomentLogPendingAction, +} from '@/store/momentLogStore'; import { usePlayerStore } from '@/store/playerStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; -import type { TravelMode } from '@/types/domain'; +import type { MomentLog, MoodTag, Track, TravelMode } from '@/types/domain'; import { CommunityRecapCard } from './CommunityRecapCard'; import { EndTravelConfirmModal } from './EndTravelConfirmModal'; @@ -24,12 +33,433 @@ import { LiveSoundMapSection } from './live-sound-map'; import { MomentCard } from './MomentCard'; import { TravelModeBottomSheet } from './TravelModeBottomSheet'; import { TravelStatusCard } from './TravelStatusCard'; +import { formatKoreanDateTime } from './travelFormat'; +import { moodLabelByValue } from './travelData'; +import { pickMomentReplacementPhoto } from '@/utils/momentPhotoPicker'; import { createMomentLogGroups, createSessionRecapId, momentLogGroupToRecapItem, } from '@/utils/recapMappers'; -import type { MomentLog } from '@/types/domain'; + +function getUniqueTrackCount(logs: MomentLog[]) { + return new Set(logs.map((log) => log.track?.id).filter(Boolean)).size; +} + +type MomentLogEditDraft = { + moodTags: MoodTag[]; + note?: string; + placeName?: string; + removePhoto?: boolean; + replacePhotoUri?: string; + track?: Track; +}; + +const moodOptions = Object.entries(moodLabelByValue) as Array<[MoodTag, string]>; + +function momentLogPatchFromPayload( + moment: MomentLog, + payload: MomentLogEditQueuePayload, +): Partial { + return { + moodTags: payload.moodTags, + note: payload.note ?? undefined, + photoUri: payload.removePhoto ? undefined : payload.replacePhotoUri ?? moment.photoUri, + placeName: payload.placeName ?? undefined, + track: payload.track, + }; +} + +function momentLogCreatePayloadFromLog(log: MomentLog): MomentLogCreateQueuePayload { + return { + createdAt: log.createdAt, + location: log.location, + moodTags: log.moodTags, + note: log.note, + photoUri: log.photoUri, + placeCategory: log.placeCategory, + placeId: log.placeId, + placeName: log.placeName, + sessionId: log.sessionId, + track: log.track, + travelMode: log.travelMode, + }; +} + +type TravelLogSummaryCardProps = { + currentTrack?: Track; + logs: MomentLog[]; + momentCount: number; + onCreateRecap: () => void; + onDeleteMoment: (moment: MomentLog) => Promise | void; + onEditMoment: (moment: MomentLog, draft: MomentLogEditDraft) => Promise | void; + onOpenMoment: (moment: MomentLog) => void; + pendingMomentActionId?: string; + sessionStatus: 'active' | 'ended' | 'idle'; + trackCount: number; +}; + +function formatMomentTime(value: string) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return '시간 없음'; + } + + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + hour12: false, + minute: '2-digit', + }).format(date); +} + +function getMomentMoodLabel(log: MomentLog) { + return ( + log.moodTags + .map((tag) => moodLabelByValue[tag]) + .filter(Boolean) + .join(', ') || '무드 없음' + ); +} + +function TravelLogSummaryCard({ + currentTrack, + logs, + momentCount, + onCreateRecap, + onDeleteMoment, + onEditMoment, + onOpenMoment, + pendingMomentActionId, + sessionStatus, + trackCount, +}: TravelLogSummaryCardProps) { + const [editingMomentId, setEditingMomentId] = useState(); + const [editMoodDraft, setEditMoodDraft] = useState([]); + const [editNoteDraft, setEditNoteDraft] = useState(''); + const [editPhotoMessage, setEditPhotoMessage] = useState(); + const [editPhotoUriDraft, setEditPhotoUriDraft] = useState(); + const [editPlaceDraft, setEditPlaceDraft] = useState(''); + const [editTrackDraft, setEditTrackDraft] = useState(); + const title = sessionStatus === 'idle' ? '최근 여행 로그' : '이번 여행 로그'; + const buttonLabel = + momentCount === 0 + ? '첫 Moment 남기기' + : sessionStatus === 'active' + ? 'Recap 만들기' + : 'Recap 보기'; + const resetEditDraft = () => { + setEditingMomentId(undefined); + setEditMoodDraft([]); + setEditNoteDraft(''); + setEditPhotoMessage(undefined); + setEditPhotoUriDraft(undefined); + setEditPlaceDraft(''); + setEditTrackDraft(undefined); + }; + const handlePickPhoto = async (momentLogId: string) => { + setEditPhotoMessage(undefined); + + const result = await pickMomentReplacementPhoto(momentLogId); + + if (result.status === 'selected') { + setEditPhotoUriDraft(result.uri); + setEditPhotoMessage('새 사진을 선택했어요. 저장하면 Moment에 반영돼요.'); + return; + } + + if (result.status === 'permission-denied') { + setEditPhotoMessage('사진을 교체하려면 사진 보관함 접근 권한이 필요해요.'); + return; + } + + if (result.status === 'unavailable') { + setEditPhotoMessage('사진을 불러오지 못했어요. 잠시 후 다시 시도해주세요.'); + } + }; + + return ( + + + + + {title} + + + {momentCount}개 순간 · {trackCount}곡 + + + + + Soundtrack + + + + + + {logs.length === 0 ? ( + + + 아직 저장한 순간이 없어요. 지금 장소의 곡을 하나 고르고 사진이나 메모로 남겨보세요. + + + ) : ( + logs.slice(0, 3).map((log) => { + const isEditing = editingMomentId === log.id; + const isActionPending = pendingMomentActionId === log.id; + const trackLabel = log.track + ? `${log.track.title} - ${log.track.artist}` + : '음악 없음'; + const metaLabel = `${log.photoUri ? '사진' : '사진 없음'} / ${trackLabel} / ${getMomentMoodLabel(log)}`; + const actionLabelPrefix = log.placeName ?? '저장한 순간'; + const draftTrackLabel = editTrackDraft + ? `${editTrackDraft.title} - ${editTrackDraft.artist}` + : '음악 없음'; + + return ( + + onOpenMoment(log)} + > + + + {formatMomentTime(log.createdAt)} {log.placeName ?? '위치 없음'} + + + {metaLabel} + {log.note ? ` · ${log.note}` : ''} + + + + + {log.syncStatus === 'failed' + ? '동기화 필요' + : log.syncStatus === 'pending' + ? '동기화 중' + : '저장됨'} + + + + + {isEditing ? ( + + + + + {moodOptions.map(([tag, label]) => { + const isSelected = editMoodDraft.includes(tag); + + return ( + { + setEditMoodDraft((current) => + current.includes(tag) + ? current.filter((item) => item !== tag) + : [...current, tag], + ); + }} + > + + {label} + + + ); + })} + + + + 사진 + + {editPhotoUriDraft ? ( + + + + ) : null} + + {editPhotoUriDraft ? '사진 연결됨' : '사진 없음'} + + {editPhotoMessage ? ( + + {editPhotoMessage} + + ) : null} + + void handlePickPhoto(log.id)} + > + + {editPhotoUriDraft ? '사진 교체' : '사진 추가'} + + + {editPhotoUriDraft ? ( + { + setEditPhotoUriDraft(undefined); + setEditPhotoMessage('저장하면 Moment 사진이 제거돼요.'); + }} + > + + 사진 제거 + + + ) : null} + + + + + 연결된 곡 + + + {draftTrackLabel} + + {currentTrack ? ( + setEditTrackDraft(currentTrack)} + > + + 현재 곡으로 교체 + + + ) : ( + + 현재 선택된 곡이 없어요. + + )} + + + { + void Promise.resolve( + onEditMoment(log, { + moodTags: editMoodDraft, + note: editNoteDraft.trim() || undefined, + placeName: editPlaceDraft.trim() || undefined, + removePhoto: Boolean(log.photoUri && !editPhotoUriDraft), + replacePhotoUri: + editPhotoUriDraft && editPhotoUriDraft !== log.photoUri + ? editPhotoUriDraft + : undefined, + track: editTrackDraft, + }), + ).then(() => { + resetEditDraft(); + }); + }} + > + + {isActionPending ? '저장 중' : '저장'} + + + + 취소 + + + + ) : ( + + { + setEditingMomentId(log.id); + setEditMoodDraft(log.moodTags); + setEditNoteDraft(log.note ?? ''); + setEditPhotoMessage(undefined); + setEditPhotoUriDraft(log.photoUri); + setEditPlaceDraft(log.placeName ?? ''); + setEditTrackDraft(log.track); + }} + > + 수정 + + void onDeleteMoment(log)} + > + + {isActionPending ? '삭제 중' : '삭제'} + + + + )} + + ); + }) + )} + + + + + {buttonLabel} + + + + ); +} export function TravelScreen() { const insets = useSafeAreaInsets(); @@ -38,8 +468,12 @@ export function TravelScreen() { const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false); const [isStartingTravel, setIsStartingTravel] = useState(false); const [isCreatingRecap, setIsCreatingRecap] = useState(false); + const [pendingMomentActionId, setPendingMomentActionId] = useState(); + const [isSyncingMomentUploads, setIsSyncingMomentUploads] = useState(false); + const [isSyncingPendingActions, setIsSyncingPendingActions] = useState(false); const [recapMessage, setRecapMessage] = useState(); - const [, setClockTick] = useState(0); + const [clockTick, setClockTick] = useState(0); + const authStatus = useAuthStore((state) => state.status); const { currentLocation, currentPlace, @@ -52,7 +486,46 @@ export function TravelScreen() { startSession, } = useTravelSessionStore(); const { currentTrack } = usePlayerStore(); - const { addLog, logs: momentLogs, removeLog, updateLog } = useMomentLogStore(); + const { + logs: momentLogs, + mergeServerLogs, + pendingActions, + queueCreate, + queueDelete, + queueEdit, + removePendingAction, + removeLog, + resolveLocalLog, + updateLog, + } = useMomentLogStore(); + const pendingCreateActions = useMemo( + () => pendingActions.filter((action) => action.type === 'create'), + [pendingActions], + ); + const pendingChangeActions = useMemo( + () => pendingActions.filter((action) => action.type !== 'create'), + [pendingActions], + ); + const pendingActionCount = pendingChangeActions.length; + const stalePendingCreateMomentIds = useMemo(() => { + const staleThresholdMs = 60 * 1000; + const now = Date.now(); + + return new Set( + pendingCreateActions + .filter((action) => now - new Date(action.queuedAt).getTime() > staleThresholdMs) + .map((action) => action.momentLogId), + ); + }, [clockTick, pendingCreateActions]); + const momentLogListParams = useMemo(() => ({ limit: 50 }), []); + const { + data: serverMomentLogs, + isError: isMomentLogSyncError, + isFetching: isMomentLogSyncing, + refetch: refetchMomentLogs, + } = useMomentLogListQuery(momentLogListParams, { + enabled: authStatus === 'authenticated', + }); const sessionLogs = useMemo( () => momentLogs.filter((log) => log.sessionId === session.id), [momentLogs, session.id], @@ -63,9 +536,36 @@ export function TravelScreen() { ); const momentCount = session.status === 'idle' ? 0 : sessionLogs.length; const trackCount = useMemo( - () => new Set(sessionLogs.map((log) => log.track?.id).filter(Boolean)).size, + () => getUniqueTrackCount(sessionLogs), [sessionLogs], ); + const travelLogMoments = useMemo( + () => (session.status === 'idle' ? momentLogs : sessionLogs), + [momentLogs, session.status, sessionLogs], + ); + const travelLogMomentCount = + session.status === 'idle' ? momentLogs.length : sessionLogs.length; + const travelLogTrackCount = useMemo( + () => getUniqueTrackCount(travelLogMoments), + [travelLogMoments], + ); + const unsyncedMomentUploads = useMemo( + () => + travelLogMoments.filter( + (log) => + log.syncStatus === 'failed' || + log.syncStatus === 'local' || + (log.syncStatus === 'pending' && stalePendingCreateMomentIds.has(log.id)), + ), + [stalePendingCreateMomentIds, travelLogMoments], + ); + const pendingMomentUploadCount = useMemo( + () => + travelLogMoments.filter( + (log) => log.syncStatus === 'pending' && !stalePendingCreateMomentIds.has(log.id), + ).length, + [stalePendingCreateMomentIds, travelLogMoments], + ); const localRecaps = useMemo( () => createMomentLogGroups(momentLogs) @@ -90,6 +590,14 @@ export function TravelScreen() { return () => clearInterval(intervalId); }, [session.status]); + useEffect(() => { + if (authStatus !== 'authenticated' || !serverMomentLogs) { + return; + } + + mergeServerLogs(serverMomentLogs); + }, [authStatus, mergeServerLogs, serverMomentLogs]); + const openModeSheet = () => { if (session.status === 'ended') { resetSession(); @@ -132,36 +640,65 @@ export function TravelScreen() { } }; const retryMomentLog = async (log: MomentLog) => { - if (log.syncStatus === 'pending') { - return; + if (log.syncStatus === 'pending' && !stalePendingCreateMomentIds.has(log.id)) { + return false; } + const queuedCreateAction = pendingCreateActions.find( + (action) => action.momentLogId === log.id, + ); + const createPayload = queuedCreateAction?.payload ?? momentLogCreatePayloadFromLog(log); + + queueCreate(log.id, createPayload); updateLog(log.id, { syncStatus: 'pending' }); try { const serverLog = await momentLogApi.createMomentLog({ - createdAt: log.createdAt, + ...createPayload, idempotencyKey: log.id, - location: log.location, - moodTags: log.moodTags, - photoUri: log.photoUri, - placeCategory: log.placeCategory, - placeId: log.placeId, - placeName: log.placeName, - sessionId: log.sessionId, - track: log.track, - travelMode: log.travelMode, }); if (!serverLog) { updateLog(log.id, { syncStatus: 'local' }); - return; + return false; } - removeLog(log.id); - addLog(serverLog); + resolveLocalLog(log.id, serverLog); + await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); + return true; } catch { + queueCreate(log.id, createPayload); updateLog(log.id, { syncStatus: 'failed' }); + return false; + } + }; + const handleRetryUnsyncedMomentUploads = async () => { + if (isSyncingMomentUploads || unsyncedMomentUploads.length === 0) { + return; + } + + setIsSyncingMomentUploads(true); + + let failureCount = 0; + + try { + for (const log of [...unsyncedMomentUploads]) { + const didSync = await retryMomentLog(log); + + if (!didSync) { + failureCount += 1; + } + } + + await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + setRecapMessage( + failureCount === 0 + ? '대기 중인 Moment 업로드를 모두 동기화했어요.' + : `Moment ${failureCount}개는 아직 서버에 올리지 못했어요.`, + ); + } finally { + setIsSyncingMomentUploads(false); } }; const handleConfirmEnd = async () => { @@ -232,6 +769,234 @@ export function TravelScreen() { router.push(`/recap-share/${recapId}`); }; + const handleCreateTravelLogRecap = () => { + if (travelLogMomentCount === 0) { + router.push('/camera'); + return; + } + + if (session.status === 'active') { + setIsEndConfirmVisible(true); + return; + } + + if (session.status === 'ended') { + openCurrentRecap(); + return; + } + + const latestRecap = localRecaps[0]; + + if (latestRecap) { + router.push(`/recap-share/${latestRecap.shareId}`); + } + }; + const handleDeleteMoment = async (moment: MomentLog) => { + if (pendingMomentActionId) { + return; + } + + setPendingMomentActionId(moment.id); + + try { + let deletedOnServer = false; + + if (moment.syncStatus === 'synced') { + deletedOnServer = Boolean(await momentLogApi.deleteMomentLog(moment.id)); + + if (!deletedOnServer) { + throw new Error('Moment delete was not accepted by the server.'); + } + } + + removeLog(moment.id); + await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + setRecapMessage( + deletedOnServer + ? '서버와 여행 로그에서 Moment를 삭제했어요.' + : '로컬 여행 로그에서 Moment를 삭제했어요.', + ); + } catch { + if (moment.syncStatus === 'synced') { + queueDelete(moment); + setRecapMessage('서버 Moment 삭제에 실패해서 동기화 대기열에 저장했어요.'); + } else { + setRecapMessage('Moment 삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); + } + } finally { + setPendingMomentActionId(undefined); + } + }; + const handleEditMoment = async (moment: MomentLog, draft: MomentLogEditDraft) => { + if (pendingMomentActionId) { + return; + } + + const editPayload: MomentLogEditQueuePayload = { + moodTags: draft.moodTags, + note: draft.note?.trim() || null, + placeName: draft.placeName?.trim() || null, + removePhoto: draft.removePhoto, + replacePhotoUri: draft.removePhoto ? undefined : draft.replacePhotoUri, + track: draft.track, + }; + const nextPatch = momentLogPatchFromPayload(moment, editPayload); + + setPendingMomentActionId(moment.id); + + try { + if (moment.syncStatus === 'synced') { + if (draft.removePhoto) { + await momentLogApi.deleteMomentLogPhoto(moment.id); + } else if (draft.replacePhotoUri) { + await momentLogApi.updateMomentLogPhoto(moment.id, draft.replacePhotoUri); + } + + const serverLog = await momentLogApi.updateMomentLog(moment.id, { + moodTags: editPayload.moodTags, + note: editPayload.note, + placeName: editPayload.placeName, + track: editPayload.track, + }); + + if (serverLog) { + updateLog(moment.id, serverLog); + } else { + throw new Error('Moment edit was not accepted by the server.'); + } + } else { + updateLog(moment.id, nextPatch); + queueCreate(moment.id, momentLogCreatePayloadFromLog({ ...moment, ...nextPatch })); + } + + await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + setRecapMessage('Moment 정보를 수정했어요.'); + } catch { + if (moment.syncStatus === 'synced') { + updateLog(moment.id, nextPatch); + queueEdit(moment.id, editPayload); + setRecapMessage('서버 Moment 수정에 실패해서 동기화 대기열에 저장했어요.'); + } else { + setRecapMessage('Moment 수정에 실패했어요. 잠시 후 다시 시도해주세요.'); + } + } finally { + setPendingMomentActionId(undefined); + } + }; + const syncPendingMomentAction = async (action: MomentLogPendingAction) => { + if (action.type === 'create') { + const localMoment = momentLogs.find((moment) => moment.id === action.momentLogId); + + if (!localMoment) { + removePendingAction(action.id); + return; + } + + updateLog(action.momentLogId, { syncStatus: 'pending' }); + + const serverLog = await momentLogApi.createMomentLog({ + ...action.payload, + idempotencyKey: action.momentLogId, + }); + + if (!serverLog) { + updateLog(action.momentLogId, { syncStatus: 'local' }); + throw new Error('Queued Moment create was not accepted by the server.'); + } + + resolveLocalLog(action.momentLogId, serverLog); + return; + } + + if (action.type === 'delete') { + try { + const accepted = await momentLogApi.deleteMomentLog(action.momentLogId); + + if (!accepted) { + throw new Error('Queued Moment delete was not accepted by the server.'); + } + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + removePendingAction(action.id); + return; + } + + throw error; + } + + removePendingAction(action.id); + return; + } + + const localMoment = momentLogs.find((moment) => moment.id === action.momentLogId); + + if (!localMoment) { + removePendingAction(action.id); + return; + } + + if (action.payload.removePhoto) { + const photoDeletedLog = await momentLogApi.deleteMomentLogPhoto(action.momentLogId); + + if (!photoDeletedLog) { + throw new Error('Queued Moment photo delete was not accepted by the server.'); + } + } else if (action.payload.replacePhotoUri) { + const photoUpdatedLog = await momentLogApi.updateMomentLogPhoto( + action.momentLogId, + action.payload.replacePhotoUri, + ); + + if (!photoUpdatedLog) { + throw new Error('Queued Moment photo update was not accepted by the server.'); + } + } + + const serverLog = await momentLogApi.updateMomentLog(action.momentLogId, { + moodTags: action.payload.moodTags, + note: action.payload.note, + placeName: action.payload.placeName, + track: action.payload.track, + }); + + if (!serverLog) { + throw new Error('Queued Moment edit was not accepted by the server.'); + } + + updateLog(action.momentLogId, serverLog); + removePendingAction(action.id); + }; + const handleRetryPendingMomentActions = async () => { + if (isSyncingPendingActions || pendingActions.length === 0) { + return; + } + + setIsSyncingPendingActions(true); + + let failureCount = 0; + + try { + for (const action of [...pendingChangeActions]) { + try { + await syncPendingMomentAction(action); + } catch { + failureCount += 1; + } + } + + await queryClient.invalidateQueries({ queryKey: momentLogQueryKeys.all }); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + setRecapMessage( + failureCount === 0 + ? '대기 중인 Moment 변경을 모두 동기화했어요.' + : `Moment 변경 ${failureCount}개는 아직 동기화하지 못했어요.`, + ); + } finally { + setIsSyncingPendingActions(false); + } + }; const handleCommunityRecapCreated = async (recap: { id: string }) => { setSessionRecapId(recap.id); await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); @@ -276,10 +1041,90 @@ export function TravelScreen() { {recapMessage ? ( - {recapMessage} + {recapMessage} + + ) : null} + + {authStatus === 'authenticated' && isMomentLogSyncError ? ( + + + 서버 여행 로그를 불러오지 못했어요. 로컬에 저장된 Moment를 먼저 보여드릴게요. + + void refetchMomentLogs()} + > + 다시 동기화 + + + ) : authStatus === 'authenticated' && isMomentLogSyncing ? ( + + + 서버 여행 로그를 동기화하고 있어요. + + + ) : null} + + {unsyncedMomentUploads.length > 0 || pendingMomentUploadCount > 0 ? ( + + + {pendingMomentUploadCount > 0 + ? `Moment ${pendingMomentUploadCount}개를 서버에 올리는 중이에요.` + : `서버에 아직 올라가지 않은 Moment ${unsyncedMomentUploads.length}개가 있어요.`} + + {unsyncedMomentUploads.length > 0 ? ( + authStatus === 'authenticated' ? ( + void handleRetryUnsyncedMomentUploads()} + > + + {isSyncingMomentUploads ? '업로드 중' : '지금 업로드'} + + + ) : ( + + 로그인하면 로컬에 남긴 Moment를 서버에 동기화할 수 있어요. + + ) + ) : null} + + ) : null} + + {pendingActionCount > 0 ? ( + + + 서버에 아직 반영되지 않은 Moment 변경 {pendingActionCount}개가 있어요. + + void handleRetryPendingMomentActions()} + > + + {isSyncingPendingActions ? '동기화 중' : '지금 동기화'} + + ) : null} + router.push(`/recap-share/${moment.id}`)} + pendingMomentActionId={pendingMomentActionId} + sessionStatus={session.status} + trackCount={travelLogTrackCount} + /> + void handleCommunityRecapCreated(recap)} sessionId={session.id} sessionStatus={session.status} + trackCount={trackCount} /> @@ -312,7 +1158,7 @@ export function TravelScreen() { {moments.length === 0 ? ( - + 아직 저장한 Moment가 없어요. 여행 중 카메라 버튼으로 첫 순간을 남겨보세요. @@ -342,7 +1188,7 @@ export function TravelScreen() { {localRecaps.length === 0 ? ( - + 여행이 끝나면 저장한 Moment가 Recap으로 묶여요. diff --git a/src/components/travel/live-sound-map/LiveSoundMapSection.tsx b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx index 8f18d66..fa3bc7a 100644 --- a/src/components/travel/live-sound-map/LiveSoundMapSection.tsx +++ b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx @@ -2,9 +2,11 @@ import { Feather } from '@expo/vector-icons'; import { useEffect, useMemo, useState } from 'react'; import { Pressable, View } from 'react-native'; +import { ApiError } from '@/api/client'; import { communityApi } from '@/api/communityApi'; import { syncRecommendationEvent } from '@/api/recommendationEventApi'; import { AppText } from '@/components/AppText'; +import { useAuthStore } from '@/store/authStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import type { GeoPoint, @@ -12,13 +14,19 @@ import type { PlaceContext, SoundMapPin as ServerSoundMapPin, Track, + TravelMateRequest, TravelMode, } from '@/types/domain'; import { formatPlaceLabel } from '@/utils/placeLabel'; import { OpenLayersSoundMap } from './OpenLayersSoundMap'; -import { createSoundMapCenter, createSoundMapPins } from './soundMapData'; +import { + createFallbackMusicMatches, + createSoundMapCenter, + createSoundMapPins, +} from './soundMapData'; import type { SoundMapPin, SoundMapVisibility } from './types'; +import { modeLabelByValue } from '../travelData'; type LiveSoundMapSectionProps = { currentLocation?: GeoPoint; @@ -50,6 +58,32 @@ const visibilityCopy: Record = { + cafe_together: '근처 카페 산책 코스 같이 볼까요?', + liked_track: '이 곡 좋아해서 눌렀어요', + walk_together: '근처 산책 코스 같이 볼까요?', +}; + +type SoundMapPublishState = 'blocked' | 'failed' | 'idle' | 'private' | 'synced' | 'syncing'; + +function createProfileSummary(match: MusicMatch) { + return [ + ...match.pin.profile.preferredMoods.slice(0, 1), + ...match.pin.profile.preferredGenres.slice(0, 1), + ...match.pin.profile.travelStyles.slice(0, 1), + ] + .filter(Boolean) + .join(' · ') || '취향 공개'; +} + +function getMateRequestErrorMessage(error: unknown) { + if (error instanceof ApiError && error.message) { + return error.message; + } + + return '동행 요청을 보내지 못했어요. 잠시 후 다시 시도해주세요.'; +} + export function LiveSoundMapSection({ currentLocation, currentPlace, @@ -62,19 +96,158 @@ export function LiveSoundMapSection({ const [serverPins, setServerPins] = useState([]); const [matches, setMatches] = useState([]); const [mapMessage, setMapMessage] = useState(); + const [isLoadingMateRequests, setIsLoadingMateRequests] = useState(false); + const [mateRequests, setMateRequests] = useState([]); const [requestedMatchIds, setRequestedMatchIds] = useState>({}); const [hiddenMatchIds, setHiddenMatchIds] = useState>({}); const [pendingMatchId, setPendingMatchId] = useState(); + const [pendingMateRequestActionId, setPendingMateRequestActionId] = useState(); + const [publishState, setPublishState] = useState('idle'); + const authStatus = useAuthStore((state) => state.status); + const currentUserId = useAuthStore((state) => state.user?.id); const addEvent = useRecommendationEventStore((state) => state.addEvent); const center = useMemo( () => createSoundMapCenter(currentLocation, currentPlace), [currentLocation, currentPlace], ); const isTravelActive = sessionStatus === 'active'; - const isLive = isTravelActive && visibility !== 'private'; const syncLocation = currentLocation ?? currentPlace?.location; const locationLabel = currentPlace?.title ?? formatPlaceLabel(center); + const currentTrackLabel = currentTrack + ? `${currentTrack.title} - ${currentTrack.artist}` + : '선택한 곡 없음'; const visibilityMessage = visibilityCopy[visibility]; + const canSyncSoundMap = isTravelActive && authStatus === 'authenticated'; + const canPublishNearby = canSyncSoundMap && Boolean(syncLocation) && Boolean(currentTrack); + const canPublishCurrentTrack = + canSyncSoundMap && Boolean(syncLocation) && Boolean(currentTrack) && visibility !== 'private'; + const liveStatus = !isTravelActive + ? 'off' + : authStatus !== 'authenticated' + ? 'preview' + : visibility === 'private' + ? 'private' + : !syncLocation + ? 'locationMissing' + : !currentTrack + ? 'trackMissing' + : publishState === 'syncing' + ? 'syncing' + : publishState === 'failed' + ? 'failed' + : publishState === 'synced' + ? 'live' + : 'ready'; + const isLive = liveStatus === 'live'; + const liveStatusCopy = { + failed: { + badge: 'RETRY', + description: '서버에 공개 상태를 저장하지 못했어요. 네트워크가 회복되면 다시 시도할 수 있어요.', + title: '공개 실패', + }, + live: { + badge: 'LIVE', + description: visibilityMessage.description, + title: visibilityMessage.title, + }, + locationMissing: { + badge: 'READY', + description: '현재 위치나 장소 좌표가 있어야 지도에 음악 핀을 남길 수 있어요.', + title: '위치 필요', + }, + off: { + badge: 'OFF', + description: '여행을 시작하면 현재 위치와 Soundlog에서 선택한 음악을 지도에 표시할 수 있어요.', + title: '여행 모드 OFF', + }, + preview: { + badge: 'PREVIEW', + description: '로그인하면 서버 지도에 현재 음악을 공개할 수 있어요. 지금은 로컬 미리보기예요.', + title: '미리보기', + }, + private: { + badge: 'PRIVATE', + description: visibilityMessage.description, + title: visibilityMessage.title, + }, + ready: { + badge: 'READY', + description: '현재 공개 범위를 서버에 저장하는 중이에요.', + title: visibilityMessage.title, + }, + syncing: { + badge: 'SYNC', + description: '현재 곡과 위치 공개 범위를 서버에 저장하고 있어요.', + title: visibilityMessage.title, + }, + trackMissing: { + badge: 'READY', + description: '추천 플레이리스트에서 곡을 선택하거나 외부 링크를 열면 현재 음악으로 공개할 수 있어요.', + title: '현재 곡 없음', + }, + }[liveStatus]; + const statusBadgeClassName = isLive + ? 'bg-soundlog-lime' + : liveStatus === 'failed' + ? 'bg-amber-300/15' + : 'bg-white/10'; + const statusBadgeTextClassName = isLive + ? 'text-soundlog-inverse' + : liveStatus === 'failed' + ? 'text-amber-100' + : 'text-white/55'; + const statusIconName = isLive + ? 'radio' + : liveStatus === 'syncing' + ? 'upload-cloud' + : liveStatus === 'trackMissing' + ? 'music' + : liveStatus === 'locationMissing' + ? 'map-pin' + : liveStatus === 'failed' + ? 'alert-circle' + : 'lock'; + const statusIconColor = isLive + ? '#B7E628' + : liveStatus === 'failed' + ? '#FDE68A' + : 'rgba(255,255,255,0.72)'; + const statusPillLabel = isLive + ? '표시중' + : liveStatus === 'syncing' + ? '동기화' + : liveStatus === 'private' + ? '숨김' + : liveStatus === 'failed' + ? '재시도' + : liveStatus === 'preview' + ? '미리보기' + : undefined; + const nearbyCtaTitle = canPublishNearby ? '주변 사운드 취향 보기' : '주변 사운드 취향 미리보기'; + const nearbyCtaDescription = canPublishNearby + ? '대략 위치와 현재 음악만 공개하고 취향이 맞는 여행자를 찾아요.' + : '로그인, 여행 모드, 위치, 현재 곡이 준비되면 주변 익명 공개로 전환돼요.'; + const shouldShowPreviewPeers = !canSyncSoundMap && !canPublishCurrentTrack; + const fallbackMatches = useMemo( + () => + createFallbackMusicMatches({ + center, + currentTrack, + place: currentPlace, + selectedMode, + }), + [center, currentPlace, currentTrack, selectedMode], + ); + const activeMatches = + visibility === 'nearby' && matches.length === 0 && shouldShowPreviewPeers + ? fallbackMatches + : matches; + const visibleMatches = activeMatches.filter((match) => !hiddenMatchIds[match.id]).slice(0, 2); + const pendingMateRequests = mateRequests + .filter((request) => request.status === 'pending') + .slice(0, 3); + const selectedModeLabel = selectedMode ? modeLabelByValue[selectedMode] : '산책'; + const matchModeTabs = Array.from(new Set([selectedModeLabel, '카페', '야경'])).slice(0, 3); const pins = useMemo(() => { const matchScoreByPinId = new Map(matches.map((match) => [match.targetPinId, match.matchScore])); const serverVisualPins = serverPins.map((pin) => toVisualPin(pin, matchScoreByPinId.get(pin.id))); @@ -87,13 +260,19 @@ export function LiveSoundMapSection({ return [...serverVisualPins, ...matchVisualPins]; } - return createSoundMapPins({ center, currentTrack, visibility }); - }, [center, currentTrack, matches, serverPins, visibility]); + return createSoundMapPins({ + center, + currentTrack, + includePreviewPeers: shouldShowPreviewPeers, + visibility, + }); + }, [center, currentTrack, matches, serverPins, shouldShowPreviewPeers, visibility]); useEffect(() => { - if (!isTravelActive) { + if (!canSyncSoundMap) { setServerPins([]); setMatches([]); + setMateRequests([]); return; } @@ -135,24 +314,68 @@ export function LiveSoundMapSection({ return () => { ignore = true; }; - }, [isTravelActive, syncLocation?.lat, syncLocation?.lng, visibility]); + }, [canSyncSoundMap, syncLocation?.lat, syncLocation?.lng, visibility]); useEffect(() => { - if (!isLive || !currentTrack || !syncLocation) { + if (!canSyncSoundMap) { + setMateRequests([]); return; } let ignore = false; + setIsLoadingMateRequests(true); + communityApi + .getTravelMateRequests({ box: 'all', limit: 6, status: 'pending' }) + .then((requests) => { + if (!ignore) { + setMateRequests(requests); + } + }) + .catch(() => { + if (!ignore) { + setMapMessage('동행 요청함을 불러오지 못했어요.'); + } + }) + .finally(() => { + if (!ignore) { + setIsLoadingMateRequests(false); + } + }); + + return () => { + ignore = true; + }; + }, [canSyncSoundMap]); + + useEffect(() => { + if (!canSyncSoundMap) { + setPublishState(isTravelActive ? 'blocked' : 'idle'); + return; + } + + if (!syncLocation) { + setPublishState('blocked'); + return; + } + + if (!currentTrack && visibility !== 'private') { + setPublishState('blocked'); + return; + } + + let ignore = false; + + setPublishState(visibility === 'private' ? 'private' : 'syncing'); communityApi .upsertCurrentTrack({ - artistName: currentTrack.artist, + artistName: currentTrack?.artist, location: syncLocation, moodTags: [], placeName: currentPlace?.title, sessionId, - trackId: currentTrack.id, - trackTitle: currentTrack.title, + trackId: currentTrack?.id, + trackTitle: currentTrack?.title, travelMode: selectedMode, visibility, }) @@ -163,10 +386,12 @@ export function LiveSoundMapSection({ setServerPins((pins) => [pin, ...pins.filter((item) => item.id !== pin.id)]); setMapMessage(undefined); + setPublishState(visibility === 'private' ? 'private' : 'synced'); }) .catch(() => { if (!ignore) { setMapMessage('현재 곡 공개 상태를 서버에 저장하지 못했어요.'); + setPublishState('failed'); } }); @@ -174,11 +399,13 @@ export function LiveSoundMapSection({ ignore = true; }; }, [ + authStatus, + canSyncSoundMap, currentPlace?.title, currentTrack?.artist, currentTrack?.id, currentTrack?.title, - isLive, + isTravelActive, selectedMode, sessionId, syncLocation, @@ -224,33 +451,67 @@ export function LiveSoundMapSection({ } setRequestedMatchIds((state) => ({ ...state, [match.id]: request.id })); + setMateRequests((requests) => [ + request, + ...requests.filter((item) => item.id !== request.id), + ]); setMapMessage('동행 요청을 보냈어요. 상대가 수락하기 전까지 연락처와 정확한 위치는 숨겨져요.'); - } catch { - setMapMessage('동행 요청을 보내지 못했어요. 잠시 후 다시 시도해주세요.'); + } catch (error) { + setMapMessage(getMateRequestErrorMessage(error)); } finally { setPendingMatchId(undefined); } }; - const handleReportMatch = async (match: MusicMatch) => { + const handleReportAndBlockMatch = async (match: MusicMatch) => { try { await communityApi.reportTarget({ reason: 'safety', targetPinId: match.targetPinId, }); - setMapMessage('신고를 접수했어요. 이 매칭은 계속 볼 수 있지만 정확한 위치와 연락처는 노출되지 않아요.'); - } catch { - setMapMessage('신고를 접수하지 못했어요. 잠시 후 다시 시도해주세요.'); - } - }; - const handleBlockMatch = async (match: MusicMatch) => { - try { await communityApi.blockUser({ targetPinId: match.targetPinId }); setHiddenMatchIds((state) => ({ ...state, [match.id]: true })); setMatches((items) => items.filter((item) => item.id !== match.id)); setServerPins((items) => items.filter((item) => item.id !== match.targetPinId)); - setMapMessage('차단했어요. 해당 여행자의 공개 음악은 더 이상 추천에 보이지 않아요.'); + setMapMessage('차단/신고를 접수했어요. 해당 여행자의 공개 음악은 더 이상 추천에 보이지 않아요.'); } catch { - setMapMessage('차단하지 못했어요. 잠시 후 다시 시도해주세요.'); + setMapMessage('차단/신고를 접수하지 못했어요. 잠시 후 다시 시도해주세요.'); + } + }; + const handleUpdateMateRequest = async ( + request: TravelMateRequest, + action: 'accept' | 'cancel' | 'decline', + ) => { + if (pendingMateRequestActionId) { + return; + } + + setPendingMateRequestActionId(request.id); + setMapMessage(undefined); + + try { + const updated = await communityApi.updateTravelMateRequest(request.id, action); + + if (!updated) { + setMapMessage('로그인된 서버 세션에서 동행 요청을 처리할 수 있어요.'); + return; + } + + setMateRequests((requests) => + updated.status === 'pending' + ? requests.map((item) => (item.id === updated.id ? updated : item)) + : requests.filter((item) => item.id !== updated.id), + ); + setMapMessage( + action === 'accept' + ? '동행 요청을 수락했어요. 정확한 위치와 연락처는 계속 선택 공개로 유지돼요.' + : action === 'decline' + ? '동행 요청을 거절했어요.' + : '보낸 동행 요청을 취소했어요.', + ); + } catch { + setMapMessage('동행 요청 상태를 바꾸지 못했어요. 잠시 후 다시 시도해주세요.'); + } finally { + setPendingMateRequestActionId(undefined); } }; @@ -267,22 +528,18 @@ export function LiveSoundMapSection({ 지금 듣는 음악을 지도에 남겨요 - + OpenLayers 지도를 Soundlog 다크 테마로 조정하고, 여행 모드 중인 내 음악과 동행자/주변 익명 핀을 함께 보여줘요. - {isLive ? 'LIVE' : 'PREVIEW'} + {liveStatusCopy.badge} @@ -299,28 +556,44 @@ export function LiveSoundMapSection({ {mapMessage ? ( - {mapMessage} + {mapMessage} ) : null} - + + + 내 현재 음악 + - {isTravelActive ? visibilityMessage.title : '여행 모드 OFF'} + {liveStatusCopy.title} - {isTravelActive - ? visibilityMessage.description - : '여행을 시작하면 현재 위치와 Soundlog에서 선택한 음악을 지도에 표시할 수 있어요.'} + {liveStatusCopy.description} - {locationLabel} · {currentTrack ? `${currentTrack.title} - ${currentTrack.artist}` : '선택한 음악 없음'} + {locationLabel} · {currentTrackLabel} + {statusPillLabel ? ( + + + {statusPillLabel} + + + ) : null} @@ -335,7 +608,7 @@ export function LiveSoundMapSection({ className={`min-h-[44px] flex-1 basis-[92px] items-center justify-center rounded-full border px-3 ${ selected ? 'border-soundlog-lime bg-soundlog-lime' - : 'border-white/12 bg-white/10' + : 'border-white/10 bg-white/10' }`} key={option.value} onPress={() => handleVisibilityPress(option.value)} @@ -352,65 +625,237 @@ export function LiveSoundMapSection({ })} - {visibility === 'nearby' && matches.length > 0 ? ( - - 주변 음악 취향 매칭 - {matches.filter((match) => !hiddenMatchIds[match.id]).slice(0, 2).map((match) => { - const requested = Boolean(requestedMatchIds[match.id]); - const disabled = requested || pendingMatchId === match.id; + {canSyncSoundMap && (isLoadingMateRequests || pendingMateRequests.length > 0) ? ( + + + + 동행 요청함 + + 수락 전에는 연락처와 정확한 위치를 공개하지 않아요. + + + + + {isLoadingMateRequests ? '동기화' : `${pendingMateRequests.length}건`} + + + + + + {pendingMateRequests.map((request) => { + const isIncoming = request.targetUserId === currentUserId; + const isBusy = pendingMateRequestActionId === request.id; + + return ( + + + + + {isIncoming ? '받은 요청' : '보낸 요청 대기'} + + + {mateRequestMessageLabel[request.messageTemplate]} + + + + + 대기중 + + + + + + {isIncoming ? ( + <> + void handleUpdateMateRequest(request, 'accept')} + style={{ opacity: isBusy ? 0.55 : 1 }} + > + + {isBusy ? '처리 중' : '수락'} + + + void handleUpdateMateRequest(request, 'decline')} + style={{ opacity: isBusy ? 0.55 : 1 }} + > + + 거절 + + + + ) : ( + void handleUpdateMateRequest(request, 'cancel')} + style={{ opacity: isBusy ? 0.55 : 1 }} + > + + {isBusy ? '취소 중' : '요청 취소'} + + + )} + + + ); + })} + + + ) : null} + + {visibility !== 'nearby' ? ( + handleVisibilityPress('nearby')} + > + + + {nearbyCtaTitle} + + {nearbyCtaDescription} + + + + + + + + ) : ( + + + 주변 사운드 취향 + + 대략 위치만 공개 · 2시간 후 자동 숨김 + + - return ( + + {matchModeTabs.map((label, index) => ( - - - - {match.pin.track?.title ?? '공개한 음악'} · {match.matchScore}% + + {label} + + + ))} + + + {visibleMatches.length > 0 ? ( + visibleMatches.map((match) => { + const requested = Boolean(requestedMatchIds[match.id]); + const disabled = requested || pendingMatchId === match.id; + + return ( + + + + 동행 매칭 요청 + + + 취향 {match.matchScore}% 일치 · {match.pin.placeName ?? '성수 근처'} · 정확한 위치 비공개 + + + + + + + {match.pin.alias} + + + {createProfileSummary(match)} · {match.pin.placeName ?? '대략 위치'} · 정확한 좌표 숨김 + + + + + {match.matchScore}% + + + + + + 공개 사운드 + + + + {match.pin.track?.title ?? '공개한 음악'} - {match.pin.alias} · {match.pin.placeName ?? '대략 위치'} · 정확한 좌표 숨김 + {match.pin.track?.artist ?? match.pin.alias} · 음악 취향으로 먼저 판단해요 - void handleMateRequest(match)} - > - + + 정확한 위치는 서로 수락 후에도 선택 공개 + + + 첫 메시지는 템플릿으로만 시작 + + + + + void handleMateRequest(match)} > - {requested ? '요청됨' : pendingMatchId === match.id ? '전송 중' : '동행 요청'} - - - - - void handleReportMatch(match)} - > - 신고 - - void handleBlockMatch(match)} - > - 차단 - + + {requested ? '요청됨' : pendingMatchId === match.id ? '전송 중' : '취향으로 인사'} + + + void handleReportAndBlockMatch(match)} + > + 차단/신고 + + - - ); - })} + ); + }) + ) : ( + + + 주변 공개 음악을 찾는 중이에요 + + + 아직 매칭 후보가 없어도 내 현재 음악 핀은 지도에 남고, 새 공개 핀이 들어오면 이 영역에 표시돼요. + + + )} - ) : null} + )} ); } diff --git a/src/components/travel/live-sound-map/OpenLayersSoundMap.tsx b/src/components/travel/live-sound-map/OpenLayersSoundMap.tsx index c81ff40..e92ce62 100644 --- a/src/components/travel/live-sound-map/OpenLayersSoundMap.tsx +++ b/src/components/travel/live-sound-map/OpenLayersSoundMap.tsx @@ -12,12 +12,24 @@ const pinPositions: Record = 'nearby-rainy': { left: '52%', top: '66%' }, }; +const fallbackPinPositions: Array<{ left: `${number}%`; top: `${number}%` }> = [ + { left: '36%', top: '43%' }, + { left: '62%', top: '28%' }, + { left: '52%', top: '66%' }, + { left: '22%', top: '31%' }, + { left: '70%', top: '58%' }, +]; + const kindClassName = { companion: 'border-soundlog-blue bg-[#132244]', me: 'border-soundlog-lime bg-[#1E2B16]', nearby: 'border-soundlog-warning bg-[#2A1A15]', } as const; +function getPinPosition(pinId: string, index: number) { + return pinPositions[pinId] ?? fallbackPinPositions[index % fallbackPinPositions.length]; +} + export function OpenLayersSoundMap({ pins, sessionStatus, visibility }: SoundMapViewport) { const isLive = sessionStatus === 'active' && visibility !== 'private'; @@ -35,8 +47,8 @@ export function OpenLayersSoundMap({ pins, sessionStatus, visibility }: SoundMap - {pins.map((pin) => { - const position = pinPositions[pin.id] ?? pinPositions.me; + {pins.map((pin, index) => { + const position = getPinPosition(pin.id, index); return ( > = { + cafe: '카페', + drive: '드라이브', + night: '야경', + ocean: '바다', + walk: '산책', }; function offsetLocation(center: GeoPoint, latOffset: number, lngOffset: number): GeoPoint { @@ -27,26 +42,30 @@ export function createSoundMapCenter(location?: GeoPoint, place?: PlaceContext): export function createSoundMapPins({ center, currentTrack, + includePreviewPeers = true, visibility, }: { center: GeoPoint; currentTrack?: Track; + includePreviewPeers?: boolean; visibility: SoundMapVisibility; }): SoundMapPin[] { - const selectedTrack = currentTrack ?? fallbackTrack; - const pins: SoundMapPin[] = [ - { - artistName: selectedTrack.artist, - id: 'me', - kind: 'me', - label: '나', - location: center, - subtitle: 'Soundlog에서 선택한 현재 곡', - trackTitle: selectedTrack.title, - }, - ]; + const selectedTrack = currentTrack ?? (includePreviewPeers ? fallbackTrack : undefined); + const pins: SoundMapPin[] = selectedTrack + ? [ + { + artistName: selectedTrack.artist, + id: 'me', + kind: 'me', + label: '나', + location: center, + subtitle: 'Soundlog에서 선택한 현재 곡', + trackTitle: selectedTrack.title, + }, + ] + : []; - if (visibility === 'private') { + if (!includePreviewPeers || visibility === 'private') { return pins; } @@ -87,3 +106,115 @@ export function createSoundMapPins({ return pins; } + +function createFallbackSoundMapPin({ + center, + id, + index, + matchMood, + placeName, + title, + track, +}: { + center: GeoPoint; + id: string; + index: number; + matchMood: string; + placeName: string; + title: string; + track: Track; +}): DomainSoundMapPin { + const offsets = [ + { lat: -0.0038, lng: -0.0046 }, + { lat: 0.0022, lng: -0.006 }, + ]; + const offset = offsets[index] ?? offsets[0]; + + return { + alias: title, + expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), + id, + isMine: false, + location: offsetLocation(center, offset.lat, offset.lng), + moodTags: [], + placeName, + profile: { + preferredGenres: index === 0 ? ['인디'] : ['시티팝'], + preferredMoods: index === 0 ? ['잔잔한', matchMood] : ['설레는', matchMood], + travelStyles: index === 0 ? ['혼자 여행'] : ['사진 산책'], + }, + track, + updatedAt: new Date().toISOString(), + visibility: 'nearby', + }; +} + +export function createFallbackMusicMatches({ + center, + currentTrack, + place, + selectedMode, +}: { + center: GeoPoint; + currentTrack?: Track; + place?: PlaceContext; + selectedMode?: TravelMode; +}): MusicMatch[] { + const matchMood = selectedMode ? travelModeLabelByValue[selectedMode] ?? '산책' : '산책'; + const firstTrack: Track = currentTrack ?? { + artist: '아티스트', + fallbackColor: '#2B176C', + id: 'fallback-match-track-rainy', + title: '곡명 C', + }; + const secondTrack: Track = { + artist: 'City Sound', + fallbackColor: '#9EA8FF', + id: 'fallback-match-track-night', + title: 'City Light Walk', + }; + const placeName = place?.title ?? '성수 근처'; + const firstPin = createFallbackSoundMapPin({ + center, + id: 'nearby-rainy', + index: 0, + matchMood, + placeName, + title: '비 오는 골목 닉네임', + track: firstTrack, + }); + const secondPin = createFallbackSoundMapPin({ + center, + id: 'nearby-night', + index: 1, + matchMood, + placeName: '대략 위치 공개', + title: '야경 수집가', + track: secondTrack, + }); + + return [ + { + id: 'fallback-match-rainy', + matchScore: 82, + pin: firstPin, + safety: { + contactHiddenUntilAccepted: true, + exactLocationHidden: true, + firstMessageTemplates: ['liked_track', 'walk_together'], + }, + targetPinId: firstPin.id, + }, + { + id: 'fallback-match-night', + matchScore: 76, + pin: secondPin, + safety: { + contactHiddenUntilAccepted: true, + exactLocationHidden: true, + firstMessageTemplates: ['cafe_together', 'liked_track'], + }, + targetPinId: secondPin.id, + }, + ]; +} diff --git a/src/hooks/useRecapShareActions.ts b/src/hooks/useRecapShareActions.ts index 7a97974..9800ae1 100644 --- a/src/hooks/useRecapShareActions.ts +++ b/src/hooks/useRecapShareActions.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Platform } from 'react-native'; import { recapApi } from '@/api/recapApi'; @@ -19,34 +19,104 @@ type MediaPermissionResponse = { accessPrivileges?: 'all' | 'limited' | 'none'; }; +type RecapShareActionErrorCode = + | 'capture_failed' + | 'media_permission_denied' + | 'media_save_failed' + | 'share_failed' + | 'sharing_unavailable'; + +class RecapShareActionError extends Error { + code: RecapShareActionErrorCode; + + constructor(code: RecapShareActionErrorCode) { + super(code); + this.code = code; + } +} + function canWriteToLibrary(permission: MediaPermissionResponse) { return permission.granted || permission.accessPrivileges === 'limited'; } +function getActionErrorMessage( + action: ShareActionId, + error: unknown, +): RecapShareMessage { + if (error instanceof RecapShareActionError) { + switch (error.code) { + case 'capture_failed': + return { + text: '리캡 이미지를 캡처하지 못했어요. 화면을 다시 연 뒤 시도해주세요.', + type: 'error', + }; + case 'media_permission_denied': + return { + text: '리캡 이미지를 저장하려면 사진 보관함 권한이 필요해요.', + type: 'error', + }; + case 'sharing_unavailable': + return { + text: '이 기기에서는 공유 기능을 사용할 수 없어요.', + type: 'error', + }; + case 'media_save_failed': + return { + text: '리캡 이미지를 사진 보관함에 저장하지 못했어요. 잠시 후 다시 시도해주세요.', + type: 'error', + }; + case 'share_failed': + return { + text: '공유 시트를 열지 못했어요. 잠시 후 다시 시도해주세요.', + type: 'error', + }; + } + } + + return { + text: + action === 'save' + ? '리캡 이미지를 저장하지 못했어요. 다시 시도해주세요.' + : '리캡 이미지를 공유하지 못했어요. 다시 시도해주세요.', + type: 'error', + }; +} + export function useRecapShareActions({ capture, recapId }: UseRecapShareActionsParams) { const [activeAction, setActiveAction] = useState(); const [message, setMessage] = useState(); + const activeActionRef = useRef(undefined); const runAction = async (action: ShareActionId, task: () => Promise) => { - if (activeAction) { + if (activeActionRef.current) { return; } + activeActionRef.current = action; setActiveAction(action); setMessage(undefined); try { await task(); + } catch (error) { + setMessage(getActionErrorMessage(action, error)); } finally { + activeActionRef.current = undefined; setActiveAction(undefined); } }; const captureRecap = async () => { - const uri = await capture(); + let uri: string | undefined; + + try { + uri = await capture(); + } catch { + throw new RecapShareActionError('capture_failed'); + } if (!uri) { - throw new Error('capture_failed'); + throw new RecapShareActionError('capture_failed'); } return uri; @@ -70,31 +140,33 @@ export function useRecapShareActions({ capture, recapId }: UseRecapShareActionsP return; } + let MediaLibrary: typeof import('expo-media-library'); + let permission: MediaPermissionResponse; + try { - const uri = await captureRecap(); - const MediaLibrary = await import('expo-media-library'); - const permission = await MediaLibrary.requestPermissionsAsync(true); - - if (!canWriteToLibrary(permission)) { - setMessage({ - text: '리캡 이미지를 저장하려면 사진 보관함 권한이 필요해요.', - type: 'error', - }); - return; - } - - await MediaLibrary.createAssetAsync(uri); - syncShareEvent('save_image'); - setMessage({ - text: '리캡 이미지가 사진 보관함에 저장됐어요.', - type: 'success', - }); + MediaLibrary = await import('expo-media-library'); + permission = await MediaLibrary.requestPermissionsAsync(true); } catch { - setMessage({ - text: '리캡 이미지를 저장하지 못했어요. 다시 시도해주세요.', - type: 'error', - }); + throw new RecapShareActionError('media_save_failed'); + } + + if (!canWriteToLibrary(permission)) { + throw new RecapShareActionError('media_permission_denied'); } + + const uri = await captureRecap(); + + try { + await MediaLibrary.saveToLibraryAsync(uri); + } catch { + throw new RecapShareActionError('media_save_failed'); + } + + syncShareEvent('save_image'); + setMessage({ + text: '리캡 이미지가 사진 보관함에 저장됐어요.', + type: 'success', + }); }); const share = () => @@ -107,31 +179,37 @@ export function useRecapShareActions({ capture, recapId }: UseRecapShareActionsP return; } + let Sharing: typeof import('expo-sharing'); + let isAvailable = false; + + try { + Sharing = await import('expo-sharing'); + isAvailable = await Sharing.isAvailableAsync(); + } catch { + throw new RecapShareActionError('share_failed'); + } + + if (!isAvailable) { + throw new RecapShareActionError('sharing_unavailable'); + } + + const uri = await captureRecap(); + try { - const Sharing = await import('expo-sharing'); - const isAvailable = await Sharing.isAvailableAsync(); - - if (!isAvailable) { - setMessage({ - text: '이 기기에서는 공유 기능을 사용할 수 없어요.', - type: 'error', - }); - return; - } - - const uri = await captureRecap(); await Sharing.shareAsync(uri, { UTI: 'public.png', dialogTitle: 'Soundlog Recap 공유', mimeType: 'image/png', }); - syncShareEvent('os_share'); } catch { - setMessage({ - text: '리캡 이미지를 공유하지 못했어요. 다시 시도해주세요.', - type: 'error', - }); + throw new RecapShareActionError('share_failed'); } + + syncShareEvent('os_share'); + setMessage({ + text: '공유 시트를 열었어요. 공유를 취소해도 Recap은 그대로 남아 있어요.', + type: 'success', + }); }); return { diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 9f3417e..6d3879f 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -19,7 +19,6 @@ type AuthState = { updatedAt?: string; user?: AuthUser; clearAuthError: () => void; - continueAsGuest: () => void; finishLogin: (session: AuthSession) => void; logoutLocal: () => void; setAuthError: (message?: string) => void; @@ -37,18 +36,37 @@ const unauthenticatedState = { user: undefined, }; +function normalizePersistedAuthState(persistedState: unknown) { + if (!persistedState || typeof persistedState !== 'object') { + return unauthenticatedState; + } + + const state = persistedState as Partial & { status?: string }; + + if (state.status !== 'authenticated' || !state.accessToken || !state.refreshToken || !state.user) { + return { + ...unauthenticatedState, + updatedAt: new Date().toISOString(), + }; + } + + return { + accessToken: state.accessToken, + errorMessage: undefined, + lastLoginProvider: state.lastLoginProvider, + refreshToken: state.refreshToken, + status: 'authenticated' as const, + updatedAt: state.updatedAt, + user: state.user, + }; +} + export const useAuthStore = create()( persist( (set) => ({ ...unauthenticatedState, isHydrated: false, clearAuthError: () => set({ errorMessage: undefined }), - continueAsGuest: () => - set({ - ...unauthenticatedState, - status: 'guest', - updatedAt: new Date().toISOString(), - }), finishLogin: (session) => set({ accessToken: session.accessToken, @@ -69,6 +87,7 @@ export const useAuthStore = create()( setStatus: (status) => set({ status }), }), { + migrate: (persistedState) => normalizePersistedAuthState(persistedState), name: 'soundlog-auth', onRehydrateStorage: () => (state) => { state?.setHydrated(true); @@ -82,6 +101,7 @@ export const useAuthStore = create()( user: state.user, }), storage: createJSONStorage(createAuthStorage), + version: 1, }, ), ); diff --git a/src/store/libraryStore.ts b/src/store/libraryStore.ts index d1807fc..18ed886 100644 --- a/src/store/libraryStore.ts +++ b/src/store/libraryStore.ts @@ -2,10 +2,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; -import { Track } from '@/types/domain'; +import { LibraryPlaylistSummary, Track } from '@/types/domain'; export type LibraryTrackRecord = { createdAt: string; + playlist?: LibraryPlaylistSummary; playlistId?: string; track: Track; }; @@ -18,21 +19,61 @@ type LibraryState = { isSaved: (trackId?: string) => boolean; removeLikedTrack: (trackId: string) => void; removeSavedTrack: (trackId: string) => void; - seedFromPlaylist: (playlistId: string, tracks: Track[]) => void; - setLikeState: (track: Track, isLiked: boolean, playlistId?: string) => void; - setSaveState: (track: Track, isSaved: boolean, playlistId?: string) => void; - toggleLike: (track: Track, playlistId?: string) => void; - toggleSave: (track: Track, playlistId?: string) => void; + seedFromPlaylist: ( + playlistId: string, + tracks: Track[], + playlist?: LibraryPlaylistSummary, + ) => void; + setLikeState: ( + track: Track, + isLiked: boolean, + playlistId?: string, + playlist?: LibraryPlaylistSummary, + ) => void; + setSaveState: ( + track: Track, + isSaved: boolean, + playlistId?: string, + playlist?: LibraryPlaylistSummary, + ) => void; + toggleLike: (track: Track, playlistId?: string, playlist?: LibraryPlaylistSummary) => void; + toggleSave: (track: Track, playlistId?: string, playlist?: LibraryPlaylistSummary) => void; }; -function upsertRecord(records: LibraryTrackRecord[], track: Track, playlistId?: string) { +function upsertRecord( + records: LibraryTrackRecord[], + track: Track, + playlistId?: string, + playlist?: LibraryPlaylistSummary, +) { const createdAt = new Date().toISOString(); return [ - { createdAt, playlistId, track: { ...track, isLiked: undefined, isSaved: undefined } }, + { + createdAt, + playlist, + playlistId: playlistId ?? playlist?.id, + track: { ...track, isLiked: undefined, isSaved: undefined }, + }, ...records.filter((record) => record.track.id !== track.id), ]; } +function applyPlaylistMetadata( + records: LibraryTrackRecord[], + playlistId: string, + playlist?: LibraryPlaylistSummary, +) { + if (!playlist) { + return records; + } + + return records.map((record) => + record.playlistId === playlistId && !record.playlist + ? { ...record, playlist } + : record, + ); +} + export const useLibraryStore = create()( persist( (set, get) => ({ @@ -51,58 +92,61 @@ export const useLibraryStore = create()( set((state) => ({ savedTracks: state.savedTracks.filter((record) => record.track.id !== trackId), })), - seedFromPlaylist: (playlistId, tracks) => + seedFromPlaylist: (playlistId, tracks, playlist) => set((state) => { if (state.seededPlaylistIds.includes(playlistId)) { - return {}; + return { + likedTracks: applyPlaylistMetadata(state.likedTracks, playlistId, playlist), + savedTracks: applyPlaylistMetadata(state.savedTracks, playlistId, playlist), + }; } return { likedTracks: tracks .filter((track) => track.isLiked) .reduce( - (records, track) => upsertRecord(records, track, playlistId), + (records, track) => upsertRecord(records, track, playlistId, playlist), state.likedTracks, ), savedTracks: tracks .filter((track) => track.isSaved) .reduce( - (records, track) => upsertRecord(records, track, playlistId), + (records, track) => upsertRecord(records, track, playlistId, playlist), state.savedTracks, ), seededPlaylistIds: [...state.seededPlaylistIds, playlistId], }; }), - setLikeState: (track, nextLiked, playlistId) => + setLikeState: (track, nextLiked, playlistId, playlist) => set((state) => ({ likedTracks: nextLiked - ? upsertRecord(state.likedTracks, track, playlistId) + ? upsertRecord(state.likedTracks, track, playlistId, playlist) : state.likedTracks.filter((record) => record.track.id !== track.id), })), - setSaveState: (track, nextSaved, playlistId) => + setSaveState: (track, nextSaved, playlistId, playlist) => set((state) => ({ savedTracks: nextSaved - ? upsertRecord(state.savedTracks, track, playlistId) + ? upsertRecord(state.savedTracks, track, playlistId, playlist) : state.savedTracks.filter((record) => record.track.id !== track.id), })), - toggleLike: (track, playlistId) => + toggleLike: (track, playlistId, playlist) => set((state) => { const isLiked = state.likedTracks.some((record) => record.track.id === track.id); return { likedTracks: isLiked ? state.likedTracks.filter((record) => record.track.id !== track.id) - : upsertRecord(state.likedTracks, track, playlistId), + : upsertRecord(state.likedTracks, track, playlistId, playlist), }; }), - toggleSave: (track, playlistId) => + toggleSave: (track, playlistId, playlist) => set((state) => { const isSaved = state.savedTracks.some((record) => record.track.id === track.id); return { savedTracks: isSaved ? state.savedTracks.filter((record) => record.track.id !== track.id) - : upsertRecord(state.savedTracks, track, playlistId), + : upsertRecord(state.savedTracks, track, playlistId, playlist), }; }), }), diff --git a/src/store/momentLogStore.ts b/src/store/momentLogStore.ts index 8cff1e5..60675f3 100644 --- a/src/store/momentLogStore.ts +++ b/src/store/momentLogStore.ts @@ -2,13 +2,65 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; -import { MomentLog, MusicLogItem } from '@/types/domain'; +import { GeoPoint, MomentLog, MoodTag, MusicLogItem, Track, TravelMode } from '@/types/domain'; + +export type MomentLogCreateQueuePayload = { + createdAt: string; + location?: GeoPoint; + moodTags: MoodTag[]; + note?: string; + photoUri?: string; + placeCategory?: string; + placeId?: string; + placeName?: string; + sessionId?: string; + track?: Track; + travelMode?: TravelMode; +}; + +export type MomentLogEditQueuePayload = { + moodTags: MoodTag[]; + note: string | null; + placeName: string | null; + removePhoto?: boolean; + replacePhotoUri?: string; + track?: Track; +}; + +export type MomentLogPendingAction = + | { + id: string; + momentLogId: string; + payload: MomentLogCreateQueuePayload; + queuedAt: string; + type: 'create'; + } + | { + id: string; + momentLogId: string; + payload: MomentLogEditQueuePayload; + queuedAt: string; + type: 'edit'; + } + | { + id: string; + momentLogId: string; + queuedAt: string; + type: 'delete'; + }; type MomentLogState = { logs: MomentLog[]; + pendingActions: MomentLogPendingAction[]; addLog: (log: MomentLog) => void; getRecentLogs: (limit?: number) => MomentLog[]; + mergeServerLogs: (logs: MomentLog[]) => void; + queueCreate: (momentLogId: string, payload: MomentLogCreateQueuePayload) => void; + queueEdit: (momentLogId: string, payload: MomentLogEditQueuePayload) => void; + queueDelete: (log: MomentLog) => void; + removePendingAction: (id: string) => void; removeLog: (id: string) => void; + resolveLocalLog: (localMomentLogId: string, serverLog: MomentLog) => void; updateLog: (id: string, patch: Partial) => void; }; @@ -24,19 +76,172 @@ export function momentLogToMusicLogItem(log: MomentLog): MusicLogItem { }; } +function sortByNewest(logs: MomentLog[]) { + return [...logs].sort((first, second) => { + const firstTime = new Date(first.createdAt).getTime(); + const secondTime = new Date(second.createdAt).getTime(); + + return secondTime - firstTime; + }); +} + +function getPendingActionId(type: MomentLogPendingAction['type'], momentLogId: string) { + return `${type}:${momentLogId}`; +} + +function dedupePendingActions(actions: MomentLogPendingAction[]) { + return Array.from(new Map(actions.map((action) => [action.id, action])).values()); +} + +function remapPendingAction( + action: MomentLogPendingAction, + nextMomentLogId: string, +): MomentLogPendingAction { + return { + ...action, + id: getPendingActionId(action.type, nextMomentLogId), + momentLogId: nextMomentLogId, + } as MomentLogPendingAction; +} + export const useMomentLogStore = create()( persist( (set, get) => ({ logs: [], + pendingActions: [], addLog: (log) => set((state) => ({ logs: [log, ...state.logs.filter((item) => item.id !== log.id)], })), getRecentLogs: (limit = 10) => get().logs.slice(0, limit), + mergeServerLogs: (serverLogs) => + set((state) => { + const pendingDeleteIds = new Set( + state.pendingActions + .filter((action) => action.type === 'delete') + .map((action) => action.momentLogId), + ); + const pendingEditIds = new Set( + state.pendingActions + .filter((action) => action.type === 'edit') + .map((action) => action.momentLogId), + ); + const localLogsById = new Map(state.logs.map((log) => [log.id, log])); + const visibleServerLogs = serverLogs + .filter((log) => !pendingDeleteIds.has(log.id)) + .map((log) => (pendingEditIds.has(log.id) ? localLogsById.get(log.id) ?? log : log)); + const serverLogIds = new Set(visibleServerLogs.map((log) => log.id)); + const localOnlyLogs = state.logs.filter( + (log) => !serverLogIds.has(log.id) && !pendingDeleteIds.has(log.id), + ); + + return { + logs: sortByNewest([...visibleServerLogs, ...localOnlyLogs]), + }; + }), + queueCreate: (momentLogId, payload) => + set((state) => { + const createActionId = getPendingActionId('create', momentLogId); + const hasPendingDelete = state.pendingActions.some( + (action) => action.type === 'delete' && action.momentLogId === momentLogId, + ); + + if (hasPendingDelete) { + return state; + } + + return { + pendingActions: [ + ...state.pendingActions.filter((action) => action.id !== createActionId), + { + id: createActionId, + momentLogId, + payload, + queuedAt: new Date().toISOString(), + type: 'create', + }, + ], + }; + }), + queueEdit: (momentLogId, payload) => + set((state) => { + const editActionId = getPendingActionId('edit', momentLogId); + const hasPendingDelete = state.pendingActions.some( + (action) => action.type === 'delete' && action.momentLogId === momentLogId, + ); + + if (hasPendingDelete) { + return state; + } + + return { + pendingActions: [ + ...state.pendingActions.filter((action) => action.id !== editActionId), + { + id: editActionId, + momentLogId, + payload, + queuedAt: new Date().toISOString(), + type: 'edit', + }, + ], + }; + }), + queueDelete: (log) => + set((state) => { + const deleteActionId = getPendingActionId('delete', log.id); + + return { + logs: state.logs.filter((item) => item.id !== log.id), + pendingActions: [ + ...state.pendingActions.filter((action) => action.momentLogId !== log.id), + { + id: deleteActionId, + momentLogId: log.id, + queuedAt: new Date().toISOString(), + type: 'delete', + }, + ], + }; + }), + removePendingAction: (id) => + set((state) => ({ + pendingActions: state.pendingActions.filter((action) => action.id !== id), + })), removeLog: (id) => set((state) => ({ logs: state.logs.filter((item) => item.id !== id), + pendingActions: state.pendingActions.filter((action) => action.momentLogId !== id), })), + resolveLocalLog: (localMomentLogId, serverLog) => + set((state) => { + const hasLocalLog = state.logs.some((item) => item.id === localMomentLogId); + + if (!hasLocalLog) { + return state; + } + + const remappedActions = state.pendingActions + .filter( + (action) => + !(action.type === 'create' && action.momentLogId === localMomentLogId), + ) + .map((action) => + action.momentLogId === localMomentLogId + ? remapPendingAction(action, serverLog.id) + : action, + ); + + return { + logs: sortByNewest([ + serverLog, + ...state.logs.filter( + (item) => item.id !== localMomentLogId && item.id !== serverLog.id, + ), + ]), + pendingActions: dedupePendingActions(remappedActions), + }; + }), updateLog: (id, patch) => set((state) => ({ logs: state.logs.map((item) => (item.id === id ? { ...item, ...patch } : item)), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index baa0201..bd54a82 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -1,15 +1,21 @@ import { create } from 'zustand'; -import { Track } from '@/types/domain'; +import { LibraryPlaylistSummary, Track } from '@/types/domain'; type PlayerState = { currentTrack?: Track; + playlist?: LibraryPlaylistSummary; playlistId?: string; queue: Track[]; playNext: () => void; playPrevious: () => void; clearTrack: () => void; - setTrack: (track: Track, playlistId?: string, queue?: Track[]) => void; + setTrack: ( + track: Track, + playlistId?: string, + queue?: Track[], + playlist?: LibraryPlaylistSummary, + ) => void; }; export const usePlayerStore = create((set) => ({ @@ -44,12 +50,14 @@ export const usePlayerStore = create((set) => ({ clearTrack: () => set({ currentTrack: undefined, + playlist: undefined, playlistId: undefined, queue: [], }), - setTrack: (track, playlistId, queue) => + setTrack: (track, playlistId, queue, playlist) => set({ currentTrack: track, + playlist, playlistId, queue: queue?.length ? queue : [track], }), diff --git a/src/store/recommendationCacheStore.ts b/src/store/recommendationCacheStore.ts new file mode 100644 index 0000000..53b929f --- /dev/null +++ b/src/store/recommendationCacheStore.ts @@ -0,0 +1,161 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import type { + FeaturedPlaylist, + GeoPoint, + MoodRecommendation, + MusicRecommendationMode, + PlaceContext, +} from '@/types/domain'; + +type FeaturedPlaylistCacheParams = { + location?: GeoPoint; + locationRecommendationEnabled?: boolean; + recommendationMode?: MusicRecommendationMode; + place?: PlaceContext; +}; + +type MoodRecommendationCacheParams = { + currentPlace?: PlaceContext; + moodFilter?: string; + recommendationMode?: MusicRecommendationMode; + preferredGenres?: string[]; + preferredMoods?: string[]; + topFilter?: string; + travelStyles?: string[]; +}; + +type RecommendationCacheEntry = { + cachedAt: string; + data: T[]; +}; + +type RecommendationFallbackState = { + cachedAt: string; + key: string; + servedAt: string; +}; + +type RecommendationCacheState = { + featuredFallback?: RecommendationFallbackState; + featuredPlaylists: Record>; + moodFallback?: RecommendationFallbackState; + moodRecommendations: Record>; + clearFeaturedFallback: (key: string) => void; + clearMoodFallback: (key: string) => void; + getFeaturedPlaylists: (key: string) => RecommendationCacheEntry | undefined; + getMoodRecommendations: (key: string) => RecommendationCacheEntry | undefined; + markFeaturedFallback: (key: string, cachedAt: string) => void; + markMoodFallback: (key: string, cachedAt: string) => void; + setFeaturedPlaylists: (key: string, data: FeaturedPlaylist[]) => void; + setMoodRecommendations: (key: string, data: MoodRecommendation[]) => void; +}; + +function normalizeList(values?: string[]) { + return values?.filter(Boolean).slice().sort() ?? []; +} + +function normalizeLocation(location?: GeoPoint) { + if (!location) { + return undefined; + } + + return { + lat: Number(location.lat.toFixed(4)), + lng: Number(location.lng.toFixed(4)), + }; +} + +function stringifyCacheKey(scope: string, value: Record) { + return `${scope}:${JSON.stringify(value)}`; +} + +export function createFeaturedPlaylistsCacheKey(params?: FeaturedPlaylistCacheParams) { + return stringifyCacheKey('featured-playlists', { + location: normalizeLocation(params?.location), + locationRecommendationEnabled: Boolean(params?.locationRecommendationEnabled), + placeId: params?.place?.id, + recommendationMode: params?.recommendationMode ?? 'everyday', + }); +} + +export function createMoodRecommendationsCacheKey(params?: MoodRecommendationCacheParams) { + return stringifyCacheKey('mood-recommendations', { + currentPlaceId: params?.currentPlace?.id, + moodFilter: params?.moodFilter ?? '전체', + preferredGenres: normalizeList(params?.preferredGenres), + preferredMoods: normalizeList(params?.preferredMoods), + recommendationMode: params?.recommendationMode ?? 'everyday', + topFilter: params?.topFilter ?? '전체', + travelStyles: normalizeList(params?.travelStyles), + }); +} + +export const useRecommendationCacheStore = create()( + persist( + (set, get) => ({ + featuredPlaylists: {}, + moodRecommendations: {}, + clearFeaturedFallback: (key) => + set((state) => ({ + featuredFallback: + state.featuredFallback?.key === key ? undefined : state.featuredFallback, + })), + clearMoodFallback: (key) => + set((state) => ({ + moodFallback: state.moodFallback?.key === key ? undefined : state.moodFallback, + })), + getFeaturedPlaylists: (key) => get().featuredPlaylists[key], + getMoodRecommendations: (key) => get().moodRecommendations[key], + markFeaturedFallback: (key, cachedAt) => + set({ + featuredFallback: { + cachedAt, + key, + servedAt: new Date().toISOString(), + }, + }), + markMoodFallback: (key, cachedAt) => + set({ + moodFallback: { + cachedAt, + key, + servedAt: new Date().toISOString(), + }, + }), + setFeaturedPlaylists: (key, data) => + set((state) => ({ + featuredFallback: + state.featuredFallback?.key === key ? undefined : state.featuredFallback, + featuredPlaylists: { + ...state.featuredPlaylists, + [key]: { + cachedAt: new Date().toISOString(), + data, + }, + }, + })), + setMoodRecommendations: (key, data) => + set((state) => ({ + moodFallback: state.moodFallback?.key === key ? undefined : state.moodFallback, + moodRecommendations: { + ...state.moodRecommendations, + [key]: { + cachedAt: new Date().toISOString(), + data, + }, + }, + })), + }), + { + name: 'soundlog-recommendation-cache', + partialize: (state) => ({ + featuredPlaylists: state.featuredPlaylists, + moodRecommendations: state.moodRecommendations, + }), + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts index 92d5ca0..91a412c 100644 --- a/src/store/recommendationEventStore.ts +++ b/src/store/recommendationEventStore.ts @@ -6,11 +6,16 @@ import { MusicRecommendationMode, TravelMode } from '@/types/domain'; export type RecommendationEventType = | 'track_external_open' + | 'external_music_open_failed' + | 'track_selected' | 'track_like' | 'track_unlike' | 'track_save' | 'track_unsave' + | 'moment_log_saved' + | 'moment_log_sync_failed' | 'playlist_open' + | 'mood_adjusted' | 'mood_filter_change' | 'live_track_shared' | 'nearby_sound_opened' @@ -24,6 +29,7 @@ export type RecommendationEventContext = { placeCategory?: string; placeId?: string; placeName?: string; + source?: string; topFilter?: string; travelMode?: TravelMode; }; diff --git a/src/store/travelRoomStore.ts b/src/store/travelRoomStore.ts new file mode 100644 index 0000000..ad7bb78 --- /dev/null +++ b/src/store/travelRoomStore.ts @@ -0,0 +1,63 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import type { TravelRoom } from '@/types/domain'; + +type TravelRoomState = { + roomsById: Record; + roomsBySessionId: Record; + clearRoom: (sessionId: string) => void; + setRoomById: (room: TravelRoom) => void; + setRoom: (sessionId: string, room: TravelRoom) => void; +}; + +export const useTravelRoomStore = create()( + persist( + (set) => ({ + roomsById: {}, + roomsBySessionId: {}, + clearRoom: (sessionId) => + set((state) => { + const roomsBySessionId = { ...state.roomsBySessionId }; + const roomId = roomsBySessionId[sessionId]?.id; + const roomsById = { ...state.roomsById }; + + delete roomsBySessionId[sessionId]; + if (roomId) { + delete roomsById[roomId]; + } + + return { roomsById, roomsBySessionId }; + }), + setRoomById: (room) => + set((state) => ({ + roomsById: { + ...state.roomsById, + [room.id]: room, + }, + roomsBySessionId: room.sessionId + ? { + ...state.roomsBySessionId, + [room.sessionId]: room, + } + : state.roomsBySessionId, + })), + setRoom: (sessionId, room) => + set((state) => ({ + roomsById: { + ...state.roomsById, + [room.id]: room, + }, + roomsBySessionId: { + ...state.roomsBySessionId, + [sessionId]: room, + }, + })), + }), + { + name: 'soundlog-travel-rooms', + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); diff --git a/src/types/auth.ts b/src/types/auth.ts index e405344..798b6dc 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -5,7 +5,6 @@ export type AuthProvider = 'email'; export type AuthStatus = | 'authenticated' | 'checking' - | 'guest' | 'unauthenticated'; export type AuthUser = { diff --git a/src/types/domain.ts b/src/types/domain.ts index cf17320..26fe1c6 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -22,9 +22,25 @@ export type MusicRecommendationMode = 'everyday' | 'travel'; export type MoodTag = 'calm' | 'fresh' | 'emotional' | 'active' | 'local'; -export type MusicPlatformId = 'none' | 'youtubeMusic'; +export type MusicPlatformId = 'none' | 'spotify' | 'youtubeMusic' | 'youtube'; -export type ExternalMusicPlatformId = 'melon' | 'youtubeMusic'; +export type ExternalMusicPlatformId = 'melon' | 'spotify' | 'youtube' | 'youtubeMusic'; + +export type PlaylistRecommendationSource = + | 'ml-recommendation' + | 'seed-fallback' + | 'server-contextual'; + +export type PlaylistRecommendationContext = { + mood?: string; + moodTags?: MoodTag[]; + placeId?: string; + source?: PlaylistRecommendationSource | string; + state?: string; + travelMode?: TravelMode; + x?: number; + y?: number; +}; export type Track = { id: string; @@ -64,6 +80,7 @@ export type PlaylistCuration = { placeName?: string; reason: string; accentColor?: string; + context?: PlaylistRecommendationContext; coverImageUrl?: string; backgroundImageUrl?: string; trackCount: number; @@ -71,6 +88,18 @@ export type PlaylistCuration = { tracks: Track[]; }; +export type LibraryPlaylistSummary = { + id: string; + regionName: string; + placeName?: string; + description?: string; + reason?: string; + coverImageUrl?: string; + backgroundImageUrl?: string; + trackCount: number; + durationText: string; +}; + export type MusicLogItem = { id: string; placeName: string; @@ -83,13 +112,14 @@ export type MusicLogItem = { export type MomentLog = { id: string; - photoUri: string; + photoUri?: string; createdAt: string; sessionId?: string; location?: GeoPoint; placeCategory?: string; placeId?: string; placeName?: string; + note?: string; track?: Track; travelMode?: TravelMode; moodTags: MoodTag[]; @@ -107,7 +137,7 @@ export type RecapItem = { sessionId?: string; }; -export type RecapTemplateId = 'album' | 'film' | 'lp'; +export type RecapTemplateId = 'album' | 'film' | 'lp' | 'map'; export type RecapShareMoment = { id: string; diff --git a/src/utils/externalMusicLinks.ts b/src/utils/externalMusicLinks.ts new file mode 100644 index 0000000..4e2ca27 --- /dev/null +++ b/src/utils/externalMusicLinks.ts @@ -0,0 +1,100 @@ +import { Linking, Platform } from 'react-native'; + +import type { ExternalMusicPlatformId, Track } from '@/types/domain'; + +export type ExternalMusicLink = { + fallbackUrl?: string; + id: ExternalMusicPlatformId | 'provided' | 'search'; + label: string; + url: string; +}; + +const platformLabels: Partial> = { + melon: 'Melon에서 열기', + spotify: 'Spotify에서 열기', + youtube: 'YouTube 검색', + youtubeMusic: 'YouTube Music', +}; + +function createTrackSearchQuery(track: Track) { + return encodeURIComponent(`${track.title} ${track.artist}`.trim()); +} + +function createGeneratedPlatformUrls( + searchQuery: string, +): Partial> { + return { + spotify: `https://open.spotify.com/search/${searchQuery}`, + youtube: `https://www.youtube.com/results?search_query=${searchQuery}`, + youtubeMusic: `https://music.youtube.com/search?q=${searchQuery}`, + }; +} + +function appendUniqueLink(links: ExternalMusicLink[], link: ExternalMusicLink) { + if (links.some((item) => item.url === link.url || item.id === link.id)) { + return; + } + + links.push(link); +} + +export function getExternalMusicLinks(track: Track): ExternalMusicLink[] { + const searchQuery = createTrackSearchQuery(track); + const generatedUrls = createGeneratedPlatformUrls(searchQuery); + const links: ExternalMusicLink[] = []; + const orderedPlatforms: ExternalMusicPlatformId[] = [ + 'spotify', + 'youtubeMusic', + 'youtube', + 'melon', + ]; + + orderedPlatforms.forEach((platform) => { + const url = track.platformUrls?.[platform] ?? generatedUrls[platform]; + + if (!url) { + return; + } + + appendUniqueLink(links, { + fallbackUrl: generatedUrls[platform], + id: platform, + label: platformLabels[platform] ?? '외부 앱에서 열기', + url, + }); + }); + + if (track.externalUrl) { + appendUniqueLink(links, { + fallbackUrl: generatedUrls.youtube, + id: 'provided', + label: '제공 링크 열기', + url: track.externalUrl, + }); + } + + appendUniqueLink(links, { + id: 'search', + label: '웹에서 곡 검색', + url: `https://www.google.com/search?q=${searchQuery}`, + }); + + return links; +} + +export async function openExternalMusicLink(link: ExternalMusicLink) { + if (Platform.OS === 'web' && typeof window !== 'undefined') { + window.open(link.url, '_blank', 'noopener,noreferrer'); + return; + } + + try { + await Linking.openURL(link.url); + } catch (error) { + if (!link.fallbackUrl || link.fallbackUrl === link.url) { + throw error; + } + + await Linking.openURL(link.fallbackUrl); + } +} diff --git a/src/utils/libraryPlaylistSummary.ts b/src/utils/libraryPlaylistSummary.ts new file mode 100644 index 0000000..643b230 --- /dev/null +++ b/src/utils/libraryPlaylistSummary.ts @@ -0,0 +1,16 @@ +import type { LibraryPlaylistSummary, PlaylistCuration } from '@/types/domain'; + +export function toLibraryPlaylistSummary( + playlist: PlaylistCuration, +): LibraryPlaylistSummary { + return { + id: playlist.id, + regionName: playlist.regionName, + placeName: playlist.placeName, + reason: playlist.reason, + coverImageUrl: playlist.coverImageUrl, + backgroundImageUrl: playlist.backgroundImageUrl, + trackCount: playlist.trackCount, + durationText: playlist.durationText, + }; +} diff --git a/src/utils/localDataMigration.ts b/src/utils/localDataMigration.ts new file mode 100644 index 0000000..22cb9dd --- /dev/null +++ b/src/utils/localDataMigration.ts @@ -0,0 +1,182 @@ +import { authApi } from '@/api/authApi'; +import { createIdempotencyKey } from '@/api/client'; +import { libraryApi } from '@/api/libraryApi'; +import { momentLogApi } from '@/api/momentLogApi'; +import { meApi } from '@/api/meApi'; +import { useLibraryStore } from '@/store/libraryStore'; +import { + useMomentLogStore, + type MomentLogCreateQueuePayload, + type MomentLogPendingAction, +} from '@/store/momentLogStore'; +import { useUserProfileStore } from '@/store/userProfileStore'; +import type { MomentLog } from '@/types/domain'; + +export type LocalDataMigrationSummary = { + libraryTrackCount: number; + momentLogCount: number; + recapDraftCount: number; +}; + +export type LocalDataMigrationSyncResult = { + libraryFailedCount: number; + librarySyncedCount: number; + migrationAccepted: boolean; + momentLogFailedCount: number; + momentLogSyncedCount: number; + summary: LocalDataMigrationSummary; +}; + +function momentLogCreatePayloadFromLog(log: MomentLog): MomentLogCreateQueuePayload { + return { + createdAt: log.createdAt, + location: log.location, + moodTags: log.moodTags, + note: log.note, + photoUri: log.photoUri, + placeCategory: log.placeCategory, + placeId: log.placeId, + placeName: log.placeName, + sessionId: log.sessionId, + track: log.track, + travelMode: log.travelMode, + }; +} + +function isCreatePendingAction( + action: MomentLogPendingAction, +): action is Extract { + return action.type === 'create'; +} + +export function getLocalDataMigrationSummary(): LocalDataMigrationSummary { + const { likedTracks, savedTracks } = useLibraryStore.getState(); + const { logs } = useMomentLogStore.getState(); + + return { + libraryTrackCount: likedTracks.length + savedTracks.length, + momentLogCount: logs.length, + recapDraftCount: logs.length > 0 ? 1 : 0, + }; +} + +async function syncCompletedLocalProfile() { + const { profile } = useUserProfileStore.getState(); + + if (!profile.completedOnboarding) { + return; + } + + await meApi.updateProfile(profile); +} + +async function syncLocalMomentLogs() { + const { + logs, + pendingActions, + queueCreate, + resolveLocalLog, + updateLog, + } = useMomentLogStore.getState(); + let syncedCount = 0; + let failedCount = 0; + + for (const log of logs) { + if (log.syncStatus === 'synced') { + continue; + } + + const queuedCreateAction = pendingActions + .filter(isCreatePendingAction) + .find((action) => action.momentLogId === log.id); + const payload = queuedCreateAction?.payload ?? momentLogCreatePayloadFromLog(log); + + queueCreate(log.id, payload); + updateLog(log.id, { syncStatus: 'pending' }); + + try { + const serverLog = await momentLogApi.createMomentLog({ + ...payload, + idempotencyKey: log.id, + }); + + if (!serverLog) { + updateLog(log.id, { syncStatus: 'local' }); + failedCount += 1; + continue; + } + + resolveLocalLog(log.id, serverLog); + syncedCount += 1; + } catch { + queueCreate(log.id, payload); + updateLog(log.id, { syncStatus: 'failed' }); + failedCount += 1; + } + } + + return { failedCount, syncedCount }; +} + +async function syncLocalLibrary() { + const { likedTracks, savedTracks } = useLibraryStore.getState(); + let syncedCount = 0; + let failedCount = 0; + + const records = [ + ...likedTracks.map((record) => ({ action: 'like' as const, record })), + ...savedTracks.map((record) => ({ action: 'save' as const, record })), + ]; + + for (const { action, record } of records) { + try { + const result = await libraryApi.updateTrackState(record.track.id, { + action, + playlistId: record.playlistId, + }); + + if (!result) { + failedCount += 1; + continue; + } + + syncedCount += 1; + } catch { + failedCount += 1; + } + } + + return { failedCount, syncedCount }; +} + +export async function migrateLocalDataToAccount(): Promise { + const summary = getLocalDataMigrationSummary(); + let migrationAccepted = false; + + try { + const migration = await authApi.migrateLocalData({ + ...summary, + idempotencyKey: createIdempotencyKey('migration'), + }); + + migrationAccepted = migration.accepted; + } catch { + migrationAccepted = false; + } + + await syncCompletedLocalProfile().catch(() => undefined); + + const [momentLogResult, libraryResult] = await Promise.all([ + syncLocalMomentLogs(), + syncLocalLibrary(), + ]); + + return { + libraryFailedCount: libraryResult.failedCount, + librarySyncedCount: libraryResult.syncedCount, + migrationAccepted, + momentLogFailedCount: momentLogResult.failedCount, + momentLogSyncedCount: momentLogResult.syncedCount, + summary, + }; +} diff --git a/src/utils/momentPhotoPicker.ts b/src/utils/momentPhotoPicker.ts new file mode 100644 index 0000000..789434b --- /dev/null +++ b/src/utils/momentPhotoPicker.ts @@ -0,0 +1,44 @@ +import { persistMomentPhoto } from '@/utils/momentFiles'; + +export type PickMomentPhotoResult = + | { + status: 'selected'; + uri: string; + } + | { + status: 'cancelled' | 'permission-denied' | 'unavailable'; + }; + +export async function pickMomentReplacementPhoto(momentLogId: string): Promise { + try { + const ImagePicker = await import('expo-image-picker'); + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(false); + + if (!permission.granted) { + return { status: 'permission-denied' }; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: false, + allowsMultipleSelection: false, + mediaTypes: ['images'], + quality: 0.92, + }); + + if (result.canceled || !result.assets[0]?.uri) { + return { status: 'cancelled' }; + } + + const persistedUri = await persistMomentPhoto( + result.assets[0].uri, + `${momentLogId}-replacement-${Date.now()}`, + ); + + return { + status: 'selected', + uri: persistedUri, + }; + } catch { + return { status: 'unavailable' }; + } +} diff --git a/src/utils/trackSanitizer.ts b/src/utils/trackSanitizer.ts index 8997efc..8d1a0f6 100644 --- a/src/utils/trackSanitizer.ts +++ b/src/utils/trackSanitizer.ts @@ -1,12 +1,27 @@ import { MoodRecommendation, PlaylistCuration, RecapItem, Track } from '@/types/domain'; -export function sanitizeTrack(track: Track): Track { - const nextTrack = { ...track }; +function sanitizeUrl(value?: string) { + const trimmed = value?.trim(); + + return trimmed || undefined; +} + +function sanitizePlatformUrls(platformUrls?: Track['platformUrls']) { + if (!platformUrls) { + return undefined; + } - delete nextTrack.externalUrl; - delete nextTrack.platformUrls; + return Object.fromEntries( + Object.entries(platformUrls).filter(([, url]) => Boolean(sanitizeUrl(url))), + ) as Track['platformUrls']; +} - return nextTrack; +export function sanitizeTrack(track: Track): Track { + return { + ...track, + externalUrl: sanitizeUrl(track.externalUrl), + platformUrls: sanitizePlatformUrls(track.platformUrls), + }; } export function sanitizeMoodRecommendation(item: MoodRecommendation): MoodRecommendation { diff --git a/src/utils/travelRoomInvite.ts b/src/utils/travelRoomInvite.ts new file mode 100644 index 0000000..dc6c6eb --- /dev/null +++ b/src/utils/travelRoomInvite.ts @@ -0,0 +1,27 @@ +import { Platform, Share } from 'react-native'; + +import type { TravelRoom } from '@/types/domain'; + +export type TravelRoomInviteShareResult = 'copied' | 'shared'; + +export function createTravelRoomInviteMessage(room: TravelRoom) { + return [ + `${room.title}에 초대했어요.`, + `Soundlog 공동 Recap 초대 코드: ${room.inviteCode}`, + '앱의 공동 Recap 카드에서 초대 코드로 참여할 수 있어요.', + ].join('\n'); +} + +export async function shareTravelRoomInvite( + room: TravelRoom, +): Promise { + const message = createTravelRoomInviteMessage(room); + + if (Platform.OS === 'web' && typeof navigator !== 'undefined' && navigator.clipboard) { + await navigator.clipboard.writeText(message); + return 'copied'; + } + + await Share.share({ message }); + return 'shared'; +}