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
106 changes: 53 additions & 53 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { travelSessionApi } from "@/api/travelSessionApi";
import { useMomentLogListQuery } from "@/api/momentLogQueries";
import { recapApi } from "@/api/recapApi";
import { recapQueryKeys } from "@/api/recapQueries";
import { useNearbyPlacesQuery } from "@/api/tourQueries";
import { AppText } from "@/components/AppText";
Expand All @@ -22,17 +24,12 @@ import {
useTravelRouteTracking,
} from "@/hooks/useTravelRouteTracking";
import { useAuthStore } from "@/store/authStore";
import { useMomentLogStore } from "@/store/momentLogStore";
import { usePlayerStore } from "@/store/playerStore";
import { queryClient } from "@/providers/queryClient";
import { useTravelSessionStore } from "@/store/travelSessionStore";
import { useTravelLogSyncStore } from "@/store/travelLogSyncStore";
import { useUserProfileStore } from "@/store/userProfileStore";
import type { TravelMode } from "@/types/domain";
import { requestForegroundLocationWithStatus } from "@/utils/location";
import { createSessionRecapId } from "@/utils/recapMappers";
import { flushPendingMomentActions } from "@/utils/momentLogSync";
import { flushPendingTravelLogFinalizations } from "@/utils/travelLogSync";

const NEARBY_TOUR_RADIUS_METERS = 2000;

