diff --git a/app.config.js b/app.config.js
index 60900a3..9ea1043 100644
--- a/app.config.js
+++ b/app.config.js
@@ -100,8 +100,8 @@ const baseConfig = {
android: {
package: 'com.mannomi.soundlog',
adaptiveIcon: {
- backgroundColor: '#E6F4FE',
- foregroundImage: './assets/soundlog-logo.png',
+ backgroundColor: '#070B1F',
+ foregroundImage: './assets/android-icon-foreground.png',
backgroundImage: './assets/android-icon-background.png',
monochromeImage: './assets/android-icon-monochrome.png',
},
@@ -122,11 +122,20 @@ const baseConfig = {
},
web: {
bundler: 'metro',
- favicon: './assets/soundlog-logo.png',
+ favicon: './assets/favicon.png',
},
plugins: [
'expo-router',
'expo-font',
+ [
+ 'expo-splash-screen',
+ {
+ backgroundColor: '#070B1F',
+ image: './assets/splash-icon.png',
+ imageWidth: 390,
+ resizeMode: 'contain',
+ },
+ ],
[
'expo-camera',
{
diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx
index 6950158..902e6b1 100644
--- a/app/(tabs)/my.tsx
+++ b/app/(tabs)/my.tsx
@@ -5,6 +5,7 @@ import { ScrollView, View } from 'react-native';
import { meApi } from '@/api/meApi';
import { useNearbyPlacesQuery } from '@/api/tourQueries';
import { AppText } from '@/components/AppText';
+import { AccountActionsSection } from '@/components/my/AccountActionsSection';
import { AuthAccountCard } from '@/components/my/AuthAccountCard';
import { MySettingsRow } from '@/components/my/MySettingsRow';
import { PermissionSettingsCard } from '@/components/my/PermissionSettingsCard';
@@ -228,6 +229,8 @@ export default function MyScreen() {
/>
) : null}
+
+
);
diff --git a/app/_layout.tsx b/app/_layout.tsx
index a80dfdb..53b223d 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,17 +1,25 @@
import '../global.css';
import { Stack } from 'expo-router';
+import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { AppEntryGate } from '@/components/auth/AppEntryGate';
import { AppProviders } from '@/providers/AppProviders';
+void SplashScreen.preventAutoHideAsync().catch(() => undefined);
+
export default function RootLayout() {
return (
-
+
diff --git a/app/auth/login.tsx b/app/auth/login.tsx
index fb46562..4f310db 100644
--- a/app/auth/login.tsx
+++ b/app/auth/login.tsx
@@ -254,7 +254,7 @@ export default function LoginScreen() {
}`}
>
{hasAcceptedRequiredTerms ? (
-
+
) : null}
diff --git a/assets/android-icon-background.png b/assets/android-icon-background.png
index 5ffefc5..c28e678 100644
Binary files a/assets/android-icon-background.png and b/assets/android-icon-background.png differ
diff --git a/assets/android-icon-foreground.png b/assets/android-icon-foreground.png
index 3a9e501..d64aea1 100644
Binary files a/assets/android-icon-foreground.png and b/assets/android-icon-foreground.png differ
diff --git a/assets/android-icon-monochrome.png b/assets/android-icon-monochrome.png
index 77484eb..bd3ebb8 100644
Binary files a/assets/android-icon-monochrome.png and b/assets/android-icon-monochrome.png differ
diff --git a/assets/favicon.png b/assets/favicon.png
index 408bd74..f75c2fe 100644
Binary files a/assets/favicon.png and b/assets/favicon.png differ
diff --git a/assets/icon.png b/assets/icon.png
index de9e7a1..a18f79b 100644
Binary files a/assets/icon.png and b/assets/icon.png differ
diff --git a/assets/soundlog-logo.png b/assets/soundlog-logo.png
index 5efdc53..21c93b2 100644
Binary files a/assets/soundlog-logo.png and b/assets/soundlog-logo.png differ
diff --git a/assets/splash-icon.png b/assets/splash-icon.png
index 03d6f6b..3fe4caf 100644
Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ
diff --git a/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md b/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md
index 809b1d2..125339f 100644
--- a/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md
+++ b/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md
@@ -65,14 +65,15 @@ import {
| Card | `#080D18` | `bg-soundlog-card` |
| Elevated card | `#090E1B` | `bg-soundlog-elevated` |
| Chip | `#171B2A` | `bg-soundlog-chip` |
-| Selected / CTA | `#B7E628` | `bg-soundlog-selected`, `bg-soundlog-lime` |
+| Selected surface | `#214A73` | `bg-soundlog-selected` |
+| Brand highlight | `#B7E628` | `bg-soundlog-lime` |
| Focus border | `#B7E628` | `border-soundlog-focus` |
| Chip border | `#364283` | `border-soundlog-border` |
| Info blue | `#6EA8FF` | `text-soundlog-blue` |
-| Accent purple | `#7A2CFF` | `text-soundlog-purple` |
+| Accent blue | `#6EA8FF` | `text-soundlog-blue` |
| Accent gold | `#B1913A` | `text-soundlog-gold` |
| Warning action | `#FF8A3D` | `bg-soundlog-warning` |
-| Primary action | `#4A1D96` | `bg-soundlog-action` |
+| Primary action | `#315F9F` | `bg-soundlog-action` |
| Danger action | `#8E310D` | `bg-soundlog-danger` |
| Inverse text | `#FFFFFF` | `text-soundlog-inverse` |
diff --git a/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md b/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md
index 33e0cbf..0289ac7 100644
--- a/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md
+++ b/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md
@@ -92,7 +92,7 @@ RN에서는 다음 방식 중 하나를 사용한다.
| `border.chip` | `#364283` | 칩 테두리 |
| `text.primary` | `#FFFFFF` | 주요 텍스트 |
| `text.secondary` | `rgba(255,255,255,0.68)` | 보조 텍스트 |
-| `accent.purple` | `#7A2CFF` | 진행바/선택 포인트 |
+| `accent.blue` | `#6EA8FF` | 진행바/선택 포인트 |
| `accent.gold` | `#B1913A` | 추천 카드 |
| `player.background` | `#45343D` | 미니 플레이어 |
diff --git a/docs/frontend/RECAP_SHARE_PAGE_RN_BUILD_DOC.md b/docs/frontend/RECAP_SHARE_PAGE_RN_BUILD_DOC.md
index f25079f..ae9e052 100644
--- a/docs/frontend/RECAP_SHARE_PAGE_RN_BUILD_DOC.md
+++ b/docs/frontend/RECAP_SHARE_PAGE_RN_BUILD_DOC.md
@@ -86,7 +86,7 @@ Figma 배경은 어두운 그라데이션과 중앙 glow가 있다. RN에서는
| 토큰 | 값 | 용도 |
| --- | --- | --- |
| `background.primary` | `#050916` | 기본 배경 |
-| `background.deepPurple` | `#160F27` | 그라데이션 |
+| `background.deepNavy` | `#101A38` | 그라데이션 |
| `background.brownGlow` | `#2C1B33` | 보조 그라데이션 |
| `text.primary` | `#FFFFFF` | 타이틀 |
| `text.secondary` | `#C1C1C1` | 날짜/시간 |
diff --git a/docs/implementation/2026-09-05-notion-qa-clear-plan.md b/docs/implementation/2026-09-05-notion-qa-clear-plan.md
new file mode 100644
index 0000000..da2f831
--- /dev/null
+++ b/docs/implementation/2026-09-05-notion-qa-clear-plan.md
@@ -0,0 +1,71 @@
+# Notion QA 전체 해소 계획
+
+## 작업 목표
+
+Notion QA 이미지에서 확인된 화면 문제를 GitHub 이슈와 연결하고 앱 코드와 에셋을 수정한 뒤 iOS Simulator에서 완료 조건을 확인한다.
+
+현재 제공된 Notion 페이지 `3d2bd7e4-3465-804a-a262-d56fd55c81a9`는 연결된 `만욱` 워크스페이스에서 접근할 수 없다. 따라서 이번 단계에서는 MCP로 읽을 수 있는 기존 SoundLog QA 원문 `3ce7cf19-a364-80fa-ac25-f2e9127b0765`와 GitHub 이슈 #29부터 #36을 근거로 처리한다. 새 페이지 권한이 열리면 이미지를 다시 수집하고 누락 이슈를 추가한다.
+
+## 이슈별 현재 상태
+
+### #29 브랜드 포인트 컬러
+
+중앙 토큰과 여러 화면의 직접 지정값에 `#4A1D96`과 `#7A2CFF`가 남아 있다. 선택 배경은 어두운 블루로 바꾸고 주요 행동은 흰색 글자를 읽을 수 있는 블루로 바꾼다. 라임은 테두리와 현재 상태 강조에 사용한다.
+
+### #30 바텀시트 전환
+
+여행 모드 시트의 딤과 본문이 같은 컨테이너에서 표시된다. 딤의 페이드와 본문의 아래쪽 진입 애니메이션을 분리한다. 배경 탭과 닫기 버튼과 아래 드래그는 같은 닫기 애니메이션을 사용한다.
+
+### #31 리캡 선택 버튼 글자
+
+현재 템플릿과 공개 범위와 무드 버튼은 12pt와 44pt 터치 영역을 사용한다. 수정 후에도 이 조건을 유지하고 작은 화면에서 잘림 여부를 Simulator로 확인한다.
+
+### #32 리캡 작성 흐름
+
+현재 상단에는 뒤로가기와 제목만 있고 저장 행동은 긴 화면의 맨 아래에만 있다. 상단 오른쪽에 저장 행동을 추가한다. 4:5 미리보기를 먼저 보여주고 템플릿 선택을 미리보기 바로 아래에 배치해서 게시물 작성 흐름으로 정리한다. 장소와 공개 범위와 음악과 여행 상태 계약은 바꾸지 않는다.
+
+### #33 계정 행동 위치
+
+로그아웃과 계정 삭제가 상단 계정 카드 안에 있다. 상단에는 프로필 또는 로그인 진입만 남기고 인증된 계정의 로그아웃과 삭제는 서비스와 개발 도구 다음 화면 최하단으로 옮긴다. 기존 확인 알림과 실패 메시지는 유지한다.
+
+### #34 마이 화면 가독성
+
+권한 행동 라벨이 11pt이고 하단 탭 라벨도 11pt다. 둘 다 12pt로 올린다. 설정 설명은 12pt를 유지하면서 흰색 투명도를 높이고 긴 장소명은 최대 두 줄로 보여준다.
+
+### #35 앱 아이콘
+
+iOS 아이콘만 라임 LP와 지도 마커 조합으로 교체됐고 Android 적응형 아이콘과 파비콘에는 이전 로고가 남아 있다. 현재 iOS 아이콘을 기준으로 Android 전경과 배경과 단색 마스크와 파비콘을 다시 만든다. iOS 아이콘은 1024 정사각형 RGB 상태를 유지한다.
+
+### #36 스플래시
+
+현재 스플래시 에셋은 Apple 템플릿 격자 이미지다. Figma 325:745의 네이비 배경과 라임 오로라와 흰색 SoundLog 워드마크를 재현한 에셋으로 교체한다. Expo 스플래시 설정과 초기 상태 표시줄을 같은 네이비 배경과 밝은 스타일로 맞춘다.
+
+## 구현 순서
+
+1. 색상 토큰과 직접 지정된 보라색을 블루와 라임 팔레트로 교체한다.
+2. 공통 닫기형 바텀시트 훅에 딤과 본문의 진입과 종료 애니메이션을 분리한다.
+3. 리캡 작성 화면과 마이 화면을 완료 조건에 맞게 재배치한다.
+4. 앱 아이콘과 스플래시 에셋을 생성하고 Expo 설정을 갱신한다.
+5. 타입 검사와 전체 테스트와 Expo 검사를 실행한다.
+6. iPhone Simulator에서 지도와 음악추천과 리캡과 마이 화면을 검수하고 스플래시를 앱 재실행으로 확인한다.
+7. PR을 만들고 각 이슈에 파일과 테스트와 화면 근거를 연결한다.
+
+## 계획 자체 검토
+
+- 기존 리캡과 로그 데이터 계약은 변경하지 않는다.
+- 서버 저장소와 운영 서버는 변경하지 않는다.
+- 기본 사용자 텍스트에 검정색이나 진한 보라색을 추가하지 않는다.
+- 에셋 교체는 iOS와 Android와 웹 설정을 함께 확인한다.
+- GitHub 이슈는 main에 반영되고 Simulator 검증 근거가 생긴 뒤에만 종료한다.
+- 제공된 새 Notion 페이지를 읽지 못한 상태에서 기존 QA 범위를 전체 범위로 오인하지 않는다.
+
+## 검증 결과
+
+- `npm run check`로 타입 검사와 브랜드 에셋 검사와 이미지 오류 처리 검사와 리캡 지도 군집 검사와 Expo Doctor를 통과했다.
+- `npm test -- --run`으로 테스트 파일 9개와 테스트 57개를 통과했다.
+- iPhone 16 Pro iOS 18.0 Simulator에서 지도 팔레트와 여행 모드 시트와 사진 없는 리캡 작성 화면과 마이 화면을 확인했다.
+- 여행 모드 시트의 딤은 화면에 고정되고 시트 본문만 아래에서 올라왔다. 배경을 누르면 같은 닫기 애니메이션으로 종료됐다.
+- 리캡 작성 화면은 상단 저장 버튼과 4:5 미리보기와 미리보기 바로 아래 게시물 스타일 선택 순서로 표시됐다. 공개 범위와 무드 버튼은 접근성 트리에서 각각 별도 버튼으로 확인됐다.
+- 마이 화면 접근성 트리에서 프로필 카드 다음에 설정과 권한과 서비스와 개발 도구가 나오고 로그아웃과 계정 삭제가 마지막 계정 섹션에 배치된 것을 확인했다.
+- 스플래시 에셋과 Expo 설정은 정적 검사로 확인했다. 디버그 실행은 `Refreshing` 오버레이가 스플래시를 가려 영상 판정에 사용할 수 없었다. 릴리스 Simulator 빌드는 소스 오류가 아니라 로컬 디스크 공간 부족으로 중단됐으므로 배포 빌드 전 재검증이 필요하다.
+- 새 Notion 페이지는 같은 연결에서 세 번 다시 읽었으나 모두 `object_not_found` 404가 반환됐다. 공유 권한이 열리기 전에는 새 이미지에 대한 누락 이슈가 없다고 보장할 수 없다.
diff --git a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html
index 166f030..6e4e093 100644
--- a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html
+++ b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html
@@ -19,7 +19,6 @@
--lime-soft: rgba(183, 230, 40, 0.12);
--blue: #6ea8ff;
--blue-soft: rgba(110, 168, 255, 0.14);
- --purple: #7a2cff;
--gold: #b1913a;
--warning: #ff8a3d;
--warning-soft: rgba(255, 138, 61, 0.14);
diff --git a/package-lock.json b/package-lock.json
index 18b31c3..e492abd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"expo-router": "~56.2.20",
"expo-secure-store": "~56.0.4",
"expo-sharing": "~56.0.26",
+ "expo-splash-screen": "~56.0.15",
"expo-status-bar": "~56.0.4",
"nativewind": "^4.2.4",
"react": "19.2.3",
@@ -5825,6 +5826,20 @@
"react-native": "*"
}
},
+ "node_modules/expo-splash-screen": {
+ "version": "56.0.15",
+ "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-56.0.15.tgz",
+ "integrity": "sha512-z2fmhPFx/ZYxWo7Roy8+uKr+Y+R6o11Ejd6zGgI4JWyXYN2A6P3+Hp3nKlGkip/hBT4TXiIw+54JCQNC3D3oKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/config-plugins": "~56.0.16",
+ "@expo/image-utils": "^0.10.5",
+ "xml2js": "0.6.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-status-bar": {
"version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-56.0.4.tgz",
diff --git a/package.json b/package.json
index 061335a..eccd624 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"expo-router": "~56.2.20",
"expo-secure-store": "~56.0.4",
"expo-sharing": "~56.0.26",
+ "expo-splash-screen": "~56.0.15",
"expo-status-bar": "~56.0.4",
"nativewind": "^4.2.4",
"react": "19.2.3",
@@ -62,6 +63,7 @@
"test": "vitest run",
"doctor": "npx expo-doctor",
"check:api-origin": "node scripts/check-api-origin.js",
+ "check:brand-qa": "node scripts/check-brand-qa.js",
"check:image-components": "node scripts/check-image-components.js",
"check:deployed-web": "node scripts/check-deployed-web.js",
"check:server-web-export": "node scripts/check-server-web-export.js",
@@ -70,7 +72,7 @@
"check:recap-clustering": "node scripts/check-recap-map-clustering.js",
"check:server-contract": "node scripts/check-server-api-contract.js",
"check:vercel-config": "node scripts/check-vercel-config.js",
- "check": "npm run typecheck && npm run check:image-components && npm run check:recap-clustering && npm run doctor",
+ "check": "npm run typecheck && npm run check:brand-qa && npm run check:image-components && npm run check:recap-clustering && npm run doctor",
"prepare": "sh scripts/install-git-hooks.sh"
},
"private": true,
diff --git a/scripts/check-brand-qa.js b/scripts/check-brand-qa.js
new file mode 100644
index 0000000..142ac7a
--- /dev/null
+++ b/scripts/check-brand-qa.js
@@ -0,0 +1,106 @@
+const fs = require('node:fs');
+const path = require('node:path');
+
+const root = path.resolve(__dirname, '..');
+const forbiddenTokens = ['#4A1D96', '#7A2CFF', '#8B72FF', 'soundlog-purple'];
+
+function walkSource(directory) {
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const filePath = path.join(directory, entry.name);
+
+ if (entry.isDirectory()) {
+ return walkSource(filePath);
+ }
+
+ return /\.(?:js|ts|tsx)$/.test(entry.name) ? [filePath] : [];
+ });
+}
+
+function assertBrandPalette() {
+ const files = [
+ ...walkSource(path.join(root, 'app')),
+ ...walkSource(path.join(root, 'src')),
+ path.join(root, 'tailwind.config.js'),
+ ];
+ const violations = files.flatMap((filePath) => {
+ const contents = fs.readFileSync(filePath, 'utf8');
+ return forbiddenTokens
+ .filter((token) => contents.includes(token))
+ .map((token) => `${path.relative(root, filePath)}: ${token}`);
+ });
+
+ if (violations.length > 0) {
+ throw new Error(
+ `보라색 브랜드 토큰이 남아 있습니다.\n${violations.join('\n')}`,
+ );
+ }
+}
+
+function readPngInfo(relativePath) {
+ const buffer = fs.readFileSync(path.join(root, relativePath));
+ const signature = buffer.subarray(0, 8).toString('hex');
+
+ if (signature !== '89504e470d0a1a0a') {
+ throw new Error(`${relativePath} 파일이 PNG가 아닙니다.`);
+ }
+
+ return {
+ colorType: buffer.readUInt8(25),
+ height: buffer.readUInt32BE(20),
+ width: buffer.readUInt32BE(16),
+ };
+}
+
+function assertPng(relativePath, expected) {
+ const actual = readPngInfo(relativePath);
+
+ if (
+ actual.width !== expected.width ||
+ actual.height !== expected.height ||
+ (expected.colorType !== undefined &&
+ actual.colorType !== expected.colorType)
+ ) {
+ throw new Error(
+ `${relativePath} 규격이 올바르지 않습니다. expected=${JSON.stringify(expected)} actual=${JSON.stringify(actual)}`,
+ );
+ }
+}
+
+function assertAppAssets() {
+ assertPng('assets/icon.png', { colorType: 2, height: 1024, width: 1024 });
+ assertPng('assets/android-icon-foreground.png', { height: 512, width: 512 });
+ assertPng('assets/android-icon-background.png', {
+ colorType: 2,
+ height: 512,
+ width: 512,
+ });
+ assertPng('assets/android-icon-monochrome.png', { height: 432, width: 432 });
+ assertPng('assets/favicon.png', { height: 48, width: 48 });
+ assertPng('assets/splash-icon.png', { height: 1024, width: 1024 });
+
+ const config = require(path.join(root, 'app.config.js'))();
+ const splashPlugin = config.plugins.find(
+ (plugin) => Array.isArray(plugin) && plugin[0] === 'expo-splash-screen',
+ );
+
+ if (!splashPlugin || splashPlugin[1]?.image !== './assets/splash-icon.png') {
+ throw new Error('Expo 스플래시 플러그인이 새 에셋을 사용하지 않습니다.');
+ }
+
+ if (
+ config.android?.adaptiveIcon?.foregroundImage !==
+ './assets/android-icon-foreground.png'
+ ) {
+ throw new Error(
+ 'Android 적응형 아이콘이 새 전경 에셋을 사용하지 않습니다.',
+ );
+ }
+
+ if (config.web?.favicon !== './assets/favicon.png') {
+ throw new Error('웹 파비콘이 새 브랜드 에셋을 사용하지 않습니다.');
+ }
+}
+
+assertBrandPalette();
+assertAppAssets();
+console.log('Soundlog 브랜드 팔레트와 앱 아이콘 및 스플래시 규격 확인 완료');
diff --git a/src/components/SettingsRow.tsx b/src/components/SettingsRow.tsx
index f059854..bed1396 100644
--- a/src/components/SettingsRow.tsx
+++ b/src/components/SettingsRow.tsx
@@ -50,7 +50,7 @@ export function SettingsRow({
{description ? (
{description}
@@ -62,8 +62,8 @@ export function SettingsRow({
) : null}
{rightText ? (
{rightText}
diff --git a/src/components/auth/AppEntryGate.tsx b/src/components/auth/AppEntryGate.tsx
index b0349be..0340094 100644
--- a/src/components/auth/AppEntryGate.tsx
+++ b/src/components/auth/AppEntryGate.tsx
@@ -1,14 +1,45 @@
import { Redirect, usePathname } from 'expo-router';
+import * as SplashScreen from 'expo-splash-screen';
+import { useEffect } from 'react';
import { useAuthStore } from '@/store/authStore';
import { useUserProfileStore } from '@/store/userProfileStore';
+const MINIMUM_SPLASH_VISIBLE_MS = 600;
+const splashMountedAt = Date.now();
+
export function AppEntryGate() {
const pathname = usePathname();
const { isHydrated: authHydrated, status } = useAuthStore();
const { isHydrated: profileHydrated, profile } = useUserProfileStore();
+ const isReady = authHydrated && profileHydrated && status !== 'checking';
+
+ useEffect(() => {
+ if (!isReady) {
+ return;
+ }
+
+ let animationFrame: number | undefined;
+ const remainingDuration = Math.max(
+ MINIMUM_SPLASH_VISIBLE_MS - (Date.now() - splashMountedAt),
+ 0,
+ );
+ const timeout = setTimeout(() => {
+ animationFrame = requestAnimationFrame(() => {
+ void SplashScreen.hideAsync();
+ });
+ }, remainingDuration);
+
+ return () => {
+ clearTimeout(timeout);
+
+ if (animationFrame !== undefined) {
+ cancelAnimationFrame(animationFrame);
+ }
+ };
+ }, [isReady]);
- if (!authHydrated || !profileHydrated || status === 'checking') {
+ if (!isReady) {
return null;
}
diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx
index 660bcc6..0348dfb 100644
--- a/src/components/dev/DevTestManager.tsx
+++ b/src/components/dev/DevTestManager.tsx
@@ -122,12 +122,12 @@ function ManagerButton({
onPress,
}: ManagerButtonProps) {
const backgroundColor = active
- ? '#4A1D96'
+ ? '#315F9F'
: destructive
? 'rgba(248,113,113,0.16)'
: 'rgba(255,255,255,0.1)';
const borderColor = active
- ? '#8B72FF'
+ ? '#B7F31D'
: destructive
? 'rgba(248,113,113,0.28)'
: 'rgba(255,255,255,0.12)';
@@ -356,7 +356,7 @@ function DevTestManagerContent() {
setIsOpen(true)}
>
diff --git a/src/components/home/CarouselProgress.tsx b/src/components/home/CarouselProgress.tsx
index 3e51fe2..edca5e4 100644
--- a/src/components/home/CarouselProgress.tsx
+++ b/src/components/home/CarouselProgress.tsx
@@ -22,7 +22,7 @@ export function CarouselProgress({
return (
Travel Mode 시작
diff --git a/src/components/library/LibraryScreen.tsx b/src/components/library/LibraryScreen.tsx
index 4555e7f..d373849 100644
--- a/src/components/library/LibraryScreen.tsx
+++ b/src/components/library/LibraryScreen.tsx
@@ -272,7 +272,7 @@ export function LibraryScreen() {
className="h-10 flex-1 items-center justify-center rounded-full"
onPress={() => selectTab(tab.id)}
style={{
- backgroundColor: isSelected ? "#4A1D96" : "transparent",
+ backgroundColor: isSelected ? "#214A73" : "transparent",
}}
>
{isCapturing || isPickingPhoto ? (
-
+
) : (
)}
diff --git a/src/components/moment-capture/MomentPhotoCanvas.tsx b/src/components/moment-capture/MomentPhotoCanvas.tsx
index 1baca9c..9d7a878 100644
--- a/src/components/moment-capture/MomentPhotoCanvas.tsx
+++ b/src/components/moment-capture/MomentPhotoCanvas.tsx
@@ -532,7 +532,7 @@ export const MomentPhotoCanvas = forwardRef<
{isMapTemplate ? (
-
+
@@ -667,7 +667,7 @@ function MapMusicPin({ track }: { track?: Track }) {
{ backgroundColor: track?.fallbackColor ?? '#B7E628' },
]}
>
-
+
)}
@@ -681,7 +681,7 @@ function MapMusicPin({ track }: { track?: Track }) {
-
+
@@ -947,7 +947,7 @@ function StickerControlHeader({
({
return (
onChange(option.value)}
style={{
- backgroundColor: selected ? '#4A1D96' : 'transparent',
+ backgroundColor: selected ? '#214A73' : 'transparent',
opacity: disabled ? 0.5 : 1,
}}
>
}
+ rightContent={
+
+ {isSaving ? (
+
+ ) : (
+ 저장
+ )}
+
+ }
title="리캡 만들기"
/>
사진과 장소, 음악을 확인하고 저장하세요.
-
-
-
-
-
-
-
{photoUri ? (
)}
+
+
+
+
+
+
+
@@ -244,7 +261,7 @@ export const MomentReviewPanel = forwardRef<
style={{ opacity: disabled ? 0.38 : 1 }}
>
@@ -322,7 +339,7 @@ export const MomentReviewPanel = forwardRef<
onPress={() => toggleMoodTag(tag)}
>
diff --git a/src/components/my/AccountActionsSection.tsx b/src/components/my/AccountActionsSection.tsx
new file mode 100644
index 0000000..a0d254b
--- /dev/null
+++ b/src/components/my/AccountActionsSection.tsx
@@ -0,0 +1,99 @@
+import { router } from 'expo-router';
+import { useState } from 'react';
+import { Alert, View } from 'react-native';
+
+import { useDeleteAccountMutation, useLogoutMutation } from '@/api/authQueries';
+import { AppText } from '@/components/AppText';
+import { MySettingsRow } from '@/components/my/MySettingsRow';
+import { SectionTitle } from '@/components/SectionTitle';
+import { useAuthStore } from '@/store/authStore';
+import { clearAccountSession } from '@/utils/accountSession';
+
+export function AccountActionsSection() {
+ const [accountMessage, setAccountMessage] = useState();
+ const deleteAccountMutation = useDeleteAccountMutation();
+ const logoutMutation = useLogoutMutation();
+ const { refreshToken, status } = useAuthStore();
+
+ if (status !== 'authenticated') {
+ return null;
+ }
+
+ const handleLogout = async () => {
+ try {
+ await logoutMutation.mutateAsync(refreshToken);
+ } finally {
+ clearAccountSession();
+ router.replace('/auth/login' as never);
+ }
+ };
+
+ const handleLogoutPress = () => {
+ Alert.alert(
+ '로그아웃할까요?',
+ '이 기기에서 Soundlog 계정 연결을 종료합니다. 저장된 리캡과 로그는 삭제되지 않아요.',
+ [
+ { style: 'cancel', text: '취소' },
+ {
+ onPress: () => void handleLogout(),
+ text: '로그아웃',
+ },
+ ],
+ );
+ };
+
+ const handleDeleteAccount = () => {
+ Alert.alert(
+ 'Soundlog 계정을 삭제할까요?',
+ '계정과 서버에 저장된 여행 기록, 리캡, 보관함 데이터가 즉시 삭제됩니다. 이 작업은 되돌릴 수 없어요.',
+ [
+ { style: 'cancel', text: '취소' },
+ {
+ onPress: () => {
+ setAccountMessage(undefined);
+ void deleteAccountMutation
+ .mutateAsync()
+ .then(() => {
+ clearAccountSession();
+ router.replace('/auth/login' as never);
+ })
+ .catch(() => {
+ setAccountMessage(
+ '계정을 삭제하지 못했어요. 네트워크 상태를 확인한 뒤 다시 시도해주세요.',
+ );
+ });
+ },
+ style: 'destructive',
+ text: '계정 삭제',
+ },
+ ],
+ );
+ };
+
+ return (
+
+
+
+
+
+ {accountMessage ? (
+
+ {accountMessage}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/my/AuthAccountCard.tsx b/src/components/my/AuthAccountCard.tsx
index 566e737..1293734 100644
--- a/src/components/my/AuthAccountCard.tsx
+++ b/src/components/my/AuthAccountCard.tsx
@@ -1,13 +1,10 @@
import { router } from 'expo-router';
-import { useState } from 'react';
-import { Alert, View } from 'react-native';
+import { View } from 'react-native';
-import { useDeleteAccountMutation, useLogoutMutation } from '@/api/authQueries';
import { AppText } from '@/components/AppText';
import { MySettingsRow } from '@/components/my/MySettingsRow';
import { SectionTitle } from '@/components/SectionTitle';
import { useAuthStore } from '@/store/authStore';
-import { clearAccountSession } from '@/utils/accountSession';
function getAccountInitial(displayName?: string, email?: string) {
const source = displayName?.trim() || email?.trim() || 'S';
@@ -16,66 +13,12 @@ function getAccountInitial(displayName?: string, email?: string) {
}
export function AuthAccountCard() {
- const [accountMessage, setAccountMessage] = useState();
- const deleteAccountMutation = useDeleteAccountMutation();
- const logoutMutation = useLogoutMutation();
- const { refreshToken, status, user } = useAuthStore();
-
- const handleLogout = async () => {
- try {
- await logoutMutation.mutateAsync(refreshToken);
- } finally {
- clearAccountSession();
- router.replace('/auth/login' as never);
- }
- };
+ const { status, user } = useAuthStore();
const handleLoginPress = () => {
router.push('/auth/login' as never);
};
- const handleLogoutPress = () => {
- Alert.alert(
- '로그아웃할까요?',
- '이 기기에서 Soundlog 계정 연결을 종료합니다. 저장된 리캡과 로그는 삭제되지 않아요.',
- [
- { style: 'cancel', text: '취소' },
- {
- onPress: () => void handleLogout(),
- text: '로그아웃',
- },
- ],
- );
- };
-
- const handleDeleteAccount = () => {
- Alert.alert(
- 'Soundlog 계정을 삭제할까요?',
- '계정과 서버에 저장된 여행 기록, 리캡, 보관함 데이터가 즉시 삭제됩니다. 이 작업은 되돌릴 수 없어요.',
- [
- { style: 'cancel', text: '취소' },
- {
- onPress: () => {
- setAccountMessage(undefined);
- void deleteAccountMutation
- .mutateAsync()
- .then(() => {
- clearAccountSession();
- router.replace('/auth/login' as never);
- })
- .catch(() => {
- setAccountMessage(
- '계정을 삭제하지 못했어요. 네트워크 상태를 확인한 뒤 다시 시도해주세요.',
- );
- });
- },
- style: 'destructive',
- text: '계정 삭제',
- },
- ],
- );
- };
-
if (status === 'authenticated' && user) {
const accountInitial = getAccountInitial(user.displayName, user.email);
@@ -91,34 +34,12 @@ export function AuthAccountCard() {
{user.displayName}
-
+
{user.email ?? 'Soundlog 계정'}
로그인됨
-
-
-
-
- {accountMessage ? (
-
- {accountMessage}
-
- ) : null}
);
}
diff --git a/src/components/my/PermissionSettingsCard.tsx b/src/components/my/PermissionSettingsCard.tsx
index b07c983..ed26e63 100644
--- a/src/components/my/PermissionSettingsCard.tsx
+++ b/src/components/my/PermissionSettingsCard.tsx
@@ -61,7 +61,7 @@ export function PermissionSettingsCard({
) : null}
{isUnavailable ? (
-
+
웹에서는 실제 기기 권한을 확인할 수 없어요. 모바일 앱에서
확인해주세요.
diff --git a/src/components/my/PermissionStatusRow.tsx b/src/components/my/PermissionStatusRow.tsx
index e1ce3c2..22cad18 100644
--- a/src/components/my/PermissionStatusRow.tsx
+++ b/src/components/my/PermissionStatusRow.tsx
@@ -122,7 +122,7 @@ export function PermissionStatusRow({
) : action ? (
{action.label}
diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx
index b670ebf..fc246cc 100644
--- a/src/components/navigation/BottomNavigation.tsx
+++ b/src/components/navigation/BottomNavigation.tsx
@@ -63,7 +63,7 @@ export function BottomNavigation() {
tabBarActiveTintColor: colors.accent.lime,
tabBarInactiveTintColor: colors.text.muted,
tabBarLabelStyle: {
- fontSize: 11,
+ fontSize: 12,
fontWeight: "700",
marginTop: 2,
},
diff --git a/src/components/playlist/PlaylistHeroInfo.tsx b/src/components/playlist/PlaylistHeroInfo.tsx
index caf5185..c7495a4 100644
--- a/src/components/playlist/PlaylistHeroInfo.tsx
+++ b/src/components/playlist/PlaylistHeroInfo.tsx
@@ -98,7 +98,7 @@ export function PlaylistHeroInfo({
onPress={onOpenFirstTrack}
style={{ opacity: disabled ? 0.45 : 1 }}
>
-
+
diff --git a/src/components/playlist/PlaylistMusicFilter.tsx b/src/components/playlist/PlaylistMusicFilter.tsx
index 7a8dc46..121d068 100644
--- a/src/components/playlist/PlaylistMusicFilter.tsx
+++ b/src/components/playlist/PlaylistMusicFilter.tsx
@@ -34,7 +34,7 @@ export function PlaylistMusicFilter({
key={filter}
onPress={() => onSelectMoodFilter(filter)}
style={{
- backgroundColor: isSelected ? '#4A1D96' : 'rgba(255, 255, 255, 0.09)',
+ backgroundColor: isSelected ? '#214A73' : 'rgba(255, 255, 255, 0.09)',
borderColor: isSelected ? '#B7E628' : 'rgba(255, 255, 255, 0.22)',
}}
>
diff --git a/src/components/recap-share/RecapMusicSummary.tsx b/src/components/recap-share/RecapMusicSummary.tsx
index 0e0b34c..ebd8346 100644
--- a/src/components/recap-share/RecapMusicSummary.tsx
+++ b/src/components/recap-share/RecapMusicSummary.tsx
@@ -131,7 +131,7 @@ export function RecapMusicSummary({
className="h-10 w-10 items-center justify-center rounded-full bg-soundlog-lime"
onPress={onOpenMap}
>
-
+
) : null
}
diff --git a/src/components/recap-share/RecapTemplateSelector.tsx b/src/components/recap-share/RecapTemplateSelector.tsx
index 8f9eac7..bc863cc 100644
--- a/src/components/recap-share/RecapTemplateSelector.tsx
+++ b/src/components/recap-share/RecapTemplateSelector.tsx
@@ -39,7 +39,7 @@ export function RecapTemplateSelector({
onPress={() => onSelect(option.id)}
>
diff --git a/src/components/recap-share/ShareActionList.tsx b/src/components/recap-share/ShareActionList.tsx
index 36a2c26..2dc56c7 100644
--- a/src/components/recap-share/ShareActionList.tsx
+++ b/src/components/recap-share/ShareActionList.tsx
@@ -12,7 +12,7 @@ const actions: Array<{
textColor: string;
}> = [
{
- backgroundColor: '#4A1D96',
+ backgroundColor: '#315F9F',
icon: 'download',
iconColor: '#FFFFFF',
id: 'save',
diff --git a/src/components/recap/RecapListCard.tsx b/src/components/recap/RecapListCard.tsx
index e1405ff..65f6554 100644
--- a/src/components/recap/RecapListCard.tsx
+++ b/src/components/recap/RecapListCard.tsx
@@ -60,7 +60,7 @@ export function RecapListCard({ imageUrl, item, onPress }: RecapListCardProps) {
-
+
diff --git a/src/components/travel/EndTravelConfirmModal.tsx b/src/components/travel/EndTravelConfirmModal.tsx
index c27440c..54970a3 100644
--- a/src/components/travel/EndTravelConfirmModal.tsx
+++ b/src/components/travel/EndTravelConfirmModal.tsx
@@ -28,7 +28,7 @@ export function EndTravelConfirmModal({
-
+
diff --git a/src/components/travel/RecapCard.tsx b/src/components/travel/RecapCard.tsx
index cac2fe1..0cfcc33 100644
--- a/src/components/travel/RecapCard.tsx
+++ b/src/components/travel/RecapCard.tsx
@@ -55,7 +55,7 @@ export function RecapCard({ item, onPress }: RecapCardProps) {
-
+
diff --git a/src/components/travel/TravelModeBottomSheet.tsx b/src/components/travel/TravelModeBottomSheet.tsx
index b144ccb..7feed57 100644
--- a/src/components/travel/TravelModeBottomSheet.tsx
+++ b/src/components/travel/TravelModeBottomSheet.tsx
@@ -1,5 +1,5 @@
import { Feather } from '@expo/vector-icons';
-import { Animated, Modal, Pressable, View } from 'react-native';
+import { Animated, Modal, Pressable, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppText } from '@/components/AppText';
@@ -26,15 +26,26 @@ export function TravelModeBottomSheet({
visible,
}: TravelModeBottomSheetProps) {
const insets = useSafeAreaInsets();
- const { panHandlers, translateY } = useDismissibleBottomSheetGesture({
- onDismiss: onClose,
- visible,
- });
+ const { backdropOpacity, dismiss, panHandlers, translateY } =
+ useDismissibleBottomSheetGesture({
+ onDismiss: onClose,
+ visible,
+ });
return (
-
-
-
+
+
+
+
+
diff --git a/src/components/travel/TravelStatusCard.tsx b/src/components/travel/TravelStatusCard.tsx
index ad05130..c855d03 100644
--- a/src/components/travel/TravelStatusCard.tsx
+++ b/src/components/travel/TravelStatusCard.tsx
@@ -179,7 +179,7 @@ export function TravelStatusCard({
return (
-
+
여행을 시작해보세요
diff --git a/src/components/travel/live-sound-map/LiveSoundMapSection.tsx b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx
index c6c795b..72fa522 100644
--- a/src/components/travel/live-sound-map/LiveSoundMapSection.tsx
+++ b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx
@@ -808,7 +808,7 @@ export function LiveSoundMapSection({
-
+
diff --git a/src/components/travel/live-sound-map/SoundMapView.tsx b/src/components/travel/live-sound-map/SoundMapView.tsx
index 0d4daf3..0752a02 100644
--- a/src/components/travel/live-sound-map/SoundMapView.tsx
+++ b/src/components/travel/live-sound-map/SoundMapView.tsx
@@ -51,7 +51,7 @@ const markerPalette: Record<
> = {
cluster: {
background: '#251A4A',
- border: '#8B72FF',
+ border: '#315F9F',
icon: '#F2EEFF',
labelBackground: 'rgba(139,114,255,0.22)',
labelText: '#F2EEFF',
@@ -65,8 +65,8 @@ const markerPalette: Record<
},
me: {
background: '#B7E628',
- border: '#4A1D96',
- icon: '#4A1D96',
+ border: '#6EA8FF',
+ icon: '#6EA8FF',
labelBackground: 'rgba(9,5,21,0.18)',
labelText: '#FFFFFF',
},
diff --git a/src/components/travel/recap-map/RecapMapSection.tsx b/src/components/travel/recap-map/RecapMapSection.tsx
index ff32c67..7d31a2b 100644
--- a/src/components/travel/recap-map/RecapMapSection.tsx
+++ b/src/components/travel/recap-map/RecapMapSection.tsx
@@ -168,13 +168,13 @@ function getMapLegendItems(filter: RecapMapFilter) {
if (filter === 'mine') {
return [
{ color: '#B7E628', label: '내 리캡' },
- { color: '#8B72FF', label: '리캡 묶음' },
+ { color: '#315F9F', label: '리캡 묶음' },
];
}
return [
{ color: '#FF8A3D', label: '공개 리캡' },
- { color: '#8B72FF', label: '리캡 묶음' },
+ { color: '#315F9F', label: '리캡 묶음' },
];
}
@@ -548,7 +548,7 @@ export function RecapMapSection({
@@ -664,7 +664,7 @@ export function RecapMapSection({
diff --git a/src/constants/colors.ts b/src/constants/colors.ts
index 6700b70..936f922 100644
--- a/src/constants/colors.ts
+++ b/src/constants/colors.ts
@@ -5,7 +5,7 @@ export const colors = {
background: {
primary: '#070B1F',
secondary: '#0B102A',
- deepPurple: '#170738',
+ deepNavy: '#101A38',
gradient: ['#070B1F', '#070B1F', '#070B1F', '#070B1F', '#070B1F'],
aurora: [
'rgba(7,11,31,0)',
@@ -19,7 +19,7 @@ export const colors = {
card: '#080D18',
cardElevated: '#090E1B',
chip: '#171B2A',
- chipSelected: '#4A1D96',
+ chipSelected: '#214A73',
player: '#45343D',
tab: 'rgba(10, 16, 30, 0.78)',
glass: 'rgba(255,255,255,0.1)',
@@ -31,11 +31,10 @@ export const colors = {
},
accent: {
blue: '#6EA8FF',
- purple: '#7A2CFF',
gold: '#B1913A',
lime: '#B7E628',
warning: '#FF8A3D',
- action: '#4A1D96',
+ action: '#315F9F',
danger: '#8E310D',
},
text: {
diff --git a/src/design-system/tokens.ts b/src/design-system/tokens.ts
index d84ef1e..3e73a0b 100644
--- a/src/design-system/tokens.ts
+++ b/src/design-system/tokens.ts
@@ -25,7 +25,6 @@ export const soundlogDesignTokens = {
},
accent: {
blue: colors.accent.blue,
- purple: colors.accent.purple,
gold: colors.accent.gold,
lime: colors.accent.lime,
warning: colors.accent.warning,
diff --git a/src/hooks/useBottomSheetGesture.ts b/src/hooks/useBottomSheetGesture.ts
index d0758cd..e22dcc1 100644
--- a/src/hooks/useBottomSheetGesture.ts
+++ b/src/hooks/useBottomSheetGesture.ts
@@ -26,13 +26,43 @@ export function useDismissibleBottomSheetGesture({
onDismiss,
visible = true,
}: DismissibleBottomSheetGestureParams) {
- const translateY = useRef(new Animated.Value(0)).current;
+ const backdropOpacity = useRef(new Animated.Value(0)).current;
+ const translateY = useRef(new Animated.Value(360)).current;
useEffect(() => {
- if (visible) {
- translateY.setValue(0);
+ if (!visible) {
+ backdropOpacity.stopAnimation();
+ translateY.stopAnimation();
+ backdropOpacity.setValue(0);
+ translateY.setValue(360);
+ return;
}
- }, [translateY, visible]);
+
+ backdropOpacity.stopAnimation();
+ translateY.stopAnimation();
+ backdropOpacity.setValue(0);
+ translateY.setValue(360);
+
+ const animationFrame = requestAnimationFrame(() => {
+ Animated.parallel([
+ Animated.timing(backdropOpacity, {
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ toValue: 1,
+ useNativeDriver: true,
+ }),
+ Animated.spring(translateY, {
+ damping: 24,
+ mass: 0.82,
+ stiffness: 230,
+ toValue: 0,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ });
+
+ return () => cancelAnimationFrame(animationFrame);
+ }, [backdropOpacity, translateY, visible]);
const restore = useCallback(() => {
Animated.spring(translateY, {
@@ -44,18 +74,27 @@ export function useDismissibleBottomSheetGesture({
}, [translateY]);
const dismiss = useCallback(() => {
- Animated.timing(translateY, {
- duration: 170,
- easing: Easing.out(Easing.cubic),
- toValue: 360,
- useNativeDriver: true,
- }).start(({ finished }) => {
+ Animated.parallel([
+ Animated.timing(backdropOpacity, {
+ duration: 150,
+ easing: Easing.in(Easing.quad),
+ toValue: 0,
+ useNativeDriver: true,
+ }),
+ Animated.timing(translateY, {
+ duration: 180,
+ easing: Easing.out(Easing.cubic),
+ toValue: 360,
+ useNativeDriver: true,
+ }),
+ ]).start(({ finished }) => {
if (finished) {
onDismiss();
- translateY.setValue(0);
+ backdropOpacity.setValue(0);
+ translateY.setValue(360);
}
});
- }, [onDismiss, translateY]);
+ }, [backdropOpacity, onDismiss, translateY]);
const panResponder = useMemo(
() =>
@@ -86,6 +125,8 @@ export function useDismissibleBottomSheetGesture({
);
return {
+ backdropOpacity,
+ dismiss,
panHandlers: panResponder.panHandlers,
translateY,
};
diff --git a/src/utils/trackVisuals.ts b/src/utils/trackVisuals.ts
index 591d9f1..7a12ea8 100644
--- a/src/utils/trackVisuals.ts
+++ b/src/utils/trackVisuals.ts
@@ -5,7 +5,7 @@ const fallbackPalette = [
'#20146F',
'#2D6A72',
'#45536B',
- '#7A2CFF',
+ '#6EA8FF',
'#1D7F8C',
];
diff --git a/tailwind.config.js b/tailwind.config.js
index b75a9f3..f915b9b 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -12,16 +12,15 @@ module.exports = {
card: '#080D18',
elevated: '#090E1B',
chip: '#171B2A',
- selected: '#4A1D96',
+ selected: '#214A73',
border: '#364283',
focus: '#B7E628',
blue: '#6EA8FF',
- purple: '#7A2CFF',
gold: '#B1913A',
lime: '#B7E628',
player: '#45343D',
warning: '#FF8A3D',
- action: '#4A1D96',
+ action: '#315F9F',
danger: '#8E310D',
inverse: '#FFFFFF',
},