diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml
index 7b6aa62..40c6111 100644
--- a/app/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -3,10 +3,11 @@
-
-
+ -
-
-->
+ android:src="@drawable/launch_logo" />
+
diff --git a/app/android/app/src/main/res/drawable-xxhdpi/launch_logo.png b/app/android/app/src/main/res/drawable-xxhdpi/launch_logo.png
new file mode 100644
index 0000000..f2ab5ce
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxhdpi/launch_logo.png differ
diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml
index 7b6aa62..40c6111 100644
--- a/app/android/app/src/main/res/drawable/launch_background.xml
+++ b/app/android/app/src/main/res/drawable/launch_background.xml
@@ -3,10 +3,11 @@
-
-
+ -
-
-->
+ android:src="@drawable/launch_logo" />
+
diff --git a/app/assets/brand/fanuzu-mark.png b/app/assets/brand/fanuzu-mark.png
new file mode 100644
index 0000000..f2ab5ce
Binary files /dev/null and b/app/assets/brand/fanuzu-mark.png differ
diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules
index 3f48afb..7bf3afd 100644
--- a/app/firebase/firestore.rules
+++ b/app/firebase/firestore.rules
@@ -309,6 +309,13 @@ service cloud.firestore {
// 타인 배지가 실제로 보이는 곳은 S-M-HERO 히어로 랭킹 하나뿐이고,
// 그것은 /fandoms/{f}/members/{uid}.honorBadgeIds 비정규화 필드로 읽는다(설계서 §2.3·§4.4).
// 랭킹 하나 보여주려고 타인 문서를 통째로 여는 경로를 닫는다.
+ // 성운 일 1회 기록(`fn-nebula-create` 가 쓴다). GALAXY 「성운 생성」 버튼이 오늘 이미 만들었는지
+ // 보고 비활성화 · 초과 안내를 띄운다(Figma `540:19592`). 쓰기는 서버만.
+ match /users/{uid}/nebulaCreates/{day} {
+ allow read: if isSelf(uid);
+ allow write: if false;
+ }
+
match /users/{uid}/badges/{badgeId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
diff --git a/app/firebase/functions/src/admin/admin.ts b/app/firebase/functions/src/admin/admin.ts
index 4e62d52..43372f2 100644
--- a/app/firebase/functions/src/admin/admin.ts
+++ b/app/firebase/functions/src/admin/admin.ts
@@ -105,6 +105,9 @@ export const fnAdminWrite = onCall(async (req) => {
await ref.set({
...(patch as Record),
+ // 처음 쓰는 문서에는 생성 시각을 남긴다 — 퀘스트 목록 *퀘스트 생성 최신순 소팅*(Figma `518:88395`)의
+ // 정렬 키. 예전 문서는 `updatedAt` 만 있어 수정할 때마다 순서가 바뀌었다.
+ ...(before.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
diff --git a/app/firebase/functions/test/admin_write.test.ts b/app/firebase/functions/test/admin_write.test.ts
index 0f9c452..8111473 100644
--- a/app/firebase/functions/test/admin_write.test.ts
+++ b/app/firebase/functions/test/admin_write.test.ts
@@ -67,3 +67,13 @@ it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못
expect(doc.get('fpiScore')).toBeUndefined();
await getFirestore().doc('fandoms/admin-write-test').delete();
});
+
+// 퀘스트 목록 *퀘스트 생성 최신순*(Figma `518:88395`) — 처음 쓴 문서만 생성 시각을 받고, 수정은 그대로 둔다.
+it('처음 쓰는 문서에는 createdAt 을 찍고 다시 써도 바꾸지 않는다', async () => {
+ const ref = getFirestore().doc('notices/admin-write-test');
+ await run('ops', ref.path, { title: '첫' });
+ const first = (await ref.get()).get('createdAt');
+ expect(first).toBeDefined();
+ await run('ops', ref.path, { title: '둘' });
+ expect((await ref.get()).get('createdAt').isEqual(first)).toBe(true);
+});
diff --git a/app/firebase/test/tests/firestore.rules.test.mjs b/app/firebase/test/tests/firestore.rules.test.mjs
index e70c18f..e441bd2 100644
--- a/app/firebase/test/tests/firestore.rules.test.mjs
+++ b/app/firebase/test/tests/firestore.rules.test.mjs
@@ -1772,3 +1772,15 @@ describe('테스트 자가 점검', () => {
expect(testEnvironment.projectId).toMatch(/^demo-/);
});
});
+
+describe('성운 일 1회 기록 — GALAXY 「성운 생성」 버튼(Figma 540:19592)', () => {
+ test('본인 기록은 읽고, 남의 기록은 못 읽고, 아무도 쓰지 못한다', async () => {
+ await seedDocuments(testEnvironment, {
+ 'users/alice/nebulaCreates/2026-10-06': { fandomId: 'f1' },
+ });
+ await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-06')));
+ await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-07')));
+ await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/nebulaCreates/2026-10-06')));
+ await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-08'), { fandomId: 'f2' }));
+ });
+});
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
index 9da19ea..617fe81 100644
Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
index 9da19ea..7416d6c 100644
Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
index 9da19ea..f2ab5ce 100644
Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard
index 69f7b93..d415e51 100644
--- a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard
+++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -33,6 +33,6 @@
-
+
diff --git a/app/lib/app.dart b/app/lib/app.dart
index 5ace112..1934c37 100644
--- a/app/lib/app.dart
+++ b/app/lib/app.dart
@@ -4,11 +4,14 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'core/config/feature_flags.dart' show isOlderVersion;
import 'core/data/repositories.dart';
import 'core/i18n/app_locale.dart';
import 'core/ui/adaptive_planet_effects.dart';
import 'core/push/push_messaging.dart';
+import 'core/ui/app_dialog.dart';
import 'core/router/app_router.dart';
import 'features/home/home_screen.dart' show homeRefreshSignal;
import 'core/theme/app_theme.dart';
@@ -123,9 +126,42 @@ class _FancakeAppState extends State with WidgetsBindingObserver {
}
}
+ /// 업데이트 확인을 마쳤는가 — 앱 실행당 한 번만 안내한다.
+ bool _updateChecked = false;
+ bool _updateChecking = false;
+
+ /// 스플래시 업데이트 확인 — Figma `510:19053` 「앱 버전 정보 · 최신 버전 여부 확인 ·
+ /// 업데이트 안내 노출」. 최신 버전은 운영값 `config/features.latestAppVersion` 이고,
+ /// 값이 없거나 설치 버전을 못 읽으면 안내하지 않는다.
+ Future _checkUpdate() async {
+ final latest = _repos.flags.latestVersion;
+ if (_updateChecked || _updateChecking || latest == null) return;
+ _updateChecking = true;
+ try {
+ final installed = (await PackageInfo.fromPlatform()).version;
+ _updateChecked = true;
+ if (!isOlderVersion(installed, latest)) return;
+ final context = _router.routerDelegate.navigatorKey.currentContext;
+ if (context == null || !context.mounted) {
+ _updateChecked = false; // 화면이 아직 없으면 다음 알림 때 다시 본다.
+ return;
+ }
+ await showAlertDialog(
+ context,
+ context.l10n.updateAvailableTitle,
+ body: context.l10n.updateAvailableBody(latest),
+ );
+ } catch (e) {
+ debugPrint('[FancakeApp] update check: ${e.runtimeType}');
+ } finally {
+ _updateChecking = false;
+ }
+ }
+
@override
void initState() {
super.initState();
+ _repos.flags.addListener(_checkUpdate);
WidgetsBinding.instance.addObserver(this);
widget.auth.addListener(_onAuthChanged);
_onAuthChanged();
@@ -133,6 +169,7 @@ class _FancakeAppState extends State with WidgetsBindingObserver {
@override
void dispose() {
+ _repos.flags.removeListener(_checkUpdate);
WidgetsBinding.instance.removeObserver(this);
_presence?.cancel();
widget.auth.removeListener(_onAuthChanged);
diff --git a/app/lib/core/config/feature_flags.dart b/app/lib/core/config/feature_flags.dart
index 2d49b98..31ef911 100644
--- a/app/lib/core/config/feature_flags.dart
+++ b/app/lib/core/config/feature_flags.dart
@@ -27,9 +27,18 @@ class FeatureFlags extends ChangeNotifier {
/// STATION 탭 노출 스위치. 문서의 `stationEnabled`(bool) 하나를 읽는다.
static const String stationKey = 'stationEnabled';
+ /// 운영 중인 최신 앱 버전(문자열 `x.y.z`). 스플래시 업데이트 확인 — Figma `510:19053`
+ /// 「앱 버전 정보 · 최신 버전 여부 확인 · 업데이트 안내 노출」. 로그인 전에 읽어야 해서
+ /// 같은 공개 문서에 둔다. 값이 없으면 안내를 띄우지 않는다.
+ static const String latestVersionKey = 'latestAppVersion';
+
StreamSubscription