Expand All @@ -47,7 +44,6 @@ export default function MapHomeScreen() {
const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false);
const [isEndingTravel, setIsEndingTravel] = useState(false);
const [mapMessage, setMapMessage] = useState<string>();
const momentLogs = useMomentLogStore((state) => state.logs);
const {
clearLocation,
currentLocation,
Expand All @@ -73,6 +69,13 @@ export default function MapHomeScreen() {
location: currentLocation,
radiusMeters: NEARBY_TOUR_RADIUS_METERS,
});
const sessionMomentsQuery = useMomentLogListQuery(
{
limit: 100,
sessionId: session.status === "active" ? session.id : undefined,
},
{ enabled: status === "authenticated" && session.status === "active" },
);
const nearestTourPlace = currentLocation
? nearbyPlacesQuery.data?.find(
(place) => place.source === "tour-api" && Boolean(place.location),
Expand All @@ -96,9 +99,7 @@ export default function MapHomeScreen() {
: nearbyPlacesQuery.isError
? "error"
: "empty";
const sessionMomentCount = momentLogs.filter(
(log) => log.sessionId === session.id,
).length;
const sessionMomentCount = sessionMomentsQuery.data?.length ?? 0;

useEffect(
function synchronizeRecommendationMode() {
Expand Down Expand Up @@ -244,23 +245,18 @@ export default function MapHomeScreen() {
travelMode: nextMode,
});

startSession({
id: serverSession?.id,
routePoints: serverSession?.routePoints ?? initialRoutePoints,
startedAt: serverSession?.startedAt ?? startedAt,
});
} catch {
const startLocation = currentLocation ?? activeCurrentPlace?.location;
const startedAt = new Date().toISOString();
if (!serverSession?.id) {
throw new Error("travel_session_create_failed");
}

startSession({
routePoints: startLocation
? [createRoutePoint(startLocation, new Date(startedAt))]
: undefined,
startedAt,
id: serverSession.id,
routePoints: serverSession.routePoints ?? initialRoutePoints,
startedAt: serverSession.startedAt ?? startedAt,
});
} catch {
setMapMessage(
"서버 여행 세션 연결에 실패해서 로컬 여행모드로 먼저 시작했어요.",
"여행모드를 시작하지 못했어요. 네트워크를 확인한 뒤 다시 시도해주세요.",
);
} finally {
setIsStartingTravel(false);
Expand All @@ -274,59 +270,63 @@ export default function MapHomeScreen() {

const endingSession = session;
const endedAt = new Date().toISOString();
const localRecapId = createSessionRecapId(endingSession.id);

setIsEndingTravel(true);
setMapMessage(undefined);

try {
await flushPendingMomentActions();

const latestSessionLogs = useMomentLogStore
.getState()
.logs.filter((log) => log.sessionId === endingSession.id);
const latestSessionLogs =
(await sessionMomentsQuery.refetch()).data ?? [];

const endedServerSession = await travelSessionApi.endTravelSession(
endingSession.id,
{
endedAt,
location: currentLocation ?? activeCurrentPlace?.location,
routePoints: endingSession.routePoints,
},
);

endSession();
setIsEndConfirmVisible(false);
if (!endedServerSession) {
throw new Error("travel_session_end_failed");
}

if (latestSessionLogs.length === 0) {
endSession();
setSessionRecapId(undefined);
setIsEndConfirmVisible(false);
setMapMessage(
"여행을 종료했어요. 남긴 리캡이 없어 로그는 만들지 않았어요.",
);
return;
}

useTravelLogSyncStore.getState().queueFinalization({
endedAt,
location: currentLocation ?? activeCurrentPlace?.location,
routePoints: endingSession.routePoints,
sessionId: endingSession.id,
templateId: "album",
title: `${latestSessionLogs[0]?.placeName ?? "여행"} 로그`,
});
const syncResult = await flushPendingTravelLogFinalizations();
const recapId =
syncResult.createdRecapIds[endingSession.id] ?? localRecapId;

setSessionRecapId(recapId);
await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists });
const recap = await recapApi.createRecap(
{
momentLogIds: latestSessionLogs.map((log) => log.id),
routePoints: endingSession.routePoints,
sessionId: endingSession.id,
templateId: "album",
title: `${latestSessionLogs[0]?.placeName ?? "여행"} 로그`,
visibility: "private",
},
`travel-log:${endingSession.id}`,
);

if (recapId === localRecapId) {
setMapMessage(
"서버 동기화가 끝나면 여행 로그가 자동으로 완성돼요. 지금은 기기 기록을 보여드릴게요.",
);
if (!recap) {
throw new Error("travel_log_create_failed");
}

router.push(`/recap-share/${recapId}`);
} catch {
endSession();
setSessionRecapId(localRecapId);
setSessionRecapId(recap.id);
setIsEndConfirmVisible(false);
await queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists });
router.push(`/recap-share/${recap.id}`);
} catch {
setIsEndConfirmVisible(false);
setMapMessage(
"서버 로그 생성에 실패해 기기에 저장된 여행 로그를 먼저 보여드려요.",
"여행 로그를 서버에 저장하지 못했어요. 네트워크를 확인한 뒤 다시 종료해주세요.",
);
router.push(`/recap-share/${localRecapId}`);
} finally {
setIsEndingTravel(false);
}
Expand Down
2 changes: 0 additions & 2 deletions app/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { PageHeader } from "@/components/PageHeader";
import { Screen } from "@/components/Screen";
import { useAuthStore } from "@/store/authStore";
import { useUserProfileStore } from "@/store/userProfileStore";
import { migrateLocalDataToAccount } from "@/utils/localDataMigration";

type AuthMode = "login" | "register";

Expand Down Expand Up @@ -99,7 +98,6 @@ export default function LoginScreen() {
}

finishLogin(session);
void migrateLocalDataToAccount();
router.replace(getNextRoute(didCompleteOnboarding));
} catch (error) {
setStatus("unauthenticated");
Expand Down
26 changes: 5 additions & 21 deletions docs/frontend/AUTH_LOGIN_FLOW_PLAN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Soundlog 로그인/소셜 로그인 구현 계획

> **Superseded:** 이 문서의 소셜 로그인 설계는 과거 검토안이다. 현재 MVP 인증 기준은 이메일/비밀번호이며, 실제 구현과 제품 명세는 `docs/implementation/2026-07-02-first-party-login-plan.md` `docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md`를 따른다.
> **Superseded:** 이 문서의 소셜 로그인과 로컬 데이터 이관 설계는 과거 검토안이다. 현재 MVP 인증 기준은 Soundlog 자체 이메일과 비밀번호이며 사용자 기록은 로그인 상태에서 서버에 직접 저장한다. 실제 구현과 제품 명세는 `docs/implementation/2026-07-02-first-party-login-plan.md`, `docs/implementation/2026-08-11-server-first-recap-write-plan.md`, `docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md`를 따른다.

## 1. 목표

Expand All @@ -10,7 +10,7 @@ Soundlog에 계정 로그인을 붙이는 인증 플로우를 설계한다. 현

- 소셜 로그인 제공자를 쉽게 추가할 수 있는 인증 레이어 구축
- 앱 시작 시 세션 복구, 로그인 필요 여부, 온보딩 완료 여부를 안정적으로 분기
- 기존 로컬 취향/여행 로그/좋아요 데이터를 로그인 후 서버 계정으로 이관할 수 있는 구조 마련
- 로그인 이후 생성하는 기록을 서버 계정에 즉시 저장하는 구조 마련
- 웹, iOS, Android에서 모두 테스트 가능한 mock auth 플로우 제공

## 2. 추천 UX 정책
Expand All @@ -23,7 +23,7 @@ Soundlog에 계정 로그인을 붙이는 인증 플로우를 설계한다. 현

- 여행 기록과 Recap은 계정에 보존되어야 사용자가 데이터 유실을 덜 걱정한다.
- 공동 Recap, Live Sound Map, 음악 취향 매칭은 계정 식별과 신고/차단 정책이 필요하다.
- 기존 기기에 남아 있는 로컬 기록은 로그인 후 서버 동기화를 시도한다.
- 로그인 전에 사용자 기록을 만들지 않으므로 별도 이관 단계를 두지 않는다.

권장 제한:

Expand Down Expand Up @@ -73,7 +73,7 @@ Soundlog에 계정 로그인을 붙이는 인증 플로우를 설계한다. 현

- `app/(tabs)/my.tsx`
- 상단에 계정 카드 추가
- 로그인 상태: 이름/이메일/제공자/동기화 상태/로그아웃
- 로그인 상태: 이름/이메일/제공자/로그아웃
- 로그아웃 상태: 로그인 유도 CTA

## 4. 상태 관리 설계
Expand Down Expand Up @@ -189,10 +189,6 @@ refresh token으로 access token을 갱신한다.

온보딩/취향 정보를 서버에 저장한다.

### `POST /v1/me/migrate-local-data`

로그인 전 로컬로 만든 로그, 좋아요, Recap 초안을 로그인 계정으로 이관한다.

## 6. 소셜 로그인 제공자 전략

### MVP 1순위
Expand Down Expand Up @@ -344,22 +340,10 @@ access token 만료 시 여러 query/mutation이 동시에 refresh를 호출할
- web MVP: mock auth 또는 memory session
- web production: 백엔드 httpOnly secure cookie 기반 세션 검토

### 10.4 로그인 전 로컬 데이터 이관

로그인 전 로컬에 남은 데이터가 로그인 후 중복 생성될 수 있다.

보강안:

- 로컬 로그/좋아요/Recap에는 `localId`, `createdAt`, `syncedAt`을 둔다.
- migration endpoint는 idempotency key를 받는다.
- migration 성공 후 즉시 삭제하지 않고 `syncedAt` 표시로 남긴다.

## 11. 구현 전 확인 질문

아래 항목은 제품 정책에 영향을 주므로 구현 전에 결정이 필요하다.

1. MVP에서는 로그인을 필수로 막고, 온보딩 소개와 약관만 로그아웃 상태에서 접근할 수 있게 합니다.
2. 1차 소셜 로그인 제공자는 무엇으로 갈까요? 권장안은 Apple, Google, Kakao입니다.
3. 로그인 전 만든 여행 로그/좋아요/Recap은 로그인 후 자동 이관할까요, 사용자 확인 후 이관할까요?
4. 로그아웃 시 로컬 여행 기록은 유지할까요, 모두 삭제할까요?
5. 실제 소셜 OAuth는 이번 작업에서 붙일까요, 아니면 mock auth 골격과 API 계약까지만 먼저 갈까요?
3. 실제 소셜 OAuth는 이번 작업에서 붙일까요, 아니면 mock auth 골격과 API 계약까지만 먼저 갈까요?
9 changes: 4 additions & 5 deletions docs/frontend/RN_FRONTEND_PLANNING_POINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔
| 네비게이션 | React Navigation | 탭, 스택, 모달 흐름 구성에 적합 |
| 서버 상태 | TanStack Query | 관광 API, 추천 API, Recap API 캐싱 및 실패 처리 |
| 클라이언트 상태 | Zustand 또는 Jotai | 현재 여행 세션, 선택 태그, 선택 곡 상태 관리 |
| 로컬 저장소 | MMKV 또는 AsyncStorage | 온보딩 상태, 최근 여행 세션, 임시 로그 저장 |
| 로컬 저장소 | MMKV 또는 AsyncStorage | 온보딩 상태와 진행 중 GPS 경로 복구용 센서 버퍼 저장 |
| 카메라 | expo-camera 또는 react-native-vision-camera | MVP는 expo-camera, 고성능 촬영/프레임 처리는 vision-camera 검토 |
| 위치 | expo-location 또는 react-native-geolocation-service | 백그라운드 위치 필요 여부에 따라 선택 |
| 이미지 처리 | expo-image, expo-image-manipulator | Recap 썸네일, 이미지 압축, 캐싱 |
Expand Down Expand Up @@ -341,7 +341,6 @@ type MomentLog = {
mode?: TravelMode;
moods: MoodTag[];
memo?: string;
syncStatus: "local" | "syncing" | "synced" | "failed";
};
```

Expand Down Expand Up @@ -517,9 +516,9 @@ Soundlog는 이미지와 위치, 리스트가 많기 때문에 모바일 성능
프론트 대응은 다음과 같다.

- 마지막 추천 플레이리스트를 로컬 캐싱한다.
- 리캡 저장 요청은 네트워크 실패 시 로컬 큐에 저장한다.
- 네트워크 복구 후 자동 동기화한다.
- Recap 생성 요청 실패 시 재시도 가능하게 한다.
- 리캡 저장 요청은 서버 성공 응답을 받은 뒤 완료 처리한다.
- 저장 요청이 실패하면 작성 화면을 유지하고 명시적으로 재시도할 수 있게 한다.
- 여행 시작과 종료도 서버 요청이 실패하면 로컬 성공 상태로 바꾸지 않는다.
- 오프라인 상태에서는 “최근 추천 기반으로 계속 듣기”를 제공한다.

---
Expand Down
Loading
Loading