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
41 changes: 41 additions & 0 deletions .easignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# dependencies
node_modules/

# Expo
.expo/
dist/
web-build/
expo-env.d.ts

# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
/ios
/android

# Metro
.metro-health-check*

# debug
npm-debug.*
yarn-debug.*
yarn-error.*

# macOS
.DS_Store
*.pem

# local env files
.env*.local

# TypeScript
*.tsbuildinfo

# Release documentation and local QA captures
artifacts/
docs/
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@
- API health checks and command-line diagnostics may support investigation, but they do not replace simulator verification and must not be reported as completed app testing.
- Use a web target only for explicit web build or export compatibility work. Even then, do not use it for product acceptance testing unless the user's latest message explicitly overrides this rule.

## Server Ownership Boundary

- Soundlog 앱 에이전트는 모바일 앱 저장소만 담당한다. `SoundLogServer`, `soundlog-ml`과 운영 인프라는 서버 팀원이 담당한다.
- 앱 빌드나 배포 요청을 서버 배포 권한으로 해석하지 않는다. 앱을 EAS 또는 TestFlight로 배포하더라도 서버는 변경하지 않는다.
- 서버 저장소의 파일을 수정하거나 커밋하거나 브랜치를 푸시하거나 Pull Request를 만들지 않는다.
- 운영 서버에 SSH로 접속하지 않는다. Docker 이미지를 배포하거나 컨테이너를 재시작하거나 배포 워크플로를 실행하지 않는다.
- 서버의 환경변수와 GitHub Actions 시크릿과 SSH 키와 도메인과 DNS와 인증서와 방화벽 설정을 생성하거나 변경하지 않는다.
- 운영 데이터베이스의 migration과 seed와 관리자 API와 사용자 또는 신고 데이터를 변경하지 않는다.
- 앱 연동 검수에 필요한 공개 HTTPS `GET` 요청은 허용한다. 예를 들어 health와 OpenAPI와 법적 문서의 응답 상태를 읽을 수 있다. 이 검사는 서버 배포나 서버 기능 완료의 증거로 사용하지 않는다.
- 앱 변경에 서버 작업이 필요하면 필요한 API 계약과 현재 실패 증거만 정리해 서버 팀원에게 전달한다. 앱 작업 중 임의로 서버 수정이나 우회 배포를 하지 않는다.
- 작업을 시작하기 전에 `pwd`, `git rev-parse --show-toplevel`, `git remote get-url origin`으로 앱 저장소인지 확인한다. 서버 저장소가 나오면 즉시 중단하고 앱 저장소로 이동한다.
- 서버 작업을 함께 해달라는 후속 요청이 오더라도 대상 저장소와 작업 범위를 사용자가 최신 메시지에서 명시하지 않으면 수행하지 않는다.

## Text Color

- 기본 사용자 노출 텍스트와 버튼, 탭, 칩의 인터랙션 라벨은 흰색 또는 흰색 투명도 계열을 사용한다. 상태를 구분하는 의미 색상과 브랜드 강조 색상은 예외로 둔다.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/Test

## 문서

- [앱 빌드와 TestFlight 배포](deploy.md)
- [문서 인덱스](docs/README.md)
- [리캡·로그 도메인 기준](docs/product/RECAP_LOG_DOMAIN_MODEL.md)
- [서비스 기획서](docs/product/SOUNDLOG_APP_PLANNING.md)
Expand Down
126 changes: 111 additions & 15 deletions app/(tabs)/music.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Redirect, router } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

