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
25 changes: 19 additions & 6 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Feather } from '@expo/vector-icons';
import { Tabs, router } from 'expo-router';
import { Alert, Pressable, View } from 'react-native';
import { Alert, Platform, Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { colors } from '@/constants/colors';
Expand All @@ -9,6 +9,11 @@ import { useTravelSessionStore } from '@/store/travelSessionStore';

type TabIconName = keyof typeof Feather.glyphMap;

const webGlassTabBarStyle = {
backdropFilter: 'blur(22px) saturate(150%)',
WebkitBackdropFilter: 'blur(22px) saturate(150%)',
};

function TabIcon({ name, color }: { name: TabIconName; color: string }) {
return <Feather color={color} name={name} size={22} />;
}
Expand Down Expand Up @@ -38,10 +43,12 @@ function CameraTabButton() {
<Pressable
accessibilityLabel="순간 저장 카메라 열기"
accessibilityRole="button"
className="-mt-6 h-[68px] w-[68px] items-center justify-center rounded-full border-4 border-white/40 bg-[#f4f4f4]"
className="-mt-6 h-[70px] w-[70px] items-center justify-center rounded-full border border-white/15 bg-soundlog-bg"
onPress={handlePress}
>
<View className="h-[58px] w-[58px] rounded-full bg-white" />
<View className="h-[58px] w-[58px] items-center justify-center rounded-full border-[3px] border-soundlog-lime bg-black/25">
<View className="h-[44px] w-[44px] rounded-full bg-soundlog-lime" />
</View>
</Pressable>
);
}
Expand All @@ -53,19 +60,25 @@ export default function TabsLayout() {
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#ffffff',
tabBarInactiveTintColor: '#ffffff',
tabBarActiveTintColor: colors.accent.lime,
tabBarInactiveTintColor: colors.text.muted,
tabBarShowLabel: false,
tabBarStyle: {
position: 'absolute',
height: getTabBarHeight(insets.bottom),
paddingBottom: Math.max(insets.bottom, 12),
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: 'rgba(255,255,255,0.08)',
borderTopColor: 'rgba(255,255,255,0.24)',
backgroundColor: colors.surface.tab,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
boxShadow: '0 -18px 42px rgba(0,0,0,0.34)',
shadowColor: '#000',
shadowOffset: { height: -14, width: 0 },
shadowOpacity: 0.34,
shadowRadius: 24,
...(Platform.OS === 'web' ? webGlassTabBarStyle : {}),
},
}}
>
Expand Down
89 changes: 66 additions & 23 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { router } from 'expo-router';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { ScrollView } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

