Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ API origin이 최신 서버인지 확인하려면 아래처럼 실행합니다.
SOUNDLOG_API_ORIGIN=http://<EC2_HOST>:4000 npm run check:api-origin
```

이 검사는 로그인 필수 API도 함께 확인하므로 `SOUNDLOG_CHECK_EMAIL`,
`SOUNDLOG_CHECK_PASSWORD`를 지정하면 해당 smoke 계정으로 로그인합니다. 값을
지정하지 않으면 `@soundlog.test` 임시 계정을 생성해 검증합니다.

Android 지인 테스트용 내부 배포 빌드는 아래 명령으로 생성합니다.

```bash
Expand Down
1 change: 1 addition & 0 deletions app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const baseConfig = {
],
'expo-secure-store',
'expo-image',
['expo-build-properties', {}],
],
experiments: {
typedRoutes: true,
Expand Down
171 changes: 142 additions & 29 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
Expand All @@ -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';
Expand All @@ -65,6 +72,21 @@ const travelModeToMlState: Partial<Record<TravelMode, PlaylistMlState>> = {
walk: '산책',
};

const travelModeDisplayLabel: Record<TravelMode, string> = {
cafe: '카페',
drive: '드라이브',
festival: '축제',
night: '야경',
ocean: '바다',
walk: '산책',
};

const travelStyleDisplayLabel: Record<string, string> = {
'바다 보기': '바다',
'야경 감상': '야경',
'카페 투어': '카페',
};

function resolvePlaylistMood(filter: string, preferredMoods: string[]): PlaylistMlMood {
if (filter !== '전체' && moodFilterToMlMood[filter]) {
return moodFilterToMlMood[filter];
Expand All @@ -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<string>();
const [creatingPlaylistId, setCreatingPlaylistId] = useState<string>();
const {
Expand Down Expand Up @@ -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 ?? []),
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -222,6 +307,7 @@ function HomeContent() {
addRecommendationEvent({
context: createRecommendationEventContext({
moodFilter: selectedMoodFilter,
source: contextualPlaylist.context?.source,
}),
playlistId: nextPlaylistId,
type: 'playlist_open',
Expand Down Expand Up @@ -392,6 +478,20 @@ function HomeContent() {
updatedAt={locationUpdatedAt}
/>

<CurrentSoundtrackCard
cachedAt={isUsingCachedFeaturedPlaylists ? featuredFallback?.cachedAt : undefined}
currentPlace={currentPlace}
isCached={isUsingCachedFeaturedPlaylists}
isError={featuredPlaylistsQuery.isError}
isLoading={featuredPlaylistsQuery.isLoading}
moodLabel={resolveCurrentMoodLabel(selectedMoodFilter, profile.preferredMoods)}
onCaptureMoment={() => router.push('/camera' as never)}
onOpenPlaylist={handleSelectFeaturedPlaylist}
onRetry={() => void featuredPlaylistsQuery.refetch()}
playlist={featuredPlaylistsQuery.data?.[0]}
travelLabel={resolveCurrentTravelLabel(selectedMode, profile.travelStyles)}
/>

{recommendationMode === 'travel' ? (
<View className="-mt-2">
<TravelSessionCard
Expand All @@ -415,7 +515,9 @@ function HomeContent() {
</View>

<FeaturedPlaylistSection
cachedAt={isUsingCachedFeaturedPlaylists ? featuredFallback?.cachedAt : undefined}
data={featuredPlaylistsQuery.data}
isCached={isUsingCachedFeaturedPlaylists}
isError={featuredPlaylistsQuery.isError}
isLoading={featuredPlaylistsQuery.isLoading}
onSelectPlaylist={handleSelectFeaturedPlaylist}
Expand All @@ -424,7 +526,9 @@ function HomeContent() {

<View className="mt-2">
<MoodRecommendationSection
cachedAt={isUsingCachedMoodRecommendations ? moodFallback?.cachedAt : undefined}
data={moodRecommendationsQuery.data}
isCached={isUsingCachedMoodRecommendations}
isError={moodRecommendationsQuery.isError}
isLoading={moodRecommendationsQuery.isLoading}
onSelectMoodFilter={handleSelectMoodFilter}
Expand Down Expand Up @@ -454,6 +558,7 @@ function HomeContent() {
}

export default function HomeScreen() {
const { isHydrated: authHydrated, status } = useAuthStore();
const { isHydrated, profile } = useUserProfileStore();

useEffect(() => {
Expand All @@ -462,6 +567,14 @@ export default function HomeScreen() {
}
}, [isHydrated, profile.completedOnboarding]);

if (!authHydrated || status === 'checking') {
return <Screen />;
}

if (status !== 'authenticated') {
return <Redirect href={profile.completedOnboarding ? '/auth/login' : '/onboarding'} />;
}

if (!isHydrated || !profile.completedOnboarding) {
return isHydrated ? <Redirect href="/onboarding" /> : <Screen />;
}
Expand Down
10 changes: 4 additions & 6 deletions app/(tabs)/my.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,17 @@ export default function MyScreen() {
{
description: selectedSummary || '아직 저장된 취향 정보가 없어요.',
icon: 'sliders',
label: '취향 정보 수정',
label: '취향 수정',
onPress: () =>
router.push({
pathname: '/onboarding',
params: { mode: 'edit' },
} as never),
},
{
description: profile.locationRecommendationEnabled
? '위치 기반 추천 사용 중'
: '위치 추천 꺼짐',
description: '위치 · 카메라 · 사진',
icon: 'map-pin',
label: '위치/카메라 권한',
label: '권한 설정',
},
{
description: '데이터 수집과 보관, 삭제 요청 방법을 확인합니다.',
Expand Down Expand Up @@ -161,7 +159,7 @@ export default function MyScreen() {
contentContainerStyle={{ paddingBottom: 132, paddingHorizontal: 20, paddingTop: 32 }}
showsVerticalScrollIndicator={false}
>
<AppText className="text-[26px] font-semibold text-white">My</AppText>
<AppText className="text-[26px] font-semibold text-white">마이</AppText>

<AuthAccountCard />

Expand Down
9 changes: 9 additions & 0 deletions app/(tabs)/travel-room/[roomId].tsx
Original file line number Diff line number Diff line change
@@ -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 <TravelRoomDetailScreen roomId={roomId ?? ''} />;
}
Loading
Loading