Expand Down Expand Up @@ -27,6 +27,10 @@ import { CurrentSoundtrackCard } from "@/components/home/CurrentSoundtrackCard";
import { HomeSoundtrackBottomSheet } from "@/components/home/HomeSoundtrackBottomSheet";
import { HomeHeader } from "@/components/home/HomeHeader";
import { LocationContextCard } from "@/components/home/LocationContextCard";
import {
RecommendationFeedbackSheet,
type RecommendationFeedbackRating,
} from "@/components/home/RecommendationFeedbackSheet";
import {
MoodRecommendationSection,
isMoodRecommendationFilter,
Expand Down Expand Up @@ -145,16 +149,18 @@ function HomeContent() {
const [actionMessage, setActionMessage] = useState<string>();
const [isSoundtrackSheetVisible, setIsSoundtrackSheetVisible] =
useState(false);
const [feedbackPlaylist, setFeedbackPlaylist] = useState<PlaylistCuration>();
const feedbackPromptTimerRef = useRef<
ReturnType<typeof setTimeout> | undefined
>(undefined);
const promptedFeedbackPlaylistIdsRef = useRef(new Set<string>());
const [selectedMusicPlaylistId, setSelectedMusicPlaylistId] =
useState<string>();
const [
selectedMusicPlaylistEyebrowLabel,
setSelectedMusicPlaylistEyebrowLabel,
] = useState("Music Playlist");
const {
selectedMoodFilter,
setSelectedMoodFilter,
} = useHomeFilterStore();
const { selectedMoodFilter, setSelectedMoodFilter } = useHomeFilterStore();
const { currentTrack, setTrack } = usePlayerStore();
const {
isLiked,
Expand Down Expand Up @@ -386,6 +392,14 @@ function HomeContent() {
}
}, [selectedMoodFilter, setSelectedMoodFilter]);

useEffect(function clearFeedbackPromptTimerOnUnmount() {
return () => {
if (feedbackPromptTimerRef.current) {
clearTimeout(feedbackPromptTimerRef.current);
}
};
}, []);

useEffect(() => {
if (!currentLocation) {
return;
Expand All @@ -399,7 +413,9 @@ function HomeContent() {
}

const nextPlace =
nearbyPlacesQuery.data?.[0] ?? reverseGeocodedPlaceQuery.data ?? undefined;
nearbyPlacesQuery.data?.[0] ??
reverseGeocodedPlaceQuery.data ??
undefined;

if (nextPlace?.id !== currentPlace?.id) {
setPlace(nextPlace);
Expand Down Expand Up @@ -536,6 +552,28 @@ function HomeContent() {
},
[addRecommendationEvent, selectedMoodFilter, setSelectedMoodFilter],
);
const queueRecommendationFeedback = useCallback(
(playlist?: PlaylistCuration) => {
if (
!playlist ||
promptedFeedbackPlaylistIdsRef.current.has(playlist.id)
) {
return;
}

promptedFeedbackPlaylistIdsRef.current.add(playlist.id);

if (feedbackPromptTimerRef.current) {
clearTimeout(feedbackPromptTimerRef.current);
}

feedbackPromptTimerRef.current = setTimeout(() => {
setFeedbackPlaylist(playlist);
feedbackPromptTimerRef.current = undefined;
}, 220);
},
[],
);
const handleEnableLocationRecommendation = useCallback(async () => {
const nextProfile = {
companionType: profile.companionType,
Expand Down Expand Up @@ -581,13 +619,7 @@ function HomeContent() {
} catch {
setLocationStatus("unavailable");
}
}, [
clearLocation,
locationStatus,
setLocation,
setLocationStatus,
setPlace,
]);
}, [clearLocation, locationStatus, setLocation, setLocationStatus, setPlace]);
const handleSetCurrentLocation = useCallback(async () => {
if (!profile.locationRecommendationEnabled) {
const didEnable = await handleEnableLocationRecommendation();
Expand Down Expand Up @@ -666,6 +698,9 @@ function HomeContent() {
const handleCloseCurrentSoundtrack = useCallback(() => {
setIsSoundtrackSheetVisible(false);
}, []);
const handleDismissCurrentSoundtrack = useCallback(() => {
queueRecommendationFeedback(recommendedPlaylist);
}, [queueRecommendationFeedback, recommendedPlaylist]);
const handleSelectCurrentSoundtrackTrack = useCallback(
(track: Track) => {
if (!recommendedPlaylist) {
Expand Down Expand Up @@ -803,7 +838,61 @@ function HomeContent() {
);
const handleCloseMusicPlaylistSheet = useCallback(() => {
setSelectedMusicPlaylistId(undefined);
queueRecommendationFeedback(selectedMusicPlaylist);
}, [queueRecommendationFeedback, selectedMusicPlaylist]);
const handleCloseRecommendationFeedback = useCallback(() => {
setFeedbackPlaylist(undefined);
}, []);
const handleSubmitRecommendationFeedback = useCallback(
(rating: RecommendationFeedbackRating, moodFilter?: string) => {
if (!feedbackPlaylist) {
return;
}

const context = createRecommendationEventContext({
moodFilter: moodFilter ?? selectedMoodFilter,
source: feedbackPlaylist.context?.source,
});

syncRecommendationEvent(
addRecommendationEvent({
context,
playlistId: feedbackPlaylist.id,
type: "recommendation_feedback",
value: moodFilter ? `${rating}:${moodFilter}` : rating,
}),
);

if (moodFilter) {
setSelectedMoodFilter(moodFilter);
syncRecommendationEvent(
addRecommendationEvent({
context,
playlistId: feedbackPlaylist.id,
type: "mood_adjusted",
value: moodFilter,
}),
);
setActionMessage(
`${moodFilter} 무드로 바꿨어요. 다음 추천에 바로 반영할게요.`,
);
} else if (rating === "great") {
setActionMessage("좋아요. 비슷한 장소와 무드 추천에 반영할게요.");
} else if (rating === "okay") {
setActionMessage("피드백을 저장했어요. 다음 추천을 더 잘 맞춰볼게요.");
} else {
setActionMessage("다음 추천에서는 다른 느낌을 더 살펴볼게요.");
}

setFeedbackPlaylist(undefined);
},
[
addRecommendationEvent,
feedbackPlaylist,
selectedMoodFilter,
setSelectedMoodFilter,
],
);
const handleSelectMusicPlaylistTrack = useCallback(
(track: Track) => {
if (!selectedMusicPlaylist) {
Expand Down Expand Up @@ -961,8 +1050,7 @@ function HomeContent() {
enabled={profile.locationRecommendationEnabled}
isLoading={locationStatus === "loading"}
isPlaceLoading={
nearbyPlacesQuery.isFetching ||
reverseGeocodedPlaceQuery.isFetching
nearbyPlacesQuery.isFetching || reverseGeocodedPlaceQuery.isFetching
}
location={currentLocation}
onEnable={handleSetCurrentLocation}
Expand Down Expand Up @@ -1050,6 +1138,7 @@ function HomeContent() {
}
likedTrackIds={currentSoundtrackLikedTrackIds}
onClose={handleCloseCurrentSoundtrack}
onDismissed={handleDismissCurrentSoundtrack}
onRetry={() => void refetchRecommendedPlaylist()}
onSelectTrack={handleSelectCurrentSoundtrackTrack}
onToggleLike={handleToggleCurrentSoundtrackLike}
Expand Down Expand Up @@ -1077,6 +1166,13 @@ function HomeContent() {
savedTrackIds={selectedMusicPlaylistSavedTrackIds}
visible={isMusicPlaylistSheetVisible}
/>
<RecommendationFeedbackSheet
onClose={handleCloseRecommendationFeedback}
onSubmit={handleSubmitRecommendationFeedback}
playlistReason={feedbackPlaylist?.reason}
regionName={feedbackPlaylist?.regionName}
visible={Boolean(feedbackPlaylist)}
/>
{currentTrack ? <MiniPlayer /> : null}
</Screen>
);
Expand Down
Loading
Loading