Expand All @@ -11,13 +11,17 @@ import {
import { useNearbyPlacesQuery } from '@/api/tourQueries';
import { MiniPlayer } from '@/components/MiniPlayer';
import { FeaturedPlaylistSection } from '@/components/home/FeaturedPlaylistSection';
import { HomeHeader, isHomeTopFilter } from '@/components/home/HomeHeader';
import { LocationContextCard } from '@/components/home/LocationContextCard';
import {
HomeHeader,
HomeTopFilterBar,
isHomeTopFilter,
} from '@/components/home/HomeHeader';
import {
MoodRecommendationSection,
isMoodRecommendationFilter,
} from '@/components/home/MoodRecommendationSection';
import { MusicLogSection } from '@/components/home/MusicLogSection';
import { TravelModeSuggestionSheet } from '@/components/home/TravelModeSuggestionSheet';
import { TravelSessionCard } from '@/components/home/TravelSessionCard';
import { Screen } from '@/components/Screen';
import { getHomeContentBottomPadding } from '@/constants/layout';
Expand All @@ -36,6 +40,8 @@ import { createRecommendationEventContext } from '@/utils/recommendationEventCon

function HomeContent() {
const insets = useSafeAreaInsets();
const [dismissedSuggestionPlaceId, setDismissedSuggestionPlaceId] =
useState<string>();
const {
selectedMoodFilter,
selectedTopFilter,
Expand All @@ -53,13 +59,15 @@ function HomeContent() {
currentPlace,
locationStatus,
locationUpdatedAt,
recommendationMode,
selectedMode,
session,
endSession,
setLocation,
setLocationStatus,
setMode,
setPlace,
setRecommendationMode,
startSession,
} = useTravelSessionStore();

Expand All @@ -72,11 +80,14 @@ function HomeContent() {
location: currentLocation,
locationRecommendationEnabled: profile.locationRecommendationEnabled,
place: currentPlace,
recommendationMode,
});
const moodRecommendationsQuery = useMoodRecommendationsQuery({
currentPlace,
moodFilter: selectedMoodFilter,
preferredGenres: profile.preferredGenres,
preferredMoods: profile.preferredMoods,
recommendationMode,
topFilter: selectedTopFilter,
travelStyles: profile.travelStyles,
});
Expand Down Expand Up @@ -152,6 +163,21 @@ function HomeContent() {
},
[addRecommendationEvent, selectedMoodFilter, setSelectedMoodFilter],
);
const handleSelectRecommendationMode = useCallback(
(mode: typeof recommendationMode) => {
if (mode === recommendationMode) {
return;
}

setRecommendationMode(mode);
addRecommendationEvent({
context: createRecommendationEventContext({ recommendationMode: mode }),
type: 'recommendation_mode_change',
value: mode,
});
},
[addRecommendationEvent, recommendationMode, setRecommendationMode],
);
const handleEnableLocationRecommendation = () => {
updateProfile({
companionType: profile.companionType,
Expand Down Expand Up @@ -181,6 +207,23 @@ function HomeContent() {
setLocationStatus('unavailable');
}
}, [locationStatus, setLocation, setLocationStatus]);
const handleSetCurrentLocation = useCallback(() => {
if (!profile.locationRecommendationEnabled) {
handleEnableLocationRecommendation();
}

void handleRefreshLocation();
}, [
handleEnableLocationRecommendation,
handleRefreshLocation,
profile.locationRecommendationEnabled,
]);

const shouldShowTravelModeSuggestion =
Boolean(currentPlace) &&
profile.locationRecommendationEnabled &&
recommendationMode === 'everyday' &&
dismissedSuggestionPlaceId !== currentPlace?.id;

return (
<Screen>
Expand All @@ -198,26 +241,11 @@ function HomeContent() {
showsVerticalScrollIndicator={false}
>
<HomeHeader
onSelectTopFilter={handleSelectTopFilter}
selectedTopFilter={selectedTopFilter}
/>

<LocationContextCard
enabled={profile.locationRecommendationEnabled}
isLoading={locationStatus === 'loading'}
isPlaceLoading={nearbyPlacesQuery.isLoading}
location={currentLocation}
onEnable={handleEnableLocationRecommendation}
onRefresh={handleRefreshLocation}
place={currentPlace}
placeCount={nearbyPlacesQuery.data?.length ?? 0}
placeInfoMessage={
currentPlace?.source === 'mock'
? 'TourAPI 키가 없거나 실패해 임시 장소 데이터를 사용 중이에요.'
: undefined
}
status={locationStatus}
updatedAt={locationUpdatedAt}
currentPlace={currentPlace}
isLocationLoading={locationStatus === 'loading'}
onSelectRecommendationMode={handleSelectRecommendationMode}
onSetCurrentLocation={handleSetCurrentLocation}
recommendationMode={recommendationMode}
/>

<TravelSessionCard
Expand All @@ -231,6 +259,11 @@ function HomeContent() {
status={session.status}
/>

<HomeTopFilterBar
onSelectTopFilter={handleSelectTopFilter}
selectedTopFilter={selectedTopFilter}
/>

<FeaturedPlaylistSection
data={featuredPlaylistsQuery.data}
isError={featuredPlaylistsQuery.isError}
Expand All @@ -255,6 +288,16 @@ function HomeContent() {
/>
</ScrollView>
{currentTrack ? <MiniPlayer /> : null}
{shouldShowTravelModeSuggestion && currentPlace ? (
<TravelModeSuggestionSheet
onDismiss={() => setDismissedSuggestionPlaceId(currentPlace.id)}
onStartTravelMode={() => {
handleSelectRecommendationMode('travel');
setDismissedSuggestionPlaceId(currentPlace.id);
}}
place={currentPlace}
/>
) : null}
</Screen>
);
}
Expand Down
84 changes: 83 additions & 1 deletion app/(tabs)/my.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { Feather } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useCallback, useEffect } from 'react';
import { Pressable, ScrollView, View } from 'react-native';

import { useNearbyPlacesQuery } from '@/api/tourQueries';
import { AppText } from '@/components/AppText';
import { LocationContextCard } from '@/components/home/LocationContextCard';
import { MusicPlatformSettingsCard } from '@/components/my/MusicPlatformSettingsCard';
import { PermissionSettingsCard } from '@/components/my/PermissionSettingsCard';
import { Screen } from '@/components/Screen';
import { useNativePermissionSettings } from '@/hooks/useNativePermissionSettings';
import { useMusicPlatformStore } from '@/store/musicPlatformStore';
import { useRecommendationEventStore } from '@/store/recommendationEventStore';
import { useTravelSessionStore } from '@/store/travelSessionStore';
import { useUserProfileStore } from '@/store/userProfileStore';
import { requestForegroundLocationWithStatus } from '@/utils/location';

type MyMenuItem = {
description?: string;
Expand All @@ -33,16 +38,73 @@ function formatEventTime(value?: string) {
}

export default function MyScreen() {
const { profile, resetOnboarding } = useUserProfileStore();
const { profile, resetOnboarding, updateProfile } = useUserProfileStore();
const { clearEvents, events, isHydrated } = useRecommendationEventStore();
const permissionSettings = useNativePermissionSettings();
const { selectedPlatformId, setSelectedPlatform } = useMusicPlatformStore();
const {
currentLocation,
currentPlace,
locationStatus,
locationUpdatedAt,
setLocation,
setLocationStatus,
setPlace,
} = useTravelSessionStore();
const nearbyPlacesQuery = useNearbyPlacesQuery({
enabled: profile.locationRecommendationEnabled,
location: currentLocation,
radiusMeters: 2000,
});
const selectedSummary = [
...profile.preferredGenres.slice(0, 2),
...profile.preferredMoods.slice(0, 1),
...profile.travelStyles.slice(0, 1),
].join(' · ');

useEffect(() => {
if (!nearbyPlacesQuery.data) {
return;
}

const nextPlace = nearbyPlacesQuery.data[0];

if (nextPlace?.id !== currentPlace?.id) {
setPlace(nextPlace);
}
}, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]);

const handleEnableLocationRecommendation = useCallback(() => {
updateProfile({
companionType: profile.companionType,
locationRecommendationEnabled: true,
preferredGenres: profile.preferredGenres,
preferredMoods: profile.preferredMoods,
travelStyles: profile.travelStyles,
});
}, [profile, updateProfile]);

const handleRefreshLocation = useCallback(async () => {
if (locationStatus === 'loading') {
return;
}

setLocationStatus('loading');

try {
const result = await requestForegroundLocationWithStatus();

if (result.location) {
setLocation(result.location);
return;
}

setLocationStatus(result.status === 'denied' ? 'denied' : 'unavailable');
} catch {
setLocationStatus('unavailable');
}
}, [locationStatus, setLocation, setLocationStatus]);

const menuItems: MyMenuItem[] = [
{
description: selectedSummary || '아직 저장된 취향 정보가 없어요.',
Expand Down Expand Up @@ -100,6 +162,26 @@ export default function MyScreen() {
onRequest={permissionSettings.requestPermission}
/>

<View className="mt-6">
<LocationContextCard
enabled={profile.locationRecommendationEnabled}
isLoading={locationStatus === 'loading'}
isPlaceLoading={nearbyPlacesQuery.isLoading}
location={currentLocation}
onEnable={handleEnableLocationRecommendation}
onRefresh={handleRefreshLocation}
place={currentPlace}
placeCount={nearbyPlacesQuery.data?.length ?? 0}
placeInfoMessage={
currentPlace?.source === 'mock'
? 'TourAPI 키가 없거나 실패해 임시 장소 데이터를 사용 중이에요.'
: undefined
}
status={locationStatus}
updatedAt={locationUpdatedAt}
/>
</View>

<MusicPlatformSettingsCard
onSelectPlatform={setSelectedPlatform}
selectedPlatformId={selectedPlatformId}
Expand Down
33 changes: 33 additions & 0 deletions docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Soundlog Design System

## Palette

Soundlog keeps the original dark navy and muted violet UI, with one new highlight color:

| Token | Hex | Role |
| --- | --- | --- |
| `background.primary` | `#070B1F` | App background |
| `surface.card` | `#080D18` | Standard cards |
| `surface.chip` | `#0E1E3A` | Default chips and compact controls |
| `surface.chipSelected` | `#243A75` | Selected chips |
| `accent.purple` | `#7A2CFF` | Existing Soundlog purple accent |
| `accent.lime` | `#B7E628` | High-priority action and active focus only |

## Color Rules

- Use dark navy surfaces as the default visual language.
- Use fluorescent lime sparingly: camera action, active tab, selected/focused border, and Travel mode.
- Do not use the full reference palette across chips or cards.
- Keep chips compact and calm: default navy, selected muted blue, lime only as the selection border.
- Avoid adding new bright hex values inside components unless the color comes from content data.

## Tailwind Tokens

- `bg-soundlog-bg`: app background
- `bg-soundlog-card`: standard cards
- `bg-soundlog-elevated`: emphasized dark panels
- `bg-soundlog-chip`: unselected chips
- `bg-soundlog-selected`: selected chips
- `bg-soundlog-lime`: high-priority action only
- `border-soundlog-border`: standard chip border
- `border-soundlog-focus`: active/focused lime border
Loading
Loading