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>? _subscription; bool _stationTab = false; + String? _latestVersion; + + /// 운영 최신 버전. 문서에 없거나 문자열이 아니면 null. + String? get latestVersion => _latestVersion; /// 하단 탭에 STATION 을 그릴 것인가. 기본 false. bool get stationTab => _stationTab; @@ -37,8 +46,13 @@ class FeatureFlags extends ChangeNotifier { void _apply(Map data) { // 타입까지 확인한다. 누가 문자열 "true" 를 넣어도 켜지지 않는다. final next = data[stationKey] == true; - if (next == _stationTab) return; + final latest = switch (data[latestVersionKey]) { + final String v when v.trim().isNotEmpty => v.trim(), + _ => null, + }; + if (next == _stationTab && latest == _latestVersion) return; _stationTab = next; + _latestVersion = latest; notifyListeners(); } @@ -54,3 +68,21 @@ class FeatureFlags extends ChangeNotifier { super.dispose(); } } + +/// [installed] 가 [latest] 보다 낮은가 — 점으로 나눈 숫자를 앞에서부터 비교한다(`0.5.0` < `0.10.0`). +/// 빌드 번호(`+7`)와 숫자가 아닌 꼬리는 무시한다. 읽을 수 없는 값이면 false(안내하지 않음). +bool isOlderVersion(String installed, String latest) { + List? parse(String v) { + final parts = v.split('+').first.split('.'); + final nums = [for (final p in parts) int.tryParse(p.trim())]; + return nums.any((n) => n == null) ? null : nums.cast(); + } + + final a = parse(installed), b = parse(latest); + if (a == null || b == null) return false; + for (var i = 0; i < a.length || i < b.length; i++) { + final x = i < a.length ? a[i] : 0, y = i < b.length ? b[i] : 0; + if (x != y) return x < y; + } + return false; +} diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index e34c291..3ea7d2f 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -263,12 +263,21 @@ class AppShell extends StatelessWidget { selectedIndex: selected < 0 ? 0 : selected, onDestinationSelected: (i) { // 다른 탭에서 HOME 으로 돌아오면 다시 읽는다(HOME 은 탭 전환으로 다시 만들어지지 않는다). - if (branches[i] == 0 && shell.currentIndex != 0) homeRefreshSignal.value++; + if (branches[i] == 0 && shell.currentIndex != 0) + homeRefreshSignal.value++; + final again = branches[i] == shell.currentIndex; shell.goBranch( branches[i], // initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다. - initialLocation: branches[i] == shell.currentIndex, + initialLocation: again, ); + // 설명 프레임 `2097:16427` 「HOME 화면에서 클릭 시, Y스크롤값 초기화」(INSIDE · GALAXY · + // MY 동일) — 열려 있는 탭을 다시 누르면 그 탭 루트 화면을 맨 위로 올린다. + if (again) { + scrollTabToTop( + shell.route.branches[branches[i]].navigatorKey, + ); + } }, destinations: [ for (final tab in tabs) @@ -287,3 +296,35 @@ class AppShell extends StatelessWidget { }, ); } + +/// 탭 루트 화면의 세로 목록을 맨 위로. 루트 라우트의 [PrimaryScrollController] 를 쓴다 — +/// 각 탭 화면이 따로 컨트롤러를 들고 있지 않아도 된다. 다시 누름으로 위에 쌓인 화면이 닫힌 +/// 다음 프레임에 움직인다. +void scrollTabToTop(GlobalKey navigatorKey) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScrollController? found; + void visit(Element e) { + if (found != null) return; + final w = e.widget; + // 컨트롤러 없는 칸(`PrimaryScrollController.none`)과 붙은 목록이 없는 라우트는 건너뛴다. + if (w is PrimaryScrollController && (w.controller?.hasClients ?? false)) { + found = w.controller; + return; + } + e.visitChildElements(visit); + } + + navigatorKey.currentContext?.visitChildElements(visit); + for (final p in found?.positions.toList() ?? const []) { + if (p.axis == Axis.vertical && p.pixels != p.minScrollExtent) { + p.animateTo( + p.minScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + }); + // 탭이 이미 루트면 다시 그릴 일이 없어 위 콜백이 돌지 않는다 — 한 프레임을 청한다. + WidgetsBinding.instance.ensureVisualUpdate(); +} diff --git a/app/lib/core/ui/app_dialog.dart b/app/lib/core/ui/app_dialog.dart index ce98958..22cff03 100644 --- a/app/lib/core/ui/app_dialog.dart +++ b/app/lib/core/ui/app_dialog.dart @@ -176,3 +176,17 @@ Future showAlertDialog( confirmLabel: confirmLabel ?? context.l10n.commonConfirm, ), ); + +/// 아래에서 올라오고 닫으면 아래로 내려가는 전체 화면 — Figma 설명 「닫기 버튼 클릭 시, 하단 +/// move out 페이지 종료」(`516:87382` 성운 생성 · `516:87457` 행성 생성 · `516:88164` 이클립스 상세). +/// 안드로이드 기본 전환(확대)과 iOS 기본 전환(옆으로)이 모두 이 동작이 아니라 따로 둔다. +Route slideUpRoute(WidgetBuilder builder) => PageRouteBuilder( + pageBuilder: (context, _, _) => builder(context), + transitionsBuilder: (_, animation, _, child) => SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)).animate(animation), + child: child, + ), +); diff --git a/app/lib/core/ui/sheet_route.dart b/app/lib/core/ui/sheet_route.dart new file mode 100644 index 0000000..c506378 --- /dev/null +++ b/app/lib/core/ui/sheet_route.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +/// 아래에서 올라와 아래로 닫히는 전체 화면 — Figma 설명 프레임의 「닫기 버튼 클릭 시, 하단 move out +/// 페이지 종료 후 이전 화면 이동」(퀘스트 생성 `523:18123` · 위치 선택 `523:18207` · 신고하기 `548:21389` +/// · 이클립스 상세 `516:88158`). +/// +/// `MaterialPageRoute(fullscreenDialog: true)` 는 iOS 에서만 아래로 내려간다(Android 는 기본 전환). +/// 두 플랫폼 모두 같은 방향으로 움직이게 전환만 바꾼다. +class SheetRoute extends MaterialPageRoute { + SheetRoute({required super.builder}) : super(fullscreenDialog: true); + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) => SlideTransition( + position: animation.drive( + Tween(begin: const Offset(0, 1), end: Offset.zero) + .chain(CurveTween(curve: Curves.easeOutCubic)), + ), + child: child, + ); +} diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart index b9df86a..763b48f 100644 --- a/app/lib/features/auth/auth.dart +++ b/app/lib/features/auth/auth.dart @@ -138,6 +138,9 @@ abstract interface class AuthSession { /// **제공자가 인증한 이메일 전체**를 정규화(trim·소문자)해 비교한다. /// 로컬 파트만 보면 `test123@naver.com` 과 `test123@gmail.com` 처럼 남의 계정이 /// 붙는다. 비었거나 `@` 가 없는 값은 이메일로 보지 않아 false. +/// +/// Figma 예시(앞부분 같으면 O)는 **2026-09-16 회신 3절 7항**(「다른 도메인 이메일의 앞부분만으로 +/// 동일 소유자를 판단하지 않습니다」)이 대체했다. 이메일을 주지 않는 제공자의 연결은 해석 확인 Q-E8. bool sameAccountIdentity(String a, String b) { final x = a.trim().toLowerCase(); return x.contains('@') && x == b.trim().toLowerCase(); diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index d8efba6..40e7068 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -102,6 +102,8 @@ class _LoginScreenState extends State { void _open({required bool signUp}) { Navigator.of(context).push( MaterialPageRoute( + // 명세 `510:86374` 「닫기 — 아래 방향 Move Out 으로 이전 화면」 — 아래에서 올라오는 전체 화면. + fullscreenDialog: true, builder: (_) => SimpleLoginScreen(auth: widget.auth, signUp: signUp), ), ); @@ -114,20 +116,20 @@ class _LoginScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 시안 173:1905 — 상단 386 영역 가운데에 핑크 원(120) 하나. 로고 원본 미확정(Q6). + // 시안 173:1905 — 상단 386 영역 가운데 로고(120). 명세 `510:86330` 「컨테인 중앙 정렬」. + // 로고는 발주사 인수 자산 `fanuzu logo only.png` 의 마크(공통 기준 4항 FANUZU). SizedBox( height: 386, child: Center( child: GestureDetector( key: loginLogoKey, onLongPress: _toggleDevPanel, - child: Container( + child: Image.asset( + 'assets/brand/fanuzu-mark.png', width: 120, height: 120, - decoration: const BoxDecoration( - color: AppColors.cosmicAccent, - shape: BoxShape.circle, - ), + fit: BoxFit.contain, + semanticLabel: 'FANUZU', ), ), ), @@ -247,6 +249,7 @@ class _SimpleLoginScreenState extends State { void _switchMode() { Navigator.of(context).pushReplacement( MaterialPageRoute( + fullscreenDialog: true, builder: (_) => SimpleLoginScreen(auth: widget.auth, signUp: !widget.signUp), ), diff --git a/app/lib/features/auth/onboarding_screens.dart b/app/lib/features/auth/onboarding_screens.dart index 9d77093..249bb21 100644 --- a/app/lib/features/auth/onboarding_screens.dart +++ b/app/lib/features/auth/onboarding_screens.dart @@ -4,11 +4,13 @@ import '../../core/data/backend.dart' show BackendException; import '../../core/i18n/app_locale.dart'; import '../../core/push/push_messaging.dart'; import '../../core/theme/tokens.dart'; +import '../../core/ui/app_icons.dart'; import '../home/coach_mark.dart'; import '../home/home_data.dart'; import '../home/nebula_create_screen.dart'; import '../my/my_data.dart' show nicknameMaxLength, nicknameMinLength, nicknameReserved; +import '../planet/planet_data.dart' show countryName; import 'auth_repository.dart'; import 'onboarding_data.dart'; import 'signup_screens.dart'; @@ -159,6 +161,9 @@ class _OnboardingFlowState extends State { _Step _step = _Step.terms; bool _busy = false; + /// ② 에서 서버에 기록한 동의 항목. ③ 에서 뒤로 오면 이 상태로 체크해 보여 준다. + Set? _agreed; + /// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(Primary). final _picked = []; @@ -189,14 +194,25 @@ class _OnboardingFlowState extends State { void _agree(Set agreed) { if (_busy) return; + // ③ 에서 뒤로 왔다가 그대로 「완료」면 기록을 다시 남기지 않는다(서버 동의 기록 유지). + final prev = _agreed; + if (prev != null && + prev.length == agreed.length && + prev.containsAll(agreed)) { + setState(() => _step = _Step.profile); + return; + } _run(() async { await widget.repository.agree(agreed, signupTermsVersion); + _agreed = agreed; if (mounted) setState(() => _step = _Step.profile); }); } void _back() => setState( () => _step = switch (_step) { + // 명세 `511:86825` 「상단 뒤로가기 — 이전 화면」. 이전 = ② 약관(동의 체크 유지). + _Step.profile => _Step.terms, _Step.artist => _Step.profile, _Step.planet => _Step.artist, final s => s, @@ -216,9 +232,15 @@ class _OnboardingFlowState extends State { @override Widget build(BuildContext context) { final body = switch (_step) { - _Step.terms => TermsScreen(onAgreed: _agree, onBack: widget.auth.signOut), + _Step.terms => TermsScreen( + key: ValueKey(_agreed), + initial: _agreed ?? const {}, + onAgreed: _agree, + onBack: widget.auth.signOut, + ), _Step.profile => _ProfileStep( repository: widget.repository, + onBack: _back, onDone: () => setState(() => _step = _Step.artist), ), _Step.artist => _ArtistStep( @@ -262,10 +284,15 @@ class _OnboardingFlowState extends State { /// 사본 `MY/프로필 설정` 구성을 따른다. 국가 기본값은 기기 지역(Figma `554:24126` 「앱 기동 단계에서 /// 조회된 국가/언어를 디폴트」). class _ProfileStep extends StatefulWidget { - const _ProfileStep({required this.repository, required this.onDone}); + const _ProfileStep({ + required this.repository, + required this.onDone, + required this.onBack, + }); final OnboardingRepository repository; final VoidCallback onDone; + final VoidCallback onBack; @override State<_ProfileStep> createState() => _ProfileStepState(); @@ -348,12 +375,23 @@ class _ProfileStepState extends State<_ProfileStep> { } } + Future _pickCountry() async { + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: const Color(0xFF101010), + builder: (_) => _CountrySheet(current: _country), + ); + if (picked != null && mounted) setState(() => _country = picked); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; final current = _locale ?? LocaleScope.maybeOf(context)?.locale; return _StepScaffold( title: l10n.onbProfileTitle, + onBack: widget.onBack, action: _PrimaryButton( label: l10n.commonNext, onPressed: _valid && !_busy ? _save : null, @@ -369,15 +407,20 @@ class _ProfileStepState extends State<_ProfileStep> { ), ), const SizedBox(height: AppSpace.s3), - DropdownButtonFormField( - initialValue: _country, - isExpanded: true, - decoration: InputDecoration(labelText: l10n.onbCountry), - items: [ - for (final c in isoCountries) - DropdownMenuItem(value: c, child: Text('${countryFlag(c)} $c')), - ], - onChanged: (v) => setState(() => _country = v ?? _country), + // 사본 국가 리스트 모달 `178:23489`(명세 `511:86786` · `511:86796`) — 국기 · 국가명 · + // 단수 선택 체크, 「선택」을 눌러야 적용하고 닫힌다. + InkWell( + onTap: _pickCountry, + child: InputDecorator( + decoration: InputDecoration( + labelText: l10n.onbCountry, + suffixIcon: const Icon(Icons.expand_more), + ), + isEmpty: _country == null, + child: _country == null + ? null + : Text('${countryFlag(_country!)} ${countryName(_country!)}'), + ), ), const SizedBox(height: AppSpace.s3), DropdownButtonFormField( @@ -407,6 +450,78 @@ class _ProfileStepState extends State<_ProfileStep> { } } +/// 국가 리스트 모달 — 사본 `178:23489`. +/// +/// 명세 `511:86762` 타이틀 좌측 · 닫기 우측(누르면 모달 종료) / `511:86786` 국기 좌측 · 국가명 · +/// 선택한 국가에만 오른쪽 체크(단수) / `511:86796` 국가를 골라야 「선택」이 켜지고, 누르면 적용 후 닫힘. +class _CountrySheet extends StatefulWidget { + const _CountrySheet({required this.current}); + + final String? current; + + @override + State<_CountrySheet> createState() => _CountrySheetState(); +} + +class _CountrySheetState extends State<_CountrySheet> { + late String? _picked = widget.current; + + @override + Widget build(BuildContext context) => SafeArea( + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 4, 0), + child: Row( + children: [ + Expanded(child: Text(context.l10n.onbCountry, style: _title)), + IconButton( + tooltip: context.l10n.commonClose, + onPressed: () => Navigator.of(context).pop(), + icon: const ImageIcon(AppIcons.x, size: 24), + ), + ], + ), + ), + Expanded( + child: ListView( + children: [ + for (final c in isoCountries) + ListTile( + onTap: () => setState(() => _picked = c), + leading: Text( + countryFlag(c), + style: const TextStyle(fontSize: 22), + ), + title: Text(countryName(c)), + trailing: c == _picked + ? const Icon( + Icons.check_circle_outline, + color: AppColors.cosmicAccent, + ) + : null, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpace.s4), + child: _PrimaryButton( + label: context.l10n.commonSelect, + onPressed: _picked == null + ? null + : () => Navigator.of(context).pop(_picked), + ), + ), + ], + ), + ), + ); +} + /// ④ 관심 아티스트 / Primary Fandom — 최소 1개(FR-CMN-06). 처음 고른 팬덤이 대표가 된다. /// 찾는 아티스트가 없으면 ⑤ 에서 성운을 만든다. class _ArtistStep extends StatefulWidget { diff --git a/app/lib/features/auth/signup_screens.dart b/app/lib/features/auth/signup_screens.dart index e95672e..c18e45e 100644 --- a/app/lib/features/auth/signup_screens.dart +++ b/app/lib/features/auth/signup_screens.dart @@ -14,7 +14,15 @@ import '../../core/theme/tokens.dart'; /// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 [_body] 와 /// [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다). class TermsScreen extends StatefulWidget { - const TermsScreen({super.key, this.onAgreed, this.onBack}); + const TermsScreen({ + super.key, + this.onAgreed, + this.onBack, + this.initial = const {}, + }); + + /// 처음부터 체크해 둘 항목 키 — 온보딩 ③ 에서 뒤로 왔을 때 이미 동의한 항목. + final Set initial; /// 온보딩 안에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다. final ValueChanged>? onAgreed; @@ -58,7 +66,10 @@ const _body = const _stroke = Color(0xFF545454); class _TermsScreenState extends State { - final _agreed = <_Term>{}; + late final _agreed = <_Term>{ + for (final t in _Term.values) + if (widget.initial.contains(t.name)) t, + }; bool get _all => _agreed.length == _Term.values.length; bool get _ready => diff --git a/app/lib/features/eclipse/eclipse_defend_screen.dart b/app/lib/features/eclipse/eclipse_defend_screen.dart index 354499c..4c7e63b 100644 --- a/app/lib/features/eclipse/eclipse_defend_screen.dart +++ b/app/lib/features/eclipse/eclipse_defend_screen.dart @@ -14,6 +14,7 @@ import '../home/home_screen.dart' show formatPop; import '../quest/quest_evidence.dart' show ShakeProbe, deviceShakes; import 'eclipse_data.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/sheet_route.dart'; /// 「흔들어 방어」 — 팬덤 공동 Shake 참여 화면. /// @@ -35,10 +36,15 @@ class EclipseDefendScreen extends StatefulWidget { required this.repository, required this.fandomId, this.shakes = deviceShakes, + this.nebula = false, }); final EclipseRepository repository; + /// 대상 팬덤이 아직 성운인가 — 명세 `516:88158` *온보딩 행성/성운 - 이미지* · `516:88185` + /// *행성/성운 - 사용자 행성/성운 이미지*. 성운이면 성운 그림으로 그린다. + final bool nebula; + /// 흔들기 입력. 테스트는 가짜 스트림을 넣는다 — 방어 수단은 **Shake 하나**다(FX §5). final ShakeProbe shakes; @@ -326,8 +332,8 @@ class _EclipseDefendScreenState extends State ), ), const SizedBox(height: 24), - const Flexible( - child: SizedBox(height: 400, child: _ErodingPlanet()), + Flexible( + child: SizedBox(height: 400, child: _ErodingPlanet(image: _imageOf(widget.nebula))), ), const Spacer(), // `516:88184` 남은 시간 노출 영역 — 하단 정렬, 끝나면 방어 화면으로. @@ -435,7 +441,11 @@ class _EclipseDefendScreenState extends State Flexible( child: SizedBox( height: 400, - child: _DefendPlanet(remaining: remaining, pending: _pending), + child: _DefendPlanet( + remaining: remaining, + pending: _pending, + image: _imageOf(widget.nebula), + ), ), ), const Spacer(), @@ -620,6 +630,23 @@ class _EclipseTickerState extends State { } const _planet = AssetImage('assets/figma/planet-home.png'); +const _nebula = AssetImage('assets/figma/nebula-home.png'); + +AssetImage _imageOf(bool nebula) => nebula ? _nebula : _planet; + +/// 이클립스 상세로 가는 길 — 아래에서 올라와 아래로 닫힌다(명세 `516:88158` *하단 move out*). +/// HOME 버튼 · 배너(HOME · 행성 상세 · GALAXY)가 모두 이 경로를 쓴다. +Route eclipseDefendRoute({ + required EclipseRepository repository, + required String fandomId, + bool nebula = false, +}) => SheetRoute( + builder: (_) => EclipseDefendScreen( + repository: repository, + fandomId: fandomId, + nebula: nebula, + ), +); // 사본 좌표(프레임 원점 x10 · y220 기준). Widget _at(double x, double y, double w, double h, Widget child) => @@ -629,9 +656,11 @@ Widget _at(double x, double y, double w, double h, Widget child) => /// /// 행성(240) 양옆에 흐린 사본(`image 9~12`, 흐림 4)을 겹쳐 그림자가 번지는 모습이다. class _ErodingPlanet extends StatelessWidget { - const _ErodingPlanet(); + const _ErodingPlanet({required this.image}); - static Widget _shadow(double x, double y, double size, {double blur = 0}) => + final AssetImage image; + + Widget _shadow(double x, double y, double size, {double blur = 0}) => _at( x, y, @@ -639,9 +668,9 @@ class _ErodingPlanet extends StatelessWidget { size, ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), - child: const ColorFiltered( - colorFilter: ColorFilter.mode(Color(0xB3141414), BlendMode.srcATop), - child: Image(image: _planet), + child: ColorFiltered( + colorFilter: const ColorFilter.mode(Color(0xB3141414), BlendMode.srcATop), + child: Image(image: image), ), ), ); @@ -658,7 +687,7 @@ class _ErodingPlanet extends StatelessWidget { _shadow(27, 347, 192, blur: 2), _shadow(47, 320, 220), _shadow(108, 320, 220), - _at(67.5, 300, 240, 240, const Image(image: _planet)), + _at(67.5, 300, 240, 240, Image(image: image)), ], ), ), @@ -671,7 +700,13 @@ class _ErodingPlanet extends StatelessWidget { /// `Group 572` 그라디언트 원 · `516:88231`)는 잔여 시간 비율 [remaining] 이고, 운석은 /// 시간이 흐를수록(1 − [remaining]) 행성 쪽으로 다가온다. class _DefendPlanet extends StatelessWidget { - const _DefendPlanet({required this.remaining, required this.pending}); + const _DefendPlanet({ + required this.remaining, + required this.pending, + required this.image, + }); + + final AssetImage image; /// 남은 시간 비율 1 → 0. final double remaining; @@ -719,7 +754,7 @@ class _DefendPlanet extends StatelessWidget { 280, CustomPaint(painter: _RemainingRing(remaining)), ), - _at(67.5, 300, 240, 240, const Image(image: _planet)), + _at(67.5, 300, 240, 240, Image(image: image)), // 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다. _meteor( 38, diff --git a/app/lib/features/eclipse/widgets/eclipse_banner.dart b/app/lib/features/eclipse/widgets/eclipse_banner.dart index ae4b16e..6adeb79 100644 --- a/app/lib/features/eclipse/widgets/eclipse_banner.dart +++ b/app/lib/features/eclipse/widgets/eclipse_banner.dart @@ -26,6 +26,7 @@ class EclipseBanner extends StatelessWidget { required this.repository, this.fandomId, this.onTap, + this.nebula = false, this.margin = const EdgeInsets.fromLTRB( AppSpace.s4, AppSpace.s2, @@ -42,6 +43,9 @@ class EclipseBanner extends StatelessWidget { final VoidCallback? onTap; final EdgeInsets margin; + /// 대상 팬덤이 아직 성운인가 — 상세 · 방어 화면이 성운 그림을 그린다. + final bool nebula; + @override Widget build(BuildContext context) => LiveStreamBuilder( // 대상 팬덤이 바뀌면 구독을 갈아탄다 — [LiveStreamBuilder] 주석 참고. @@ -58,9 +62,11 @@ class EclipseBanner extends StatelessWidget { child: EclipseBannerCard( state: state, onTap: onTap ?? () => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute(builder: (_) => EclipseDefendScreen( - repository: repository, fandomId: state.fandomId, - )), + eclipseDefendRoute( + repository: repository, + fandomId: state.fandomId, + nebula: nebula, + ), ), ), ), diff --git a/app/lib/features/eclipse/widgets/eclipse_event_button.dart b/app/lib/features/eclipse/widgets/eclipse_event_button.dart index 4fc099e..1635695 100644 --- a/app/lib/features/eclipse/widgets/eclipse_event_button.dart +++ b/app/lib/features/eclipse/widgets/eclipse_event_button.dart @@ -20,11 +20,15 @@ class EclipseEventButton extends StatelessWidget { super.key, required this.repository, required this.fandomId, + this.nebula = false, }); final EclipseRepository repository; final String fandomId; + /// 내 팬덤이 아직 성운인가 — 상세 · 방어 화면이 성운 그림을 그린다. + final bool nebula; + @override Widget build(BuildContext context) => LiveStreamBuilder( key: ValueKey('eclipse-button-$fandomId'), @@ -49,13 +53,11 @@ class EclipseEventButton extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - // 명세 `516:88164` 「닫기 — 하단 move out」 — 아래에서 올라오는 전체 화면. - fullscreenDialog: true, - builder: (_) => EclipseDefendScreen( - repository: repository, - fandomId: state.fandomId, - ), + // 명세 `516:88164` 「닫기 — 하단 move out」 — 아래에서 올라오는 전체 화면. + eclipseDefendRoute( + repository: repository, + fandomId: state.fandomId, + nebula: nebula, ), ), child: Column( diff --git a/app/lib/features/galaxy/galaxy_data.dart b/app/lib/features/galaxy/galaxy_data.dart index 1b081a9..1cfce44 100644 --- a/app/lib/features/galaxy/galaxy_data.dart +++ b/app/lib/features/galaxy/galaxy_data.dart @@ -90,8 +90,13 @@ class GalaxySnapshot { this.joined = true, this.memberships = const [], this.joinedIds = const {}, + this.nebulaCreatedToday = false, }); + /// 오늘(KST) 이미 성운을 만들었는가 — Figma `540:19592` 「일1회 생성 가능(이후 비활성화) · + /// 횟수 초과 시 초과 안내 모달」. 서버 `fn-nebula-create` 의 일 1회 판정(E-FDM-02)과 같은 문서를 본다. + final bool nebulaCreatedToday; + final List planets; /// 내가 가입한 모든 팬덤 ID(`users.fandomIds` + 대표). 10/3 통합회신 #10 「다른 가입 팬덤에서도 Zap」 — @@ -155,6 +160,7 @@ class GalaxySnapshot { joined: joined, memberships: memberships, joinedIds: joinedIds, + nebulaCreatedToday: nebulaCreatedToday, ); } } diff --git a/app/lib/features/galaxy/galaxy_home_screen.dart b/app/lib/features/galaxy/galaxy_home_screen.dart index 2a8c4da..b4d05d6 100644 --- a/app/lib/features/galaxy/galaxy_home_screen.dart +++ b/app/lib/features/galaxy/galaxy_home_screen.dart @@ -70,6 +70,9 @@ class GalaxyHomeScreen extends StatefulWidget { class _GalaxyHomeScreenState extends State { late Future _snapshot = widget.repository.load(); + /// Figma `540:19580` 그리드 변경 영역 — 디폴트 격자, 누르면 격자 ↔ 리스트(`354:221533`). + bool _list = false; + /// 명세 §2.58-3 — *행성/성운 클릭 시 기본 행성 변경 안내 모달 노출*. 이미 대표면 궤도 맵으로. Future _choose(PlanetNode p) async { if (p.isMine) return _openMap(); @@ -104,7 +107,14 @@ class _GalaxyHomeScreenState extends State { /// 만든 성운으로 바로 들어간다. 돌아오면 「내 행성」 그리드에 새 성운이 선다. Future _createNebula() async { final navigator = Navigator.of(context, rootNavigator: true); - final myPop = (await _snapshot).myPop; + final snapshot = await _snapshot; + // Figma `540:19592` · `543:19609` — 일 1회. 오늘 이미 만들었으면 화면을 열지 않고 초과 안내 모달. + if (snapshot.nebulaCreatedToday) { + if (mounted) + await showAlertDialog(context, context.l10n.nebulaAlreadyToday); + return; + } + final myPop = snapshot.myPop; final fandomId = await navigator.push( MaterialPageRoute( builder: (_) => @@ -155,16 +165,14 @@ class _GalaxyHomeScreenState extends State { ), ); } - // 시안은 내 행성 셋을 보여 준다 — 가입하거나 만든 행성/성운, 최신 가입순(§2.58). - // 「선택」 알약은 대표 팬덤(C-15)이다. - final mine = data.memberships.take(3).toList(); + // 가입하거나 만든 행성/성운 전부, 최신 가입순(Figma `540:19586`). 「선택」 알약은 대표 팬덤(C-15). + final mine = data.memberships; return Stack( children: [ - ListView( - padding: const EdgeInsets.only(bottom: AppSpace.s9), + Column( children: [ - // 상단 — 시안 `Top Navigation`(54 · 좌우 16) 「GALAXY」 ABeeZee 20 · 검색 · 알림 42 + // 상단 — 상단 고정 노출(Figma `540:19574`). 시안 `Top Navigation`(54 · 좌우 16) 「GALAXY」 ABeeZee 20 · 검색 · 알림 42 Padding( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -191,6 +199,8 @@ class _GalaxyHomeScreenState extends State { MaterialPageRoute( builder: (_) => GalaxySearchScreen( repository: widget.repository, + onJoin: (p) => + widget.planetRepository(p.id).join(), onOpen: (p) => Navigator.of( context, @@ -226,73 +236,77 @@ class _GalaxyHomeScreenState extends State { ], ), ), - // 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱 - // 워닝 배너 병행」. 대상은 내 행성이다. - if (mine.where((p) => p.isMine).firstOrNull case final my?) - EclipseBanner( - repository: widget.eclipse, - fandomId: my.id, - margin: const EdgeInsets.fromLTRB( - 16, - AppSpace.s3, - 16, - 0, - ), - onTap: _openMap, - ), - // 「내 행성」 ABeeZee 26 · 우측 목록 아이콘 24 → 궤도 맵 (줄 44 · 위아래 4) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - child: Row( + Expanded( + child: ListView( + padding: const EdgeInsets.only(bottom: AppSpace.s9), children: [ - Text( - context.l10n.galaxyMyPlanets, - style: const TextStyle( - fontFamily: AppFonts.display, - fontWeight: FontWeight.w400, - fontSize: 26, - height: 1.4, - color: AppColors.onCosmic, - ), - ), - const Spacer(), - InkResponse( - onTap: _openMap, - radius: 20, - child: Image.asset( - 'assets/figma/icon-list.png', - width: 24, - height: 24, - color: const Color(0xFF777777), - semanticLabel: context.l10n.galaxyMap, - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - for (final (i, p) in mine.indexed) ...[ - if (i > 0) const SizedBox(width: AppSpace.s2), - Expanded( - child: _PlanetCard( - planet: p, - image: GalaxyHomeScreen.cardImageOf(p, i), - selected: p.isMine, - onTap: () => _choose(p), + // 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱 + // 워닝 배너 병행」. 대상은 내 행성이다. + if (mine.where((p) => p.isMine).firstOrNull + case final my?) + EclipseBanner( + repository: widget.eclipse, + fandomId: my.id, + margin: const EdgeInsets.fromLTRB( + 16, + AppSpace.s3, + 16, + 0, ), + onTap: _openMap, ), - ], - // 시안은 항상 세 칸이다. 행성이 모자라면 빈 칸을 둔다. - for (var i = mine.length; i < 3; i++) ...[ - if (i > 0) const SizedBox(width: AppSpace.s2), - const Expanded(child: _EmptySlot()), - ], + // 「내 행성」 ABeeZee 26 · 우측 목록 아이콘 24 → 궤도 맵 (줄 44 · 위아래 4) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Row( + children: [ + Text( + context.l10n.galaxyMyPlanets, + style: const TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + fontSize: 26, + height: 1.4, + color: AppColors.onCosmic, + ), + ), + const Spacer(), + // 격자 ↔ 리스트(Figma `540:19580`). 궤도 맵은 선택된 카드를 눌러 연다. + InkResponse( + onTap: () => setState(() => _list = !_list), + radius: 20, + child: Semantics( + container: true, + button: true, + label: _list + ? context.l10n.galaxyGridView + : context.l10n.galaxyListView, + child: _list + ? const _GridIcon() + : Image.asset( + 'assets/figma/icon-list.png', + width: 24, + height: 24, + color: const Color(0xFF777777), + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: _list + ? _PlanetList( + planets: mine, + imageOf: GalaxyHomeScreen.cardImageOf, + onTap: _choose, + ) + : _PlanetGrid(planets: mine, onTap: _choose), + ), ], ), ), @@ -303,7 +317,13 @@ class _GalaxyHomeScreenState extends State { left: 0, right: 0, bottom: 16, - child: Center(child: _NebulaPill(onTap: _createNebula)), + child: Center( + child: _NebulaPill( + onTap: _createNebula, + // 일 1회 생성 뒤 비활성화(Figma `540:19592`). 눌러도 초과 안내 모달만 뜬다. + disabled: data.nebulaCreatedToday, + ), + ), ), ], ); @@ -426,44 +446,221 @@ class _EmptySlot extends StatelessWidget { } class _NebulaPill extends StatelessWidget { - const _NebulaPill({required this.onTap}); + const _NebulaPill({required this.onTap, this.disabled = false}); final VoidCallback onTap; + final bool disabled; @override Widget build(BuildContext context) { - return Material( - color: const Color(0xFF19191C), - borderRadius: BorderRadius.circular(AppRadius.full), - child: InkWell( - onTap: onTap, + return Opacity( + opacity: disabled ? 0.4 : 1, + child: Material( + color: const Color(0xFF19191C), borderRadius: BorderRadius.circular(AppRadius.full), - child: Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.cosmicSurfaceRaised), - ), - // 시안 — Iconset/badge 16 · 간격 6 · 「성운 생성」 14/500 #a3a2a0 - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const ImageIcon( - AppIcons.badge, - size: 16, - color: AppColors.cosmicAccent, - ), - const SizedBox(width: 6), - Text( - context.l10n.nebulaCreate, - style: const TextStyle( - fontFamily: AppFonts.body, - fontSize: 14, - fontWeight: FontWeight.w500, - height: 20 / 14, - color: AppColors.onCosmicVariant, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.full), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.cosmicSurfaceRaised), + ), + // 시안 — Iconset/badge 16 · 간격 6 · 「성운 생성」 14/500 #a3a2a0 + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const ImageIcon( + AppIcons.badge, + size: 16, + color: AppColors.cosmicAccent, ), + const SizedBox(width: 6), + Text( + context.l10n.nebulaCreate, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 20 / 14, + color: AppColors.onCosmicVariant, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 격자 — 세 칸씩 줄을 채운다. 행성이 셋보다 적으면 빈 칸으로 한 줄을 맞춘다(시안 세 칸). +class _PlanetGrid extends StatelessWidget { + const _PlanetGrid({required this.planets, required this.onTap}); + + final List planets; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final cells = planets.length < 3 ? 3 : (planets.length + 2) ~/ 3 * 3; + return Column( + children: [ + for (var row = 0; row < cells; row += 3) ...[ + if (row > 0) const SizedBox(height: AppSpace.s2), + Row( + children: [ + for (var i = row; i < row + 3; i++) ...[ + if (i > row) const SizedBox(width: AppSpace.s2), + Expanded( + child: i < planets.length + ? _PlanetCard( + planet: planets[i], + image: GalaxyHomeScreen.cardImageOf(planets[i], i), + selected: planets[i].isMine, + onTap: () => onTap(planets[i]), + ) + : const _EmptySlot(), + ), + ], + ], + ), + ], + ], + ); + } +} + +/// 리스트 그리드 — 시안 `354:221533`: 80×79 #2c2c2c r8 칸(그림 64) · 간격 15 · 이름 16/600, +/// 줄 사이 1px #2c2c2c. 선택된 행성은 칸 위에 「선택」 알약. +class _PlanetList extends StatelessWidget { + const _PlanetList({ + required this.planets, + required this.imageOf, + required this.onTap, + }); + + final List planets; + final String Function(PlanetNode, [int]) imageOf; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) => Column( + children: [ + for (final (i, p) in planets.indexed) ...[ + if (i > 0) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Divider(height: 1, thickness: 1, color: Color(0xFF2C2C2C)), + ), + InkWell( + onTap: () => onTap(p), + child: Row( + children: [ + SizedBox( + width: 80, + height: 79, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF2C2C2C), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: PlanetArt( + fallback: imageOf(p, i), + planetType: p.stage == 0 ? null : p.planetType, + colorKey: p.colorKey, + semanticLabel: p.name, + ), + ), + ), + ), + if (p.isMine) + Positioned( + top: 0, + right: 0, + child: Container( + width: 32, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.cosmicAccent, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + context.l10n.commonSelect, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w600, + height: 1, + color: AppColors.onCosmic, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 15), + Expanded( + child: Text( + p.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 22 / 16, + color: AppColors.onCosmic, + ), + ), + ), + ], + ), + ), + ], + ], + ); +} + +/// 리스트 상태의 「격자로」 아이콘 — 시안 9×9 r1 #777777 네 칸(20×20). +class _GridIcon extends StatelessWidget { + const _GridIcon(); + + @override + Widget build(BuildContext context) { + Widget sq() => Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: const Color(0xFF777777), + borderRadius: BorderRadius.circular(1), + ), + ); + return SizedBox.square( + dimension: 24, + child: Center( + child: SizedBox.square( + dimension: 20, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [sq(), sq()], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [sq(), sq()], ), ], ), diff --git a/app/lib/features/galaxy/galaxy_repository_firestore.dart b/app/lib/features/galaxy/galaxy_repository_firestore.dart index 87e2677..4266cfb 100644 --- a/app/lib/features/galaxy/galaxy_repository_firestore.dart +++ b/app/lib/features/galaxy/galaxy_repository_firestore.dart @@ -26,13 +26,20 @@ class FirestoreGalaxyRepository implements GalaxyRepository { final user = userSnap.data() ?? const {}; // 대표 팬덤(C-15). `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다. final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId')); - // 「가입 또는 생성한 행성/성운」 — 최신 가입순. 시안 그리드가 세 칸이라 셋만 읽는다. - // ponytail: 모아보기 리스트 그리드(명세 §2.58-2)를 붙이면 limit 과 페이지를 연다. + // 「가입 또는 생성한 행성/성운」 — 최신 가입순 전부(Figma `540:19586`). 세 칸은 한 줄의 칸 수다. final membershipsF = db .collection('users/$uid/memberships') .orderBy('joinedAt', descending: true) - .limit(3) .get(); + // 오늘(KST) 성운 생성 기록 — 서버 `dayKey`(UTC+9)와 같은 키. + final kst = DateTime.now().toUtc().add(const Duration(hours: 9)); + final todayKey = + '${kst.year.toString().padLeft(4, '0')}-${kst.month.toString().padLeft(2, '0')}-${kst.day.toString().padLeft(2, '0')}'; + // 규칙 배포 전이면 읽기가 거부된다 — 그때는 버튼을 막지 않고 서버 판정(E-FDM-02)에 맡긴다. + final createdTodayF = db + .doc('users/$uid/nebulaCreates/$todayKey') + .get() + .then((d) => d.exists, onError: (_) => false); final fandomsF = db .collection('fandoms') @@ -102,6 +109,7 @@ class FirestoreGalaxyRepository implements GalaxyRepository { // Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙). joined: myFandomId.isNotEmpty, memberships: [for (final id in membershipIds) ?byId[id]], + nebulaCreatedToday: await createdTodayF, joinedIds: { ...?(user['fandomIds'] as List?)?.whereType(), if (myFandomId.isNotEmpty) myFandomId, @@ -118,7 +126,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository { if (q.isEmpty) return const []; final uid = _backend.requireUid; final db = _backend.db; - final user = (await db.doc('users/$uid').get()).data() ?? const {}; + final user = + (await db.doc('users/$uid').get()).data() ?? const {}; final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId')); final names = await db .collection('fandomNames') @@ -130,7 +139,9 @@ class FirestoreGalaxyRepository implements GalaxyRepository { for (final d in names.docs) if (d.data()['fandomId'] case final String id when id.isNotEmpty) id, }; - final docs = await Future.wait([for (final id in ids) db.doc('fandoms/$id').get()]); + final docs = await Future.wait([ + for (final id in ids) db.doc('fandoms/$id').get(), + ]); return [ for (final doc in docs) if (doc.data() case final data?) @@ -152,7 +163,9 @@ class FirestoreGalaxyRepository implements GalaxyRepository { _ => null, }, colorKey: data['colorKey'] is String ? data['colorKey'] as String : null, - planetType: data['planetType'] is String ? data['planetType'] as String : null, + planetType: data['planetType'] is String + ? data['planetType'] as String + : null, name: data.strOr('name', id), rank: rank, fpi: data.intOr('fpiScore'), diff --git a/app/lib/features/galaxy/galaxy_screen.dart b/app/lib/features/galaxy/galaxy_screen.dart index cc3f291..dfccaf1 100644 --- a/app/lib/features/galaxy/galaxy_screen.dart +++ b/app/lib/features/galaxy/galaxy_screen.dart @@ -573,11 +573,9 @@ class _Map extends StatelessWidget { state: e, onTap: () => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (_) => EclipseDefendScreen( - repository: eclipseRepository, - fandomId: e.fandomId, - ), + eclipseDefendRoute( + repository: eclipseRepository, + fandomId: e.fandomId, ), ), ), diff --git a/app/lib/features/galaxy/galaxy_search_screen.dart b/app/lib/features/galaxy/galaxy_search_screen.dart index 423904b..fdd548c 100644 --- a/app/lib/features/galaxy/galaxy_search_screen.dart +++ b/app/lib/features/galaxy/galaxy_search_screen.dart @@ -4,7 +4,10 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../core/theme/tokens.dart'; +import '../../core/data/backend.dart' show BackendException; +import '../../core/ui/planet_25d.dart' show PlanetArt; import '../../core/ui/play_banner.dart'; +import '../planet/widgets/fandom_size_badge.dart'; import 'galaxy_data.dart'; import '../../core/format.dart'; import '../../core/i18n/app_locale.dart'; @@ -20,10 +23,14 @@ class GalaxySearchScreen extends StatefulWidget { super.key, this.repository = const SampleGalaxyRepository(), this.onOpen, + this.onJoin, }); final GalaxyRepository repository; + /// 결과 줄의 「가입」 — Figma `543:19694` 「가입 처리 후 가입중 버튼스타일로 변경」. + final Future Function(PlanetNode planet)? onJoin; + /// 결과 줄을 누르면 그 행성 상세로 — 상세 화면 저장소는 부르는 쪽이 안다. final void Function(PlanetNode planet)? onOpen; @@ -41,6 +48,17 @@ class _GalaxySearchScreenState extends State { String _query = ''; + /// Figma `543:19672` 탭 — 디폴트 행성(false) · 성운(true). + bool _nebulaTab = false; + + /// Figma `543:19683` 필터 — 디폴트 전체. 행성: 전체 · 가입자 수 · FPI / 성운: 전체 · 가입자 수. + /// 성운의 「기여율」 필터 · 칩(`543:19704` · `543:19714`)은 기여율의 정의가 자료에 없어 해석 확인 후 붙인다. + _Sort _sort = _Sort.all; + + /// 이 화면에서 가입한 행성 — 서버 확인 뒤 「가입중」으로 바꾼다. + final _joined = {}; + final _joining = {}; + /// 전체 행성 검색 결과 — 입력이 멈춘 뒤 서버에 묻는다. Future>? _hits; Timer? _debounce; @@ -55,8 +73,15 @@ class _GalaxySearchScreenState extends State { Future _loadRecent() async { try { - final saved = (await SharedPreferences.getInstance()).getStringList(_recentKey); - if (mounted && saved != null) setState(() => _recent..clear()..addAll(saved)); + final saved = (await SharedPreferences.getInstance()).getStringList( + _recentKey, + ); + if (mounted && saved != null) + setState( + () => _recent + ..clear() + ..addAll(saved), + ); } catch (_) { // 기기 저장소를 못 쓰면 이번 화면 안에서만 기억한다. } @@ -64,7 +89,10 @@ class _GalaxySearchScreenState extends State { Future _saveRecent() async { try { - await (await SharedPreferences.getInstance()).setStringList(_recentKey, _recent); + await (await SharedPreferences.getInstance()).setStringList( + _recentKey, + _recent, + ); } catch (_) {} } @@ -95,6 +123,24 @@ class _GalaxySearchScreenState extends State { super.dispose(); } + Future _join(PlanetNode p) async { + final join = widget.onJoin; + if (join == null || _joining.contains(p.id)) return; + setState(() => _joining.add(p.id)); + try { + await join(p); + if (mounted) setState(() => _joined.add(p.id)); + } on BackendException catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _joining.remove(p.id)); + } + } + void _submit(String q) { final term = q.trim(); if (term.isEmpty) return; @@ -201,19 +247,64 @@ class _GalaxySearchScreenState extends State { result.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } - final hits = result.data ?? const []; - if (hits.isEmpty) return const _NoHit(); - return ListView.separated( - padding: const EdgeInsets.all(AppSpace.s4), - itemCount: hits.length, - separatorBuilder: (_, _) => - const SizedBox(height: AppSpace.s2), - itemBuilder: (context, i) => _Hit( - planet: hits[i], - onTap: widget.onOpen == null - ? null - : () => widget.onOpen!(hits[i]), - ), + final all = result.data ?? const []; + final joinedIds = { + ...(snap.data?.joinedIds ?? const {}), + ..._joined, + }; + final hits = [ + for (final p in all) + if ((p.stage == 0) == _nebulaTab) p, + ]; + switch (_sort) { + case _Sort.all: + break; + case _Sort.members: + hits.sort((a, b) => b.members.compareTo(a.members)); + case _Sort.fpi: + hits.sort((a, b) => b.fpi.compareTo(a.fpi)); + } + return Column( + children: [ + _Tabs( + nebula: _nebulaTab, + onPick: (v) => setState(() { + _nebulaTab = v; + if (v && _sort == _Sort.fpi) _sort = _Sort.all; + }), + ), + _Filters( + nebula: _nebulaTab, + sort: _sort, + onPick: (v) => setState(() => _sort = v), + ), + Expanded( + child: hits.isEmpty + ? const _NoHit() + : ListView.separated( + padding: const EdgeInsets.all(AppSpace.s4), + itemCount: hits.length, + separatorBuilder: (_, _) => + const SizedBox(height: 24), + itemBuilder: (context, i) { + final p = hits[i]; + final joined = + p.isMine || joinedIds.contains(p.id); + return _Hit( + planet: p, + joined: joined, + busy: _joining.contains(p.id), + onJoin: joined || widget.onJoin == null + ? null + : () => _join(p), + onTap: widget.onOpen == null + ? null + : () => widget.onOpen!(p), + ); + }, + ), + ), + ], ); }, ); @@ -290,13 +381,19 @@ class _Recent extends StatelessWidget { ), ) else - Wrap( - spacing: AppSpace.s2, - runSpacing: AppSpace.s2, - children: [ - for (final term in terms) - ActionChip(label: Text(term), onPressed: () => onPick(term)), - ], + // Figma `543:19637` 「검색 결과 칩의 최대 높이는 3줄」 — 칩 33 · 줄 간격 10. + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 33 * 3 + 10 * 2), + child: ClipRect( + child: Wrap( + spacing: AppSpace.s2, + runSpacing: 10, + children: [ + for (final term in terms) + ActionChip(label: Text(term), onPressed: () => onPick(term)), + ], + ), + ), ), ], ); @@ -318,46 +415,205 @@ class _NoHit extends StatelessWidget { ); } +enum _Sort { all, members, fpi } + +/// 탭 — 시안 `353:218091` 375×44 · 칸 171 · 16/500(선택 #4bc2ae 밑줄). +class _Tabs extends StatelessWidget { + const _Tabs({required this.nebula, required this.onPick}); + + final bool nebula; + final ValueChanged onPick; + + @override + Widget build(BuildContext context) => SizedBox( + height: 44, + child: Row( + children: [ + for (final (v, label) in [ + (false, context.l10n.galaxyTabPlanet), + (true, context.l10n.galaxyTabNebula), + ]) + Expanded( + child: InkWell( + onTap: () => onPick(v), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: v == nebula + ? AppColors.cosmicTeal + : const Color(0xFF1A1A1A), + width: v == nebula ? 2 : 1, + ), + ), + ), + child: Text( + label, + style: _t( + 16, + v == nebula + ? AppColors.cosmicTeal + : const Color(0xFFBBBBBB), + FontWeight.w500, + 1, + ), + ), + ), + ), + ), + ], + ), + ); +} + +/// 필터 칩 — 시안 32 높이 r8 · 선택 #ffffff(글자 검정) · 비선택 #1a1a1a · 12/500. +class _Filters extends StatelessWidget { + const _Filters({ + required this.nebula, + required this.sort, + required this.onPick, + }); + + final bool nebula; + final _Sort sort; + final ValueChanged<_Sort> onPick; + + @override + Widget build(BuildContext context) => SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + for (final (v, label) in [ + (_Sort.all, context.l10n.commonAll), + (_Sort.members, context.l10n.galaxySortMembers), + if (!nebula) (_Sort.fpi, 'FPI'), + ]) ...[ + GestureDetector( + onTap: () => onPick(v), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + alignment: Alignment.center, + decoration: BoxDecoration( + color: v == sort ? Colors.white : const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: _t( + 12, + v == sort ? Colors.black : AppColors.onCosmic, + FontWeight.w500, + 20 / 12, + ), + ), + ), + ), + const SizedBox(width: 4), + ], + ], + ), + ); +} + +/// 결과 한 줄 — 시안 `353:218091`: 그림 70 · 간격 15 · 이름 16/600 · 규모 칩 · 사람 14 + 수 12/500 · +/// 「FPI : n」 12/500, 오른쪽 「가입」(#ff7ddd) / 「가입중」(#1a1a1a) 알약 30 높이. class _Hit extends StatelessWidget { - const _Hit({required this.planet, this.onTap}); + const _Hit({ + required this.planet, + required this.joined, + this.busy = false, + this.onJoin, + this.onTap, + }); final PlanetNode planet; + final bool joined; + final bool busy; + final VoidCallback? onJoin; final VoidCallback? onTap; @override Widget build(BuildContext context) { - final t = Theme.of(context).textTheme; + final nebula = planet.stage == 0; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Container( - padding: const EdgeInsets.all(AppSpace.s4), - decoration: BoxDecoration( - color: AppColors.cosmicSurfaceContainer, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Text( - planet.rankLabel, - style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent), + child: Row( + children: [ + SizedBox.square( + dimension: 70, + child: PlanetArt( + fallback: nebula + ? 'assets/figma/nebula-card.png' + : 'assets/figma/planet-detail-hero.png', + planetType: nebula ? null : planet.planetType, + colorKey: planet.colorKey, + semanticLabel: planet.name, ), - const SizedBox(width: AppSpace.s4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(planet.name, style: t.bodyMedium), - Text( - context.l10n.galaxyMembers(formatPop(planet.members)), - style: t.labelSmall, - ), - ], + ), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + planet.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _t(16, AppColors.onCosmic, FontWeight.w600, 1), + ), + const SizedBox(height: 6), + Row( + children: [ + if (!nebula) ...[ + FandomSizeBadge(members: planet.members), + const SizedBox(width: 8), + ], + const Icon( + Icons.person, + size: 14, + color: Color(0xFF777777), + ), + const SizedBox(width: 3), + Text( + formatPop(planet.members), + style: _t(12, AppColors.onCosmic, FontWeight.w500, 1), + ), + if (!nebula) ...[ + const SizedBox(width: 8), + Text( + 'FPI : ${formatPop(planet.fpi)}', + style: _t(12, AppColors.onCosmic, FontWeight.w500, 1), + ), + ], + ], + ), + ], + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: busy ? null : onJoin, + child: Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: joined + ? const Color(0xFF1A1A1A) + : AppColors.cosmicAccent, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + joined ? context.l10n.planetJoined : context.l10n.galaxyJoin, + style: _t(14, AppColors.onCosmic, FontWeight.w500, 1), ), ), - Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall), - ], - ), + ), + ], ), ); } diff --git a/app/lib/features/home/home_data.dart b/app/lib/features/home/home_data.dart index 1ca1a36..dc16042 100644 --- a/app/lib/features/home/home_data.dart +++ b/app/lib/features/home/home_data.dart @@ -24,6 +24,7 @@ class PlanetGrowth { this.requiredMembers, this.nebulaGoalPassed = false, this.isFounder = false, + this.photoUrl, required this.fandomName, required this.contributorName, required this.contributedPop, @@ -36,8 +37,15 @@ class PlanetGrowth { final int? stage; final String fandomName; - /// 직전 기여자. 「BTS_Hong 님의 1,230 POP 기여로 …」 문구에 쓴다. + /// 히어로 인사 줄의 이름 — 설명 프레임 `2097:16391` 「프로필 · 닉네임 · 타이틀 - **로그인 + /// 사용자 기준** 노출」이라 로그인한 사용자다(팬덤의 직전 기여자가 아니다). final String contributorName; + + /// 인사 줄 아바타 — 로그인 사용자의 프로필 사진(`users/{uid}.photoUrl`). 없으면 이니셜. + final String? photoUrl; + + /// 「{N} POP 기여로 …」의 N. 로그인 사용자의 어느 기여량인지 자료에 정의가 없어(해석 확인 + /// Q-A17) 실 서버는 0 으로 두고 숫자 없는 문구를 쓴다. final int contributedPop; bool get hasRecentContribution => diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index e4f19e7..c0b96ef 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -26,6 +26,10 @@ class FirestoreHomeRepository implements HomeRepository { final Backend _backend; + /// 히어로 인사 줄의 로그인 사용자(설명 프레임 `2097:16391`). [load] 가 채우고 [watchGrowth] 도 쓴다. + String _myName = ''; + String? _myPhoto; + @override Stream watchGrowth(String fandomId) { if (fandomId.isEmpty) return const Stream.empty(); @@ -107,6 +111,8 @@ class FirestoreHomeRepository implements HomeRepository { final userSnap = await db.doc('users/$uid').get(); final user = userSnap.data() ?? const {}; final fandomId = user.strOr('fandomId'); + _myName = publicName(user['nickname']); + _myPhoto = user.strOr('photoUrl').isEmpty ? null : user.strOr('photoUrl'); // 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다. final walletF = db.doc('users/$uid/wallet/main').get(); @@ -366,11 +372,12 @@ class FirestoreHomeRepository implements HomeRepository { return PlanetGrowth( stage: stage, fandomName: fandom.strOr('name', fandomId), - // 기존 데이터에 이메일 형태가 남아 있어도 가린다. - contributorName: fandom.containsKey('lastContributorName') - ? publicName(fandom['lastContributorName']) - : '', - contributedPop: fandom.intOr('lastContributedPop'), + // 설명 프레임 `2097:16391` — 프로필 · 닉네임은 로그인 사용자 기준. 팬덤 문서의 직전 + // 기여자(`lastContributorName`)를 쓰면 남의 이름이 「님의 … 기여로」에 올라 내 것처럼 읽힌다. + contributorName: _myName, + photoUrl: _myPhoto, + // 「N POP 기여로」의 N 이 어느 기여량인지 정의가 없다(Q-A17) — 회신 전에는 숫자 없는 문구. + contributedPop: 0, growthPercent: current != null && goal != null && goal > 0 ? ((current / goal) * 100).clamp(0, 100).round() : null, diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index 66887a8..cc587ab 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -16,6 +16,7 @@ import 'package:go_router/go_router.dart'; import '../../core/format.dart'; import '../../core/i18n/app_locale.dart'; import '../../core/theme/tokens.dart'; +import '../../core/ui/app_dialog.dart' show slideUpRoute; import '../../core/ui/app_icons.dart'; import '../../core/data/planet_visual_state.dart'; import '../../core/ui/live_stream.dart'; @@ -167,7 +168,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { final created = await Navigator.of( context, rootNavigator: true, - ).push(MaterialPageRoute(builder: (_) => screen)); + ).push(slideUpRoute((_) => screen)); if (created == null || !mounted) return; if (widget.galaxyRepository case final repository?) { setState(() { @@ -287,7 +288,7 @@ class _LiveTickerSlot extends StatelessWidget { } /// 홈 헤더 — 성장 완료 · 첫 사용자 상태 화면도 같은 헤더를 쓴다. -class HomeHeader extends StatelessWidget { +class HomeHeader extends StatefulWidget { const HomeHeader({ super.key, required this.pop, @@ -306,71 +307,116 @@ class HomeHeader extends StatelessWidget { final List items; @override - Widget build(BuildContext context) => Padding( - // 사본 `Top Navigation` — 높이 54 · 패딩 6/16 · 오른쪽 묶음은 알약 + 42 아이콘 두 개. - padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 6), - child: SizedBox( - height: 42, - child: Row( - children: [ - const Text( - 'FANUZU', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 20, - fontWeight: FontWeight.w900, - height: 1.4, - letterSpacing: -0.5, - color: AppColors.cosmicAccent, - ), - ), - const Spacer(), - _PopBalance(value: pop), - IconButton( - constraints: const BoxConstraints.tightFor(width: 42, height: 42), - // 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게. - style: IconButton.styleFrom( - minimumSize: const Size.square(42), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - padding: EdgeInsets.zero, - icon: const Image( - image: AssetImage('assets/figma/icon-wallet.png'), - width: 42, - height: 42, - ), - // 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트. - onPressed: () => showItemSheet( - context, - items: items, - purchase: true, - fandomId: fandomId, - ), - tooltip: context.l10n.homeItemsTooltip, - ), - IconButton( - constraints: const BoxConstraints.tightFor(width: 42, height: 42), - // 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게. - style: IconButton.styleFrom( - minimumSize: const Size.square(42), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - padding: EdgeInsets.zero, - // 사본 `icon` 42 칸 안 lucide/bell 20. - iconSize: 20, - icon: const ImageIcon(AppIcons.bell, color: AppColors.onCosmic), - // 알림 — 화면 대조 원장 F0 · `229:11265`. - onPressed: () => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (_) => NotificationScreen(repository: notifications), + State createState() => _HomeHeaderState(); +} + +class _HomeHeaderState extends State { + /// 안 읽은 알림이 있는가 — 설명 프레임 `2097:16385` 「알림 - 알림 아이콘 / 알림 상태값 · + /// 알림 존재 시 배찌 알림으로 변경」. 알림함은 닫힐 때 읽음 상태를 돌려주므로(`518:88347`) + /// 돌아오면 다시 읽는다. + bool _unread = false; + + @override + void initState() { + super.initState(); + _checkUnread(); + } + + Future _checkUnread() async { + try { + final items = await widget.notifications.load(); + if (mounted) setState(() => _unread = items.any((i) => !i.read)); + } catch (_) { + // 읽지 못하면 배지를 띄우지 않는다 — 알림함 자체는 열린다. + } + } + + @override + Widget build(BuildContext context) { + final pop = widget.pop; + final items = widget.items; + final fandomId = widget.fandomId; + final notifications = widget.notifications; + return Padding( + // 사본 `Top Navigation` — 높이 54 · 패딩 6/16 · 오른쪽 묶음은 알약 + 42 아이콘 두 개. + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 6), + child: SizedBox( + height: 42, + child: Row( + children: [ + const Text( + 'FANUZU', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 20, + fontWeight: FontWeight.w900, + height: 1.4, + letterSpacing: -0.5, + color: AppColors.cosmicAccent, ), ), - tooltip: context.l10n.homeNotificationsTooltip, - ), - ], + const Spacer(), + _PopBalance(value: pop), + IconButton( + constraints: const BoxConstraints.tightFor(width: 42, height: 42), + // 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게. + style: IconButton.styleFrom( + minimumSize: const Size.square(42), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + padding: EdgeInsets.zero, + icon: const Image( + image: AssetImage('assets/figma/icon-wallet.png'), + width: 42, + height: 42, + ), + // 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트. + onPressed: () => showItemSheet( + context, + items: items, + purchase: true, + fandomId: fandomId, + ), + tooltip: context.l10n.homeItemsTooltip, + ), + IconButton( + constraints: const BoxConstraints.tightFor(width: 42, height: 42), + // 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게. + style: IconButton.styleFrom( + minimumSize: const Size.square(42), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + padding: EdgeInsets.zero, + // 사본 `icon` 42 칸 안 lucide/bell 20. + iconSize: 20, + // 배지 모양은 Figma 에 그림이 없어 테마 기본 점 배지(강조색)를 쓴다. + icon: Badge( + key: const ValueKey('home-bell-badge'), + isLabelVisible: _unread, + smallSize: 6, + backgroundColor: AppColors.cosmicAccent, + child: const ImageIcon( + AppIcons.bell, + color: AppColors.onCosmic, + ), + ), + // 알림 — 화면 대조 원장 F0 · `229:11265`. + onPressed: () async { + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => + NotificationScreen(repository: notifications), + ), + ); + await _checkUnread(); + }, + tooltip: context.l10n.homeNotificationsTooltip, + ), + ], + ), ), - ), - ); + ); + } } class _HomeBody extends StatelessWidget { @@ -488,35 +534,7 @@ class _HomeBody extends StatelessWidget { fallback: data.liveTicker, ), onClose: () => onMap(false), - onSearch: () => - Navigator.of( - context, - rootNavigator: true, - ).push( - MaterialPageRoute( - builder: (_) => GalaxySearchScreen( - repository: zap!.repository, - onOpen: (p) => - Navigator.of( - context, - rootNavigator: true, - ).push( - MaterialPageRoute( - builder: (_) => - PlanetDetailScreen( - repository: - planetRepository!( - p.id, - ), - watchPlanet: - watchPlanet, - eclipse: eclipse, - ), - ), - ), - ), - ), - ), + onSearch: () => _openSearch(context), ) : HomeHero( key: const ValueKey('home-hero'), @@ -604,9 +622,8 @@ class _HomeBody extends StatelessWidget { myPop: data.popBalance, ), ), - // 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」). - onJoin: () => - GoRouter.maybeOf(context)?.go('/galaxy'), + // 설명 프레임 `516:87335` 「행성 가입 버튼 - 클릭 시 Galaxy 검색 화면 이동」. + onJoin: () => _openSearch(context), zapButtonKey: zapKey, onHeroParticipate: _heroParticipate(context), eclipseButton: data.fandomId.isEmpty @@ -614,6 +631,7 @@ class _HomeBody extends StatelessWidget { : EclipseEventButton( repository: eclipse, fandomId: data.fandomId, + nebula: (growth ?? data.growth).isNebula, ), ), ), @@ -842,6 +860,33 @@ class _HomeBody extends StatelessWidget { ); } + /// 갤럭시 검색 — 확장 맵 돋보기(`2008:14560`)와 첫 사용자 시트 「행성 가입」(`516:87335`). + /// 지도 저장소가 없으면(표본 모드) GALAXY 탭으로 물러난다. + void _openSearch(BuildContext context) { + final galaxyRepository = zap?.repository; + if (galaxyRepository == null || planetRepository == null) { + GoRouter.maybeOf(context)?.go('/galaxy'); + return; + } + final navigator = Navigator.of(context, rootNavigator: true); + navigator.push( + MaterialPageRoute( + builder: (_) => GalaxySearchScreen( + repository: galaxyRepository, + onOpen: (p) => navigator.push( + MaterialPageRoute( + builder: (_) => PlanetDetailScreen( + repository: planetRepository!(p.id), + watchPlanet: watchPlanet, + eclipse: eclipse, + ), + ), + ), + ), + ), + ); + } + VoidCallback? _heroParticipate(BuildContext context) => planetRepository == null || data.fandomId.isEmpty ? null diff --git a/app/lib/features/home/nebula_create_screen.dart b/app/lib/features/home/nebula_create_screen.dart index e7f1318..b44d719 100644 --- a/app/lib/features/home/nebula_create_screen.dart +++ b/app/lib/features/home/nebula_create_screen.dart @@ -13,7 +13,9 @@ import '../../core/i18n/app_locale.dart'; /// 틀은 사본(이름 입력 → 성운 → 목표 영역 → 「성운 생성」)이고 숫자는 정책 원장이다. /// B-10 — 성운은 **10명 이상 + 1,000 POP 이상**을 기본 목표 **7일** 안에 채우면 정식 /// 행성이 된다. 목표가 고정값이라 사본의 목표 슬라이더 자리에는 조작할 수 없는 고정 -/// 표시를 둔다(사본 슬라이더 ↔ 원장 고정값, 해석 확인 문항). B-19 — 진화 참여자에게는 +/// 표시를 둔다. 근거는 발주사 10/3 통합회신(원장 C-112) 「성운 생성은 10 명 + 1,000 POP + +/// 기본 목표 7 일, 사용자 목표 POP 조작 없음」 — Figma `516:87407` 목표 POP · 창립멤버 핸들러를 +/// 이 회신이 대체한다. B-19 — 진화 참여자에게는 /// 기념 배지만 지급하므로 사본의 「1T 등급 · 거버넌스」 문장은 그 내용으로 바꾼다. /// /// 사본이 함께 그린 다이얼로그 두 개를 그대로 쓴다. @@ -52,6 +54,10 @@ class _NebulaCreateScreenState extends State { String? _takenName; bool get _taken => _takenName != null && _takenName == _value; + /// 설명 프레임 `516:87397` 「성운명 입력 필드 - 유효성 검증 오류 메시지」 — 서버 + /// `isValidNebulaName` 과 같은 규칙을 입력 칸에서 먼저 보인다(중복 · 금칙어는 서버 판정). + bool get _invalid => _name.text.isNotEmpty && !isValidNebulaName(_name.text); + @override void initState() { super.initState(); @@ -129,13 +135,15 @@ class _NebulaCreateScreenState extends State { ), ), const SizedBox(height: AppSpace.s5), - _NameInput(controller: _name, error: _taken), + _NameInput(controller: _name, error: _taken || _invalid), const SizedBox(height: 6), SizedBox( height: 17, - child: _taken + child: _taken || _invalid ? Text( - context.l10n.nebulaNameTaken, + _taken + ? context.l10n.nebulaNameTaken + : context.l10n.nebulaNameInvalid, textAlign: TextAlign.center, style: TextStyle( fontFamily: AppFonts.display, @@ -171,7 +179,7 @@ class _NebulaCreateScreenState extends State { 0, ), child: FilledButton( - onPressed: _taken ? null : _confirm, + onPressed: _taken || _invalid ? null : _confirm, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(56), shape: RoundedRectangleBorder( @@ -407,3 +415,10 @@ class _Target extends StatelessWidget { ], ); } + +/// 성운명 규칙 — 서버 `isValidNebulaName`(`account.ts`)과 같다: 2~20자 · 한글/영문/숫자/공백 · +/// 앞뒤 공백 · 연속 공백 불가. +bool isValidNebulaName(String name) => + RegExp(r'^[가-힣A-Za-z0-9 ]{2,20}$').hasMatch(name) && + name == name.trim() && + !name.contains(' '); diff --git a/app/lib/features/home/planet_create_screen.dart b/app/lib/features/home/planet_create_screen.dart index e20c9d2..c21f586 100644 --- a/app/lib/features/home/planet_create_screen.dart +++ b/app/lib/features/home/planet_create_screen.dart @@ -13,7 +13,9 @@ import '../../core/i18n/app_locale.dart'; /// /// 정책 원장 C-50 — 생성 때 고르는 것은 **최초 기본 모델의 형태와 컬러 두 가지**뿐이고, /// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과 -/// 직접 색 고르기(스포이트) 칩은 두지 않는다. +/// 직접 색 고르기(스포이트) 칩은 두지 않는다. 사용자화(컬러 팔레트)를 빼는 근거는 발주사 +/// 10/3 통합회신 #6(원장 C-115) 「사용자 자유 HEX/RGB 컬러피커는 적용하지 않음」 — Figma +/// `2066:17910` 의 「사용자화」를 이 회신이 대체한다. 「오브젝트 색」 줄은 Q-A14 회신 대기. /// /// 형태 칸 · 미리보기는 행성 형태 시트 그림이다([planetShapeImage] — 6번 칸만 자리표시, Q-A14). class PlanetCreateScreen extends StatefulWidget { diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart index 2fe6624..f7c9338 100644 --- a/app/lib/features/home/widgets/home_hero.dart +++ b/app/lib/features/home/widgets/home_hero.dart @@ -314,32 +314,42 @@ class _Contribution extends StatelessWidget { Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 성운·첫 사용자 상태의 「{닉네임} 님의」는 기여 여부와 무관한 인사 줄이다. - if (growth.hasRecentContribution || - ((!joined || growth.isNebula) && - growth.contributorName.trim().isNotEmpty)) ...[ + // 「{닉네임} 님의」는 로그인 사용자의 인사 줄이다(설명 프레임 `2097:16391`). + if (growth.contributorName.trim().isNotEmpty) ...[ Row( children: [ - // 프로필 사진 자산이 데이터에 없어 이니셜로 그린다. + // 로그인 사용자의 프로필 사진(`2097:16391` 「프로필 - 이미지」). 없으면 이니셜. Container( width: 26, height: 26, alignment: Alignment.center, - decoration: const BoxDecoration( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( shape: BoxShape.circle, // 사본 `Ellipse 3` #9BF8A8(프로필 사진이 없을 때의 바탕). - color: Color(0xFF9BF8A8), - boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)], - ), - child: Text( - growth.contributorName.characters.firstOrNull ?? '—', - style: const TextStyle( - fontFamily: AppFonts.body, - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.black, - ), + color: const Color(0xFF9BF8A8), + boxShadow: const [ + BoxShadow(color: Color(0xB3000000), blurRadius: 4), + ], + image: growth.photoUrl == null + ? null + : DecorationImage( + image: NetworkImage(growth.photoUrl!), + fit: BoxFit.cover, + onError: (_, _) {}, + ), ), + child: growth.photoUrl != null + ? null + : Text( + growth.contributorName.characters.firstOrNull ?? '—', + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ), ), const SizedBox(width: 9), Text( @@ -686,8 +696,10 @@ class _Nebula extends StatelessWidget { Semantics( button: ready && onBirth != null, label: context.l10n.heroNebula, - child: GestureDetector( - onTap: ready ? onBirth : null, + // Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고, + // 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다. + child: _TapShake( + onTap: ready && onBirth != null ? onBirth : () {}, child: Image.asset( 'assets/figma/nebula-home.png', width: 212, @@ -1018,6 +1030,9 @@ class HomeZap extends StatelessWidget { onItem: onItem, zapPop: data.zapPop, myPop: (zap.balance ?? data.myPop) - zap.pending * data.zapPop, + // Figma `516:88102` Zap_버프(아이템 배수 · 지속시간)는 별첨 B B-22 「배수 기능 제거」 · + // 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 — + // 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다. isHeroTime: hero, blockedReason: zap.sending ? context.l10n.zapApplying diff --git a/app/lib/features/home/widgets/quest_row.dart b/app/lib/features/home/widgets/quest_row.dart index afe06fc..465dc52 100644 --- a/app/lib/features/home/widgets/quest_row.dart +++ b/app/lib/features/home/widgets/quest_row.dart @@ -2,12 +2,10 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -import '../../../core/i18n/app_locale.dart'; import '../../../core/theme/tokens.dart'; import '../../../core/ui/app_icons.dart'; import '../../quest/widgets/quest_card_tile.dart' show QuestIconBox, questImageFor; -import '../../quest/widgets/time_attack_countdown.dart'; import '../home_data.dart'; /// 홈 「TODAY'S QUEST」 묶음 — Figma 「홈」 `2097:16428`: 343×270 · r12 안에 343×90 줄 @@ -45,8 +43,9 @@ class QuestRows extends StatelessWidget { /// 오른쪽 chevron 24 #4F4F4F. /// /// 보조 줄은 카테고리(사본 「오늘의 출석체크」 · 「커뮤니티」)다. 「홈」 프레임에는 보상 숫자 · -/// 진행 상태가 없어 그리지 않는다. 예외는 긴급 타임어택의 남은 시간 하나 — FR-QST-06 -/// 「홈 카운트다운」이 요구한다. +/// 진행 상태가 없어 그리지 않는다. 타임어택 남은 시간도 그리지 않는다 — 설명 프레임 +/// `2097:16397` 데이터는 「어드민에서 등록된 퀘스트」뿐이고, FR-QST-06 · 원장 C-94 의 카운트다운 +/// 표시 위치는 퀘스트 목록 · 상세다. class QuestRow extends StatelessWidget { const QuestRow({ super.key, @@ -65,7 +64,6 @@ class QuestRow extends StatelessWidget { @override Widget build(BuildContext context) { - final done = quest.state == QuestState.done; return Material( color: AppColors.cosmicSurfaceContainer, shape: divider @@ -100,20 +98,7 @@ class QuestRow extends StatelessWidget { ), ), const SizedBox(height: 4), - // 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06). - if (quest.endsAt case final endsAt? when !done) - TimeAttackCountdown( - endsAt: endsAt, - builder: (context, left) => _sub( - left == null - ? context.l10n.questTimeAttackClosed - : context.l10n.questTimeAttackLeft( - formatLeft(left), - ), - ), - ) - else - _sub(quest.category ?? ''), + _sub(quest.category ?? ''), ], ), ), diff --git a/app/lib/features/my/profile_edit_screen.dart b/app/lib/features/my/profile_edit_screen.dart index aa733ae..f5f649a 100644 --- a/app/lib/features/my/profile_edit_screen.dart +++ b/app/lib/features/my/profile_edit_screen.dart @@ -12,6 +12,9 @@ import '../../core/ui/bottom_inset.dart'; /// 명세 `549:22136` — *완료 디폴트 비활성화 · 변경 사항 있을 경우 활성화 ·* /// ***중복된 닉네임일 경우 닉네임 필드 아래 헬프 텍스트로 오류 메시지 노출*** /// +/// 중복 오류는 **별첨 B 「닉네임」(2~20자 · 중복 허용 · 내부 UID 식별, 원장 B-52)** 이 대체했다 — +/// 중복을 이유로 막지 않는다. 헬프 텍스트 자리는 길이 · 예약어 오류에 쓴다. +/// /// 사진은 시안대로 MY 의 카메라 배지 → [ProfilePhotoScreen] 에서 바꾼다. class ProfileEditScreen extends StatefulWidget { const ProfileEditScreen({ diff --git a/app/lib/features/my/settings_screen.dart b/app/lib/features/my/settings_screen.dart index 2a7898f..2e84259 100644 --- a/app/lib/features/my/settings_screen.dart +++ b/app/lib/features/my/settings_screen.dart @@ -353,6 +353,8 @@ class _SettingsScreenState extends State { /// 서비스 탈퇴 — 명세 `554:24885` 「연동 상태값이 있을 경우 비정상 탈퇴 처리 · 게시물 보존 · /// 탈퇴 처리 후 앱 종료」. 연동이 남아 있으면 비정상 탈퇴 안내(`554:24908` · `374:14699`)만 띄운다. /// 서버도 같은 조건을 다시 본다(E-AUTH-14). + /// 「연동 상태값」을 로그인 제공자 **외 추가 연결**로 읽는다 — 별첨 B 로 소셜 전용 로그인이라 + /// 문자 그대로면 모든 계정이 비정상 탈퇴가 된다. 해석 확인 Q-E9 회신 대기(임시값). Future _withdraw() async { final l10n = context.l10n; if (_linked.values.where((e) => e != null).length > 1) { diff --git a/app/lib/features/notification/notification_data.dart b/app/lib/features/notification/notification_data.dart index 73ceb26..51551ab 100644 --- a/app/lib/features/notification/notification_data.dart +++ b/app/lib/features/notification/notification_data.dart @@ -17,7 +17,11 @@ enum NotificationKind { like(Icons.favorite_border), comment(Icons.mode_comment_outlined), campaign(Icons.campaign_outlined), - quest(Icons.checklist_rtl); + quest(Icons.checklist_rtl), + + /// 행성 진화 참여 알림(9/21 회신 「당신도 이 진화에 함께했습니다」). Figma `518:88353` 6종 밖이라 + /// 이동하지 않는다 — 예전에는 아이템 사용으로 떨어져 노란 아이템 배지 · 「아이템 사용」으로 보였다. + evolution(Icons.public); const NotificationKind(this.icon); @@ -30,12 +34,13 @@ enum NotificationKind { comment => appL10n.notifKindComment, campaign => appL10n.notifKindCampaign, quest => appL10n.notifKindQuest, + evolution => appL10n.notifKindEvolution, }; /// 눌러서 갈 곳. `null` 이면 이동하지 않는다 — 명세가 이클립스와 아이템 /// 사용을 *클릭 이동 X* 로 정했다. String? get target => switch (this) { - eclipse || itemUsed => null, + eclipse || itemUsed || evolution => null, like || comment => appL10n.notifTargetPost, campaign => appL10n.notifTargetCampaign, quest => appL10n.notifTargetQuest, @@ -66,6 +71,7 @@ enum NotificationKind { 'comment' => NotificationKind.comment, 'campaign' => NotificationKind.campaign, 'quest' => NotificationKind.quest, + 'evolution' => NotificationKind.evolution, _ => NotificationKind.itemUsed, }; } diff --git a/app/lib/features/notification/notification_screen.dart b/app/lib/features/notification/notification_screen.dart index e0bffca..95e700a 100644 --- a/app/lib/features/notification/notification_screen.dart +++ b/app/lib/features/notification/notification_screen.dart @@ -70,44 +70,52 @@ class _NotificationScreenState extends State { } final groups = groupNotifications(items); - return Scaffold( - appBar: AppBar( - title: Text(context.l10n.homeNotificationsTooltip), - // 명세 — 뒤로가기 시 읽음 상태값을 돌려준다. - leading: BackButton( - onPressed: () => Navigator.of(context).pop({..._read}), + // 명세 `518:88347` — 뒤로가기는 *이전 화면 이동 및 읽음 상태값 반환*. 시스템 뒤로가기(Android + // 제스처 · iOS 스와이프)도 같은 값을 돌려준다. + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) Navigator.of(context).pop({..._read}); + }, + child: Scaffold( + appBar: AppBar( + title: Text(context.l10n.homeNotificationsTooltip), + // 명세 — 뒤로가기 시 읽음 상태값을 돌려준다. + leading: BackButton( + onPressed: () => Navigator.of(context).pop({..._read}), + ), ), - ), - body: items.isEmpty - ? Center(child: Text(context.l10n.notifEmpty, style: t.bodyMedium)) - : ListView( - padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), - children: [ - for (final group in groups) - // 시안 날짜 묶음 — 위아래 20 · 머리글과 항목 사이 20 · 항목 사이 24 · 아래 #1a1a1a 줄. - Container( - padding: const EdgeInsets.symmetric(vertical: 20), - foregroundDecoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: Color(0xFF1A1A1A)), + body: items.isEmpty + ? Center(child: Text(context.l10n.notifEmpty, style: t.bodyMedium)) + : ListView( + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + children: [ + for (final group in groups) + // 시안 날짜 묶음 — 위아래 20 · 머리글과 항목 사이 20 · 항목 사이 24 · 아래 #1a1a1a 줄. + Container( + padding: const EdgeInsets.symmetric(vertical: 20), + foregroundDecoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFF1A1A1A)), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + group.label, + style: _style(_grey, height: 24 / 14), + ), + for (final (i, item) in group.items.indexed) ...[ + SizedBox(height: i == 0 ? 20 : 24), + _tile(item), + ], + ], ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - group.label, - style: _style(_grey, height: 24 / 14), - ), - for (final (i, item) in group.items.indexed) ...[ - SizedBox(height: i == 0 ? 20 : 24), - _tile(item), - ], - ], - ), - ), - ], - ), + ], + ), + ), ); } @@ -187,8 +195,9 @@ class _NotificationScreenState extends State { } final screen = _screenFor(item); if (screen == null || !mounted) return; - await Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => screen)); + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => screen)); } } @@ -211,6 +220,7 @@ TextStyle _style(Color color, {double height = 1.3}) => TextStyle( NotificationKind.campaign => (Colors.black, _icon('message_circle')), NotificationKind.like => (const Color(0xFFFF5151), _icon('heart')), NotificationKind.eclipse => (Colors.black, _icon('planet')), + NotificationKind.evolution => (Colors.black, _icon('planet')), NotificationKind.itemUsed => (const Color(0xFFCEBF3B), _icon('water_ec')), NotificationKind.quest => ( AppColors.cosmicSurfaceContainer, diff --git a/app/lib/features/planet/fpi_ranking_screen.dart b/app/lib/features/planet/fpi_ranking_screen.dart index f62b3a6..ef9f85f 100644 --- a/app/lib/features/planet/fpi_ranking_screen.dart +++ b/app/lib/features/planet/fpi_ranking_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/tokens.dart'; +import '../../core/ui/planet_25d.dart' show PlanetArt; import 'planet_data.dart'; import 'widgets/action_sheet.dart'; import '../../core/i18n/app_locale.dart'; @@ -32,10 +33,38 @@ class FpiRankingScreen extends StatefulWidget { } class _FpiRankingScreenState extends State { - late final Future _ranking = widget.repository.planetRanking( + /// 명세 `546:14639` — 최초 30개, 스크롤 시 10개씩. + int _limit = 30; + late Future _ranking = widget.repository.planetRanking( metric: widget.metric, + limit: _limit, ); + Future _load(int limit) => + widget.repository.planetRanking(metric: widget.metric, limit: limit); + + bool _loadingMore = false; + + /// 지금 장이 꽉 찼을 때만 10개 더 읽는다(덜 찼으면 끝이다). + Future _next() async { + if (_loadingMore) return; + _loadingMore = true; + try { + final now = await _ranking; + if (now.rows.length < _limit || !mounted) return; + final next = _load(_limit + 10); + setState(() { + _limit += 10; + _ranking = next; + }); + await next; + } catch (_) { + // 다음 장 실패는 앞 장을 그대로 둔다. + } finally { + _loadingMore = false; + } + } + void _openUsers(FpiRankRow planet) => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( @@ -57,6 +86,9 @@ class _FpiRankingScreenState extends State { myLabel: context.l10n.rankMyPlanet(widget.metric.label), emptyLabel: context.l10n.rankPlanetEmpty, onTapRow: _openUsers, + // 명세 `546:14629` — 소속 행성 랭킹 영역도 「사용자 랭킹 화면 이동」. + onTapMine: _openUsers, + onEnd: _next, planets: true, ), ); @@ -102,18 +134,45 @@ class FpiUserRankingScreen extends StatefulWidget { class _FpiUserRankingScreenState extends State { FpiRankScope _scope = FpiRankScope.live; - late Future _ranking = _load(); + int _limit = 30; + late Future _ranking = _load(_limit); - Future _load() => switch (_scope) { + Future _load(int limit) => switch (_scope) { FpiRankScope.live => widget.repository.userRanking( metric: widget.metric, fandomId: widget.fandomId, + limit: limit, ), FpiRankScope.byCountry => widget.repository.countryRanking( metric: widget.metric, + limit: limit, ), }; + /// 국가별은 명세 `546:14713` 「최초 30개 노출 스크롤시 10개씩」. 실시간 사용자 랭킹은 명세에 수가 없어 + /// 같은 방식으로 이어 읽는다. + bool _loadingMore = false; + + /// 지금 장이 꽉 찼을 때만 10개 더 읽는다(덜 찼으면 끝이다). + Future _next() async { + if (_loadingMore) return; + _loadingMore = true; + try { + final now = await _ranking; + if (now.rows.length < _limit || !mounted) return; + final next = _load(_limit + 10); + setState(() { + _limit += 10; + _ranking = next; + }); + await next; + } catch (_) { + // 다음 장 실패는 앞 장을 그대로 둔다. + } finally { + _loadingMore = false; + } + } + @override Widget build(BuildContext context) => Scaffold( // 시안 `373:436695` — 제목 「USER RANKING」, 범위 필터는 본문 「실시간 랭킹 ⌄」. @@ -130,6 +189,8 @@ class _FpiUserRankingScreenState extends State { emptyLabel: context.l10n.rankEmpty, scopeLabel: context.l10n.rankScope(_scope.label), onPickScope: _pickScope, + onEnd: _next, + flags: _scope == FpiRankScope.byCountry, ), ); @@ -143,7 +204,8 @@ class _FpiUserRankingScreenState extends State { if (next != null && next != _scope && mounted) { setState(() { _scope = next; - _ranking = _load(); + _limit = 30; + _ranking = _load(_limit); }); } } @@ -162,8 +224,20 @@ class _RankingBody extends StatelessWidget { this.planets = false, this.scopeLabel, this.onPickScope, + this.onTapMine, + this.onEnd, + this.flags = false, }); + /// 내 줄 카드를 눌렀을 때. + final ValueChanged? onTapMine; + + /// 목록 끝 가까이 스크롤했을 때 — 다음 10개. + final VoidCallback? onEnd; + + /// 국가 랭킹 — 아바타 자리에 국기(명세 `546:14713` 「국기 이미지」). + final bool flags; + final Future future; final String myLabel; final String emptyLabel; @@ -180,7 +254,8 @@ class _RankingBody extends StatelessWidget { Widget build(BuildContext context) => FutureBuilder( future: future, builder: (context, snap) { - if (snap.connectionState != ConnectionState.done) { + // 다음 장을 읽는 동안에는 앞 장을 그대로 둔다 — 바퀴로 바꾸면 스크롤 자리가 사라진다. + if (!snap.hasData && snap.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final data = snap.data; @@ -189,84 +264,115 @@ class _RankingBody extends StatelessWidget { } final podium = scopeLabel != null && data.rows.length >= 3; final rows = podium ? data.rows.skip(3).toList() : data.rows; - return ListView( - padding: const EdgeInsets.all(AppSpace.s4), - children: [ - // 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다. - if (data.refreshMinutes case final minutes?) ...[ - Text( - [ - if (data.updatedAt case final at?) - context.l10n.planetLastUpdated( - '${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}', - ), - context.l10n.rankRefreshEvery('$minutes'), - ].join(' · '), - style: _rt(12, AppColors.onCosmicMuted, FontWeight.w500), - ), - const SizedBox(height: AppSpace.s3), - ], - if (data.mine case final me?) ...[ - Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)), - const SizedBox(height: AppSpace.s4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( + return NotificationListener( + onNotification: (n) { + if (n.metrics.extentAfter < 200) onEnd?.call(); + return false; + }, + child: ListView( + padding: const EdgeInsets.all(AppSpace.s4), + children: [ + // 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다. + if (data.refreshMinutes case final minutes?) ...[ + Text( + [ + if (data.updatedAt case final at?) + context.l10n.planetLastUpdated( + '${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}', + ), + context.l10n.rankRefreshEvery('$minutes'), + ].join(' · '), + style: _rt(12, AppColors.onCosmicMuted, FontWeight.w500), + ), + const SizedBox(height: AppSpace.s3), + ], + if (data.mine case final me?) ...[ + Text( + myLabel, + style: _rt(24, AppColors.onCosmic, FontWeight.w500), + ), + const SizedBox(height: AppSpace.s4), + Material( color: const Color(0xFF19191C), borderRadius: BorderRadius.circular(8), - ), - child: _RankRow(row: me, index: -1, planet: planets, mark: false), - ), - const SizedBox(height: 24), - ], - if (scopeLabel case final label?) ...[ - InkWell( - onTap: onPickScope, - child: Row( - children: [ - Text( - label, - style: _rt( - 18, - AppColors.onCosmic, - FontWeight.w600, - height: null, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTapMine == null ? null : () => onTapMine!(me), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: _RankRow( + row: me, + index: -1, + planet: planets, + flag: flags, + mark: false, ), ), - const SizedBox(width: 4), - const Icon( - Icons.keyboard_arrow_down, - size: 20, - color: AppColors.onCosmic, - ), - ], + ), ), - ), - const SizedBox(height: 12), - ], - if (data.rows.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 48), - child: Center(child: Text(emptyLabel)), - ), - if (podium) ...[ - _Podium(rows: data.rows.take(3).toList()), - const SizedBox(height: 12), - ], - for (final (i, row) in rows.indexed) - InkWell( - onTap: onTapRow == null ? null : () => onTapRow!(row), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: _RankRow(row: row, index: i), + const SizedBox(height: 24), + ], + if (scopeLabel case final label?) ...[ + InkWell( + onTap: onPickScope, + child: Row( + children: [ + Text( + label, + style: _rt( + 18, + AppColors.onCosmic, + FontWeight.w600, + height: null, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.keyboard_arrow_down, + size: 20, + color: AppColors.onCosmic, + ), + ], + ), ), - ), - ], + const SizedBox(height: 12), + ], + if (data.rows.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Center(child: Text(emptyLabel)), + ), + if (podium) ...[ + _Podium(rows: data.rows.take(3).toList()), + const SizedBox(height: 12), + ], + for (final (i, row) in rows.indexed) + InkWell( + onTap: onTapRow == null ? null : () => onTapRow!(row), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: _RankRow(row: row, index: i, planet: planets, flag: flags), + ), + ), + ], + ), ); }, ); } +/// 국가 코드 → 국기 이모지. 미지정(`ZZ`) · 형식 밖은 흰 깃발. +String flagOf(String code) { + final c = code.toUpperCase(); + if (c.length != 2 || c == 'ZZ' || !RegExp(r'^[A-Z]{2}$').hasMatch(c)) { + return '\u{1F3F3}'; + } + return String.fromCharCodes(c.codeUnits.map((u) => 0x1F1E6 + u - 65)); +} + TextStyle _rt( double size, Color color, @@ -302,11 +408,14 @@ const _avatarColors = [ Color(0xFFD2F5B8), ]; -Widget _avatar(int index, double size, {bool planet = false}) => planet - ? Image.asset( - 'assets/figma/planet-detail-hero.png', - width: size, - height: size, +Widget _avatar(int index, double size, {bool planet = false, FpiRankRow? row}) => planet + ? SizedBox.square( + dimension: size, + child: PlanetArt( + fallback: 'assets/figma/planet-detail-hero.png', + planetType: row?.planetType, + colorKey: row?.colorKey, + ), ) : Container( width: size, @@ -348,12 +457,14 @@ class _RankRow extends StatelessWidget { required this.row, required this.index, this.planet = false, + this.flag = false, this.mark = true, }); final FpiRankRow row; final int index; final bool planet; + final bool flag; /// 목록 안 내 행에 「(나)」를 붙인다. 위 내 줄 카드는 시안대로 이름만 둔다. final bool mark; @@ -381,7 +492,14 @@ class _RankRow extends StatelessWidget { // 사본 — 40 프레임 가운데 지름 36 원. SizedBox.square( dimension: 40, - child: Center(child: _avatar(index, 36, planet: planet)), + child: Center( + child: flag + ? Text( + flagOf(row.id), + style: const TextStyle(fontSize: 28, height: 1), + ) + : _avatar(index, 36, planet: planet, row: row), + ), ), const SizedBox(width: 6), Expanded( diff --git a/app/lib/features/planet/planet_data.dart b/app/lib/features/planet/planet_data.dart index 7424daf..f860981 100644 --- a/app/lib/features/planet/planet_data.dart +++ b/app/lib/features/planet/planet_data.dart @@ -32,6 +32,26 @@ enum FpiMetric { final String label; } +/// 팬덤 규모 — Figma 행성 정보 영역 `544:19765` 「소형 : 1,000명 미만 · 중형 : 50,000명 미만 · +/// 대형 : 50,000명 이상」. 기준은 가입자 수다(행성 상세 · GALAXY 검색 결과가 같이 쓴다). +enum FandomSize { + small, + medium, + large; + + static FandomSize of(int members) => members < 1000 + ? small + : members < 50000 + ? medium + : large; + + String get label => switch (this) { + small => appL10n.planetSizeSmall, + medium => appL10n.planetSizeMedium, + large => appL10n.planetSizeLarge, + }; +} + /// 지표별 값. 명세 — *값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*. class FpiScore { const FpiScore({ @@ -93,8 +113,14 @@ class FpiRankRow { required this.value, this.change = 0, this.isMine = false, + this.planetType, + this.colorKey, }); + /// 행성 랭킹 줄의 행성 그림(Figma `546:14629` 「행성 이미지」) — 생성 때 고른 형태 · 색. 없으면 기본 그림. + final String? planetType; + final String? colorKey; + /// 행성 랭킹이면 `fandomId`, 사용자 랭킹이면 `uid`, 국가 랭킹이면 국가 코드. final String id; @@ -272,8 +298,13 @@ class PlanetDetail { this.growthPop = 0, this.nebulaTargetPop = 1000, this.nebulaTargetMembers = 10, + this.isOwner = false, }); + /// 내가 만든 행성/성운인가(`fandoms.founderUid`). Figma `544:19765` 「가입 버튼 - 내가 생성했을 경우 + /// hide」 · `543:19721` 「(타인 행성) 신고/차단 · (내 행성) 편집」 — 편집은 C-50 으로 두지 않는다. + final bool isOwner; + /// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`). final int nebulaTargetPop; final int nebulaTargetMembers; diff --git a/app/lib/features/planet/planet_detail_screen.dart b/app/lib/features/planet/planet_detail_screen.dart index 047245e..d8bc157 100644 --- a/app/lib/features/planet/planet_detail_screen.dart +++ b/app/lib/features/planet/planet_detail_screen.dart @@ -148,7 +148,8 @@ class _PlanetDetailScreenState extends State { actions: [ IconButton( tooltip: context.l10n.commonMore, - onPressed: () => _showMore(context, d.name), + onPressed: () => + _showMore(context, d.name, mine: d.isOwner), icon: const ImageIcon( AppIcons.ellipsisVertical, size: 24, @@ -169,6 +170,7 @@ class _PlanetDetailScreenState extends State { ownerName: d.ownerName, ownerPhoto: d.ownerPhoto, joined: d.joined, + isOwner: d.isOwner, onJoin: d.joined ? _leave : _join, onOpenHistory: () => Navigator.of(context).push( MaterialPageRoute( @@ -209,7 +211,9 @@ class _PlanetDetailScreenState extends State { }, ); - void _showMore(BuildContext context, String name) => + /// Figma `543:19721` — 더보기: (타인 행성) 신고/차단 · (내 행성) 편집. 편집은 행성 사용자 커스텀이라 + /// 두지 않는다(원장 C-50 「생성 후 사용자가 외형을 바꾸는 진입점 0건」). 내 행성에는 신고 · 차단도 없다. + void _showMore(BuildContext context, String name, {required bool mine}) => showActionSheet(context, [ // 팬덤 역사는 시안 밖 기능이라 남기되 중립색으로 둔다. ActionSheetItem(context.l10n.planetPassportHistory, () { @@ -222,14 +226,16 @@ class _PlanetDetailScreenState extends State { ), ); }, destructive: false), - ActionSheetItem( - context.l10n.reportTitle, - () => _moderate(context, 'report', name), - ), - ActionSheetItem( - context.l10n.commonBlockAction, - () => _moderate(context, 'block', name), - ), + if (!mine) ...[ + ActionSheetItem( + context.l10n.reportTitle, + () => _moderate(context, 'report', name), + ), + ActionSheetItem( + context.l10n.commonBlockAction, + () => _moderate(context, 'block', name), + ), + ], ]); Future _moderate( @@ -459,6 +465,7 @@ class _FpiTab extends StatelessWidget { EclipseBanner( repository: eclipse, fandomId: detail.fandomId, + nebula: detail.isNebula, margin: const EdgeInsets.only(bottom: AppSpace.s3), ), Container( diff --git a/app/lib/features/planet/planet_repository_firestore.dart b/app/lib/features/planet/planet_repository_firestore.dart index 7b740e2..ed319b2 100644 --- a/app/lib/features/planet/planet_repository_firestore.dart +++ b/app/lib/features/planet/planet_repository_firestore.dart @@ -177,6 +177,12 @@ class FirestorePlanetRepository implements PlanetRepository { final member = memberSnap.data() ?? const {}; final posts = await postsF; final targets = nebulaTargetsFrom((await policyF).data()); + // 생성자 = `founderUid`(성운 생성자 · 탄생 전 탈퇴 시 승계자, C-100). 프로필 · 닉네임은 그 멤버 문서에서 + // 읽는다(Figma `544:19765` 「생성자 프로필 · 닉네임」). 떠났거나 시드 행성이면 줄을 그리지 않는다. + final founderUid = fandom.strOr('founderUid'); + final founder = founderUid.isEmpty + ? null + : (await db.doc('fandoms/$fandomId/members/$founderUid').get()).data(); final axes = _map(fandom, 'fpiAxes'); final ranks = _map(fandom, 'fpiRanks'); @@ -199,6 +205,8 @@ class FirestorePlanetRepository implements PlanetRepository { _ => null, }; return PlanetDetail( + isOwner: founderUid.isNotEmpty && founderUid == uid, + ownerName: founder == null ? null : publicName(founder['nickname']), joined: memberSnap.exists, stage: stage, fandomId: fandomId, @@ -325,6 +333,8 @@ class FirestorePlanetRepository implements PlanetRepository { // 순위 동향 = 직전 순위 − 현재 순위. 둘 다 서버가 적은 값이다. change: _map(data, 'fpiRanksPrev').intOr(axis, rank) - rank, isMine: id == fandomId, + planetType: data['planetType'] is String ? data['planetType'] as String : null, + colorKey: data['colorKey'] is String ? data['colorKey'] as String : null, ); } @@ -335,7 +345,11 @@ class FirestorePlanetRepository implements PlanetRepository { required String fandomId, int limit = 30, }) async { - final ranking = await _userRanking(metric: metric, fandomId: fandomId, limit: limit); + final ranking = await _userRanking( + metric: metric, + fandomId: fandomId, + limit: limit, + ); final fandom = (await _backend.db.doc('fandoms/$fandomId').get()).data(); return ranking.withUpdate(_time(fandom?['memberRankedAt']), 5); } diff --git a/app/lib/features/planet/widgets/fandom_size_badge.dart b/app/lib/features/planet/widgets/fandom_size_badge.dart new file mode 100644 index 0000000..a54d13d --- /dev/null +++ b/app/lib/features/planet/widgets/fandom_size_badge.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/tokens.dart'; +import '../planet_data.dart'; + +/// 팬덤 규모 칩 — Figma 「대형 팬덤」 `#AEC054` · 「중형 / 소형 팬덤」 `#54C07D`(바탕 10%, r4). +/// 행성 상세 히어로는 12/500 + 1px 테두리, GALAXY 목록은 10/500. +class FandomSizeBadge extends StatelessWidget { + const FandomSizeBadge({super.key, required this.members, this.large = false}); + + final int members; + + /// 행성 상세 히어로 크기(12 · 테두리). + final bool large; + + @override + Widget build(BuildContext context) { + final size = FandomSize.of(members); + final color = size == FandomSize.large + ? const Color(0xFFAEC054) + : const Color(0xFF54C07D); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + border: large ? Border.all(color: color) : null, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + size.label, + style: TextStyle( + fontFamily: AppFonts.body, + fontSize: large ? 12 : 10, + fontWeight: FontWeight.w500, + height: 1, + color: color, + ), + ), + ); + } +} diff --git a/app/lib/features/planet/widgets/planet_detail_hero.dart b/app/lib/features/planet/widgets/planet_detail_hero.dart index 1aa1891..b73a89a 100644 --- a/app/lib/features/planet/widgets/planet_detail_hero.dart +++ b/app/lib/features/planet/widgets/planet_detail_hero.dart @@ -7,6 +7,7 @@ import '../../../core/ui/live_planet.dart'; import '../../../core/data/planet_visual_state.dart'; import '../../home/home_screen.dart' show formatPop; import '../../../core/i18n/app_locale.dart'; +import 'fandom_size_badge.dart'; /// 행성 상세 히어로 — 시안 `373:441117` 의 `Frame 1984081117`(375×543). /// @@ -24,15 +25,20 @@ class PlanetDetailHero extends StatelessWidget { this.nebula = false, this.onHeroParticipate, this.onJoin, + this.isOwner = false, this.onOpenHistory, this.fandomId = '', this.watchPlanet = noPlanetWatch, this.stage, this.ownerName, this.ownerPhoto, - this.sizeLabel, + this.onOpenOwner, }); + /// 개설자 줄을 눌렀을 때 — Figma `544:19765` 「프로필 및 닉네임 클릭 시 해당 사용자의 MY로 이동」. + /// 타인 프로필 화면(`549:21969`)이 생기면 연결한다(3차 MY). null 이면 누르지 않는다. + final VoidCallback? onOpenOwner; + final VoidCallback? onOpenHistory; final String fandomId; final PlanetWatch watchPlanet; @@ -46,6 +52,9 @@ class PlanetDetailHero extends StatelessWidget { /// 알약을 눌렀을 때 — 「가입하기」면 가입, 「가입중」이면 탈퇴 시트. null 이면 상태만 보인다. final VoidCallback? onJoin; + /// 내가 만든 행성이면 가입 알약을 숨긴다(Figma `544:19765` 「가입 버튼 - 내가 생성했을 경우 hide」). + final bool isOwner; + /// 성운 상세(`373:442708`)는 규모 칩과 FPI 줄이 없다. final bool nebula; @@ -56,9 +65,6 @@ class PlanetDetailHero extends StatelessWidget { /// 개설자 사진(URL 또는 에셋). 없으면 회색 원. final String? ownerPhoto; - /// 팬덤 규모 라벨. 정책 §P-08 등급명이 붙기 전까지 시안 문구를 쓴다. - final String? sizeLabel; - static const double designHeight = 543; /// 시안의 상태 표시줄 + 내비 높이. 본문은 그 아래 439 안에 가운데 놓인다. @@ -74,7 +80,8 @@ class PlanetDetailHero extends StatelessWidget { @override Widget build(BuildContext context) { - // 1인당 화력 — 시안 「1.44」. 총 FPI 를 멤버 수로 나눈 값이며 정책 확정 전 표시용이다. + // 1인당 화력 — 시안 「1.44」. Figma `544:19765` 정의(「모든 포인트 획득, 소비 활동 및 1인당 활동 값을 + // 집계한 값」)와 FPI 원천(C-34 활동 기준)이 갈려 해석 확인 중(전수대조 영역A Q-A23). 회신 전 임시로 총 FPI ÷ 멤버 수. final perHead = members == 0 ? 0.0 : totalFpi / members; const info = Color(0xFFDEDEDE); @@ -111,31 +118,36 @@ class PlanetDetailHero extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (ownerName case final owner?) ...[ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - CircleAvatar( - radius: 13, - backgroundColor: const Color(0xFFD9D9D9), - backgroundImage: switch (ownerPhoto) { - final p? when p.startsWith('http') => NetworkImage(p), - final p? => AssetImage(p), - null => null, - }, - ), - const SizedBox(width: 9), - Text( - owner, - style: const TextStyle( - fontFamily: AppFonts.display, - fontWeight: FontWeight.w400, - fontSize: 16, - height: 1.3, - letterSpacing: -0.4, - color: AppColors.onCosmic, + GestureDetector( + onTap: onOpenOwner, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 13, + backgroundColor: const Color(0xFFD9D9D9), + backgroundImage: switch (ownerPhoto) { + final p? when p.startsWith('http') => NetworkImage( + p, + ), + final p? => AssetImage(p), + null => null, + }, ), - ), - ], + const SizedBox(width: 9), + Text( + owner, + style: const TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w400, + fontSize: 16, + height: 1.3, + letterSpacing: -0.4, + color: AppColors.onCosmic, + ), + ), + ], + ), ), const SizedBox(height: 25), ], @@ -159,21 +171,8 @@ class PlanetDetailHero extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (!nebula) ...[ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0x1A54C07D), - border: Border.all(color: const Color(0xFF54C07D)), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - sizeLabel ?? context.l10n.planetSizeMedium, - style: _body(12, const Color(0xFF54C07D)), - ), - ), + // 소형 · 중형 · 대형 — 가입자 수 기준(Figma `544:19765`). + FandomSizeBadge(members: members, large: true), const SizedBox(width: 4), ], Image.asset( @@ -227,31 +226,32 @@ class PlanetDetailHero extends StatelessWidget { ), const SizedBox(height: 12), ], - Semantics( - button: onJoin != null, - child: GestureDetector( - onTap: onJoin, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: Text( - joined - ? context.l10n.planetJoined - : context.l10n.planetJoin, - style: _body( - 14, - const Color(0xFFD5D7D9), - ).copyWith(letterSpacing: -0.35), + if (!isOwner) + Semantics( + button: onJoin != null, + child: GestureDetector( + onTap: onJoin, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + joined + ? context.l10n.planetJoined + : context.l10n.planetJoin, + style: _body( + 14, + const Color(0xFFD5D7D9), + ).copyWith(letterSpacing: -0.35), + ), ), ), ), - ), ], ), ), diff --git a/app/lib/features/quest/place_picker_screen.dart b/app/lib/features/quest/place_picker_screen.dart index 95addba..9db3e99 100644 --- a/app/lib/features/quest/place_picker_screen.dart +++ b/app/lib/features/quest/place_picker_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -8,10 +9,13 @@ import 'package:latlong2/latlong.dart'; import '../../core/theme/tokens.dart'; import '../../core/i18n/app_locale.dart'; import 'quest_data.dart'; +import 'quest_evidence.dart'; /// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`(지도 + 핀 주소 + 「선택 완료」). /// -/// 지도를 누르면 그 자리에 핀을 꽂고 주소를 보여 준다. 운영이 등록한 장소는 지도 위 표시와 +/// 명세 `523:18207` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를 +/// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다. +/// 운영이 등록한 장소는 지도 위 표시와 /// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 B — 장소 등록 주체 권고안: /// 사용자 핀 → 장소 생성 · 운영 장소 우선 노출). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다. /// @@ -42,6 +46,10 @@ class _PlacePickerScreenState extends State { String _query = ''; QuestPlace? _selected; + /// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」). + bool _resolving = false; + Timer? _settle; + List get _visible => widget.places .where((p) => _query.isEmpty || p.name.contains(_query)) .toList(); @@ -53,25 +61,65 @@ class _PlacePickerScreenState extends State { return PlacePickerScreen.fallbackCenter; } + @override + void initState() { + super.initState(); + _locate(); + } + + @override + void dispose() { + _settle?.cancel(); + super.dispose(); + } + + /// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(등록 장소 · 서울시청)에 둔다 — + /// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다. + Future _locate() async { + try { + final fix = await devicePlaceFix(); + if (!mounted || _selected != null) return; + final here = LatLng(fix.lat, fix.lng); + _map.move(here, _map.camera.zoom); + await _drop(here); + } catch (_) { + // 권한 거부 · 위치 꺼짐 · 플랫폼 구현 없음(테스트) — 기본 화면 유지. + } + } + void _choose(QuestPlace place) { - setState(() => _selected = place); + _settle?.cancel(); + setState(() { + _selected = place; + _resolving = false; + }); if (place.lat != null && place.lng != null) { _map.move(LatLng(place.lat!, place.lng!), _map.camera.zoom); } } - /// 지도를 누른 자리에 핀 — 주소는 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다. + /// 사용자가 지도를 움직이면 멈춘 뒤 가운데 좌표로 위치를 정한다(제스처가 아닌 이동 — 등록 장소 + /// 선택 · 현재 위치 이동 — 은 무시). + void _moved(MapCamera camera, bool hasGesture) { + if (!hasGesture) return; + _settle?.cancel(); + setState(() => _resolving = true); + _settle = Timer(const Duration(milliseconds: 400), () => _drop(camera.center)); + } + + /// 가운데 좌표의 주소 — 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다. Future _drop(LatLng point) async { final coords = '${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}'; - setState( - () => _selected = QuestPlace( + setState(() { + _resolving = true; + _selected = QuestPlace( id: '', name: coords, lat: point.latitude, lng: point.longitude, - ), - ); + ); + }); var address = coords; try { final marks = await Geocoding().placemarkFromCoordinates( @@ -86,14 +134,15 @@ class _PlacePickerScreenState extends State { // 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다. } if (!mounted || _selected?.lat != point.latitude) return; - setState( - () => _selected = QuestPlace( + setState(() { + _resolving = false; + _selected = QuestPlace( id: '', name: address, lat: point.latitude, lng: point.longitude, - ), - ); + ); + }); } @override @@ -140,7 +189,7 @@ class _PlacePickerScreenState extends State { options: MapOptions( initialCenter: _center, initialZoom: 15, - onTap: (_, point) => _drop(point), + onPositionChanged: _moved, ), children: [ TileLayer( @@ -163,17 +212,20 @@ class _PlacePickerScreenState extends State { ), ), ), - if (pinned != null && - pinned.lat != null && - pinned.lng != null) - Marker( - point: LatLng(pinned.lat!, pinned.lng!), + ], + ), + // 위치 마크 — *지도 영역 중간 정렬*. 핀 끝이 지도 가운데(선택 좌표)를 가리킨다. + const IgnorePointer( + child: Align( + child: Padding( + padding: EdgeInsets.only(bottom: 44), + child: SizedBox( width: 38, height: 44, - alignment: Alignment.topCenter, - child: const CustomPaint(painter: _PinPainter()), + child: CustomPaint(painter: _PinPainter()), ), - ], + ), + ), ), // OSM 타일 이용 조건 — 출처 표기. const Align( @@ -227,7 +279,7 @@ class _PlacePickerScreenState extends State { child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: FilledButton( - onPressed: pinned == null + onPressed: pinned == null || _resolving ? null : () => Navigator.of(context).pop(pinned), style: FilledButton.styleFrom( diff --git a/app/lib/features/quest/quest_ad_screens.dart b/app/lib/features/quest/quest_ad_screens.dart index 464b552..8d081c3 100644 --- a/app/lib/features/quest/quest_ad_screens.dart +++ b/app/lib/features/quest/quest_ad_screens.dart @@ -277,11 +277,25 @@ class QuestAdWarpScreen extends StatefulWidget { State createState() => _QuestAdWarpScreenState(); } -class _QuestAdWarpScreenState extends State { +class _QuestAdWarpScreenState extends State + with SingleTickerProviderStateMixin { String _message(BuildContext context) => widget.message ?? (widget._after ? context.l10n.adWarpAfter : context.l10n.adWarpBefore); + /// 명세 `518:88645` — 시청 전 *광고 시청을 북돋을 수 있는 짧은 애니메이션*, 시청 후 *서비스에 + /// 재몰입 할 수 있는 애니메이션*. 행성이 한 번 커지며 반 바퀴 돈다(한 번만 — 반복하지 않는다). + late final AnimationController _spin = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..forward(); + + @override + void dispose() { + _spin.dispose(); + super.dispose(); + } + @override void initState() { super.initState(); @@ -307,7 +321,17 @@ class _QuestAdWarpScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Image.asset('assets/figma/quest-warp-planet.png', width: 212, height: 212), + AnimatedBuilder( + animation: _spin, + builder: (_, child) { + final t = Curves.easeOutBack.transform(_spin.value); + return Transform.rotate( + angle: (widget._after ? -1 : 1) * (1 - _spin.value) * 3.14159, + child: Transform.scale(scale: 0.8 + 0.2 * t, child: child), + ); + }, + child: Image.asset('assets/figma/quest-warp-planet.png', width: 212, height: 212), + ), const SizedBox(height: 24), // 시안은 405 폭 한 줄이라 375 화면 밖으로 잘린다 — 좌우 16 안에서 가운데 두 줄로. // 행간은 한 줄이면 사본 값 1, 두 줄로 넘어갈 때만 1.4. diff --git a/app/lib/features/quest/quest_create_screen.dart b/app/lib/features/quest/quest_create_screen.dart index c9fbd8f..105ad1f 100644 --- a/app/lib/features/quest/quest_create_screen.dart +++ b/app/lib/features/quest/quest_create_screen.dart @@ -11,6 +11,7 @@ import 'quest_data.dart'; import 'widgets/quest_card_tile.dart'; import '../../core/i18n/app_locale.dart'; import '../../core/ui/bottom_inset.dart'; +import '../../core/ui/sheet_route.dart'; /// 퀘스트 생성 — 화면 대조 원장 F4 · Figma `254:164081`. /// @@ -51,6 +52,7 @@ class _QuestCreateScreenState extends State { QuestLink? _link; bool _checkingLink = false; bool _saving = false; + int? _myPop; @override void initState() { @@ -75,6 +77,9 @@ class _QuestCreateScreenState extends State { } Future _load() async { + widget.repository.spendableBalance().then((v) { + if (mounted) setState(() => _myPop = v); + }, onError: (_) {}); final catalog = await widget.repository.catalog(); if (mounted) setState(() => _catalog = catalog); } @@ -107,6 +112,8 @@ class _QuestCreateScreenState extends State { ? context.l10n.questPreviewHint : _title.text.trim(), categoryName: _activity?.name ?? _category?.name ?? context.l10n.questNoCategory, + // 명세 `523:18123` 미리보기 — *입력 상태에 따라 실시간 반영*. 고른 활동의 아이콘을 바로 보인다. + type: _activity == null ? QuestType.unknown : QuestType.parse(_activity!.key), author: QuestAuthor.user, stage: QuestStage.mine, goal: _needs('goal') ? _intOf(_goal) : 1, @@ -303,7 +310,12 @@ class _QuestCreateScreenState extends State { ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.place_outlined), - title: Text(_place?.name ?? context.l10n.questPickPlace), + // 명세 `523:18270` — *텍스트 넘칠 경우 ... 처리*. + title: Text( + _place?.name ?? context.l10n.questPickPlace, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), trailing: const Icon(Icons.chevron_right), onTap: _pickPlace, ), @@ -380,6 +392,20 @@ class _QuestCreateScreenState extends State { ), style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant), ), + // 명세 `523:18176` 완료 보상 설정 영역 — *보유 POP 값*. + if (_myPop != null) + Text.rich( + TextSpan( + text: context.l10n.commonMyPop, + children: [ + TextSpan( + text: formatPop(_myPop!), + style: const TextStyle(color: AppColors.cosmicBlue), + ), + ], + ), + style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant), + ), ] else Text( context.l10n.questNoReward, @@ -422,7 +448,8 @@ class _QuestCreateScreenState extends State { _places = places; } final picked = await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute(builder: (_) => PlacePickerScreen(places: _places)), + // 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*. + SheetRoute(builder: (_) => PlacePickerScreen(places: _places)), ); if (picked == null || !mounted) return; setState(() => _place = picked); @@ -448,8 +475,9 @@ class _QuestCreateScreenState extends State { final go = await showConfirmDialog( context, context.l10n.questCreateConfirm, - cancelLabel: context.l10n.commonKeepEditing, - confirmLabel: context.l10n.questCreate, + // 명세 `523:18176` 생성 안내 모달 — *취소 / 완료*(시안 `548:19548` · `548:19552`). + cancelLabel: context.l10n.commonCancel, + confirmLabel: context.l10n.commonDone, body: context.l10n.questCreateCost( _preview.title, formatPop(_intOf(_reward)), diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index 900cd42..d26f5cd 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -105,6 +105,18 @@ enum QuestType { _ => QuestType.unknown, }; + /// 목록 · 상세의 **카테고리** 값 — 명세 `518:88395` · `518:88417` *퀘스트 카테고리 - 등록된 카테고리값* + /// (시안 「건강」 · 「커뮤니티」 · 「출석체크」). 활동이 속한 분류는 `config/questCatalog` 기본값 + /// (`functions/src/quest/ugc.ts` — walk · shake → health, vote · sns · streaming → planet …)과 같다. + /// 출석 · 타임어택은 카탈로그 분류가 없어 유형 이름을 쓴다. + String get category => switch (this) { + steps || musicShake => appL10n.questCatHealth, + streaming || vote || sns => appL10n.questCatPlanet, + community => appL10n.questCatCommunity, + place => appL10n.questTypePlace, + _ => label, + }; + /// 사람 검토형(C-08) — 참여가 곧 지급이 아니라 제출이다. bool get reviewed => this == QuestType.vote || this == QuestType.sns; @@ -315,6 +327,14 @@ class QuestSubmission { now.difference(submittedAt!) > Duration(hours: hours); } +/// 사용자 생성 Quest 의 모집 인원이 다 찼는가 — 명세 `523:18176` *참여 인원 충족 시 퀘스트 종료*. +/// 서버 참여 수(`participantCount`)는 지급 · 검토 예약 때 오르고 최종 반려 때 내려간다. +bool questFull(QuestCard card) => + card.author == QuestAuthor.user && + card.capacity != null && + card.capacity! > 0 && + card.participants >= card.capacity!; + /// 목록 카드 하나. class QuestCard { const QuestCard({ @@ -333,12 +353,17 @@ class QuestCard { this.ugcAdPop = 10, this.endsAt, this.publishState = '', + this.authorName = '', }); final String id; final String title; final String categoryName; + /// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다. + /// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다. + final String authorName; + /// 내가 만든 Quest 의 공개 상태(`quests.status`) — `review` · `published` · `rejected` 등. /// 「내 퀘스트」 카드가 검수 중 · 반려를 공개된 것과 구분해 보인다(10/6 실기기 4-2). final String publishState; @@ -398,6 +423,7 @@ class QuestCard { adMultiplier: adMultiplier, ugcAdPop: ugcAdPop, endsAt: endsAt, + authorName: authorName, publishState: publishState, ); } @@ -508,9 +534,11 @@ enum QuestTab { bool accepts(QuestCard card) => switch (this) { QuestTab.all => true, - QuestTab.inProgress => card.stage == QuestStage.inProgress, - QuestTab.done => - card.stage == QuestStage.completed || card.stage == QuestStage.rewarded, + // 명세 `518:88405` — *진행중 - 진행중 탭 / 진행완료 - 진행중 탭 / 보상완료 - 완료 탭*. + // 보상을 받기 전(진행완료)까지는 진행중 탭에 두고, 받은 뒤에 완료 탭으로 옮긴다. + QuestTab.inProgress => + card.stage == QuestStage.inProgress || card.stage == QuestStage.completed, + QuestTab.done => card.stage == QuestStage.rewarded, QuestTab.mine => card.stage == QuestStage.mine, }; } @@ -610,6 +638,15 @@ abstract interface class QuestRepository { /// 모르면 null(헤더는 받은 값을 유지한다). Future popBalance(); + /// 퀘스트 생성에 쓸 수 있는 보유 POP(일반 + 기여 — 서버 예치 `spendSplit` 과 같은 합). 명세 + /// `523:18176` 완료 보상 설정 영역 *보유 POP 값*. 모르면 null. + Future spendableBalance(); + + /// 오늘 광고 추가 보상을 더 받을 수 있는가(일 한도 — B-03 · 10/3 통합회신 4절 「일 5회」). + /// 별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」 — 다 쓴 사람에게는 보상 안내 모달을 띄우지 않는다. + /// 판정은 서버(`fnAdReward`)가 다시 한다. 읽지 못하면 true(모달을 막지 않는다). + Future adAvailable(); + /// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다. /// /// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률 @@ -852,6 +889,12 @@ class SampleQuestRepository implements QuestRepository { @override Future popBalance() async => null; + @override + Future spendableBalance() async => null; + + @override + Future adAvailable() async => true; + /// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌 /// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다. @override diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart index f4e6c62..5c56901 100644 --- a/app/lib/features/quest/quest_detail_screen.dart +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -26,6 +26,7 @@ import 'quest_reward_screen.dart'; import 'widgets/quest_card_tile.dart'; import 'widgets/time_attack_countdown.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/sheet_route.dart'; /// 퀘스트 상세 — 화면 대조 원장 F4. /// @@ -118,6 +119,9 @@ class _QuestDetailScreenState extends State { return Scaffold( appBar: AppBar( actions: [ + // 명세 `548:19275` — 더보기는 *사용자 퀘스트 중 타인 퀘스트에서만 노출*. 내 퀘스트의 + // 수정 · 취소는 확정 정책(C-07 · B-30)이 더한 항목이라 생성자에게도 둔다. 운영 Quest 는 없음. + if (ugc) PopupMenuButton( tooltip: context.l10n.commonMore, icon: const ImageIcon( @@ -128,7 +132,7 @@ class _QuestDetailScreenState extends State { onSelected: (value) => switch (value) { 'edit' => _edit(detail), 'cancel' => _cancel(detail), - _ => _report(card.title), + _ => _report(card), }, itemBuilder: (_) => [ // 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07). @@ -146,10 +150,11 @@ class _QuestDetailScreenState extends State { value: 'cancel', child: Text(context.l10n.questCancel), ), - PopupMenuItem( - value: 'report', - child: Text(context.l10n.reportTitle), - ), + if (stage != QuestStage.mine) + PopupMenuItem( + value: 'report', + child: Text(context.l10n.reportTitle), + ), ], ), ], @@ -406,9 +411,11 @@ class _QuestDetailScreenState extends State { detail, stage, blocked, + // 정원이 찬 퀘스트도 종료다(명세 `523:18176` 종료 시점 1) — 시작 전인 사람만 막힌다. closed: - detail.endsAt != null && - !ServerClock.now().isBefore(detail.endsAt!), + (detail.endsAt != null && + !ServerClock.now().isBefore(detail.endsAt!)) || + (questFull(card) && stage == QuestStage.notStarted), ) : TimeAttackCountdown( endsAt: endsAt, @@ -959,9 +966,13 @@ class _QuestDetailScreenState extends State { // 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) — // 광고가 실패해도 기본 보상은 그대로다. - final bonus = - await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? - false; + // 일 한도를 다 쓴 사람에게는 모달을 띄우지 않는다(별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」) — + // 띄우면 광고를 끝까지 보고도 증분 0 이다. + final adOpen = await widget.repository.adAvailable(); + if (!mounted) return; + final bonus = adOpen && + (await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? + false); if (!mounted) return; int? adPop; if (bonus) { @@ -979,6 +990,13 @@ class _QuestDetailScreenState extends State { if (mounted) _toast(error.message); } if (!mounted) return; + } else if (_adShown) { + // 명세 `518:88645` — 광고 종료(100% 시청 또는 skip) 후 *광고 시청 후 애니메이션*. 끝까지 + // 보지 않았으니 증분은 없다(B-03) — 애니메이션만 거쳐 완료 화면으로 간다. + await _warp(QuestAdWarpScreen.after( + until: Future.delayed(const Duration(milliseconds: 1200)), + )); + if (!mounted) return; } } @@ -1003,7 +1021,11 @@ class _QuestDetailScreenState extends State { /// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면 /// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 · /// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다. + /// 이번 보상에서 광고가 실제로 화면에 떴는가 — skip 으로 닫았을 때도 시청 후 애니메이션을 거친다. + bool _adShown = false; + Future _watchAd(QuestReward reward) async { + _adShown = false; RewardedAdHandle? ad; BackendException? failure; final loading = widget.repository @@ -1030,6 +1052,7 @@ class _QuestDetailScreenState extends State { } return null; } + _adShown = true; final impression = await loaded.show(); if (impression == null && mounted) { _toast(context.l10n.adNotFinished); @@ -1065,14 +1088,16 @@ class _QuestDetailScreenState extends State { rootNavigator: true, ).push(MaterialPageRoute(builder: (_) => screen)); - Future _report(String title) async { + Future _report(QuestCard card) async { final reason = await Navigator.of(context, rootNavigator: true) .push( - MaterialPageRoute( + // 명세 `548:21389` — 신고 화면은 *페이지 아래 move out* 으로 닫힌다. + SheetRoute( builder: (_) => ReportScreen( + // 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*. 작성자 칸에 제목이 들어가던 것을 바로잡음. target: ModerationTarget( - authorName: title, - title: context.l10n.questNamed(title), + authorName: card.authorName, + title: card.title, ), ), ), diff --git a/app/lib/features/quest/quest_list_screen.dart b/app/lib/features/quest/quest_list_screen.dart index 22b98e7..1dfaffd 100644 --- a/app/lib/features/quest/quest_list_screen.dart +++ b/app/lib/features/quest/quest_list_screen.dart @@ -9,6 +9,7 @@ import 'quest_detail_screen.dart'; import 'widgets/quest_card_tile.dart'; import '../../core/i18n/app_locale.dart'; import '../../core/ui/bottom_inset.dart'; +import '../../core/ui/sheet_route.dart'; /// 퀘스트 목록 — 화면 대조 원장 F4. /// @@ -174,9 +175,11 @@ class _QuestListScreenState extends State { Future _report(QuestCard card) async { final reason = await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( + // 명세 `548:21389` — 신고 화면은 *페이지 아래 move out* 으로 닫힌다. + SheetRoute( builder: (_) => ReportScreen( - target: ModerationTarget(authorName: card.title, title: context.l10n.questNamed(card.title)), + // 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*(작성자 칸에 제목이 들어가던 것을 바로잡음). + target: ModerationTarget(authorName: card.authorName, title: card.title), ), ), ); @@ -381,11 +384,14 @@ class _QuestListScreenState extends State { Future _create() async { final id = await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( + // 명세 `523:18123` — 닫기는 *하단 move out 페이지 종료*. 아래에서 올라와 아래로 닫힌다. + SheetRoute( builder: (_) => QuestCreateScreen(repository: widget.repository), ), ); if (id != null && mounted) { + // 명세 `523:18176` 생성 안내 모달 — *완료 - 퀘스트 Create 후 이전 퀘스트의 내 퀘스트 탭으로 이동*. + setState(() => _tab = QuestTab.mine); // 공개 요청은 곧 공개가 아니다 — 운영 검수 뒤 공개된다(1주차 4항 · Q8). ScaffoldMessenger.of(context) ..hideCurrentSnackBar() diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart index d128889..0d9865e 100644 --- a/app/lib/features/quest/quest_repository_firestore.dart +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -133,7 +133,12 @@ class FirestoreQuestRepository implements QuestRepository { } on Exception { progress = const {}; } - cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest))); + final card = _card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)); + // 명세 `523:18176` 퀘스트 종료 시점 1 — *참여 인원 충족 시 퀘스트 종료*. 서버는 정원이 차면 + // 참여를 거부하므로(미배정 Pool 0), 아직 참여하지 않은 사람에게는 열린 퀘스트로 보이지 않게 한다. + // 이미 참여한 사람(진행중 · 완료 탭)과 생성자(내 퀘스트)에게는 그대로 남긴다. + if (questFull(card) && card.stage == QuestStage.notStarted) continue; + cards.add(card); } return cards; } @@ -198,7 +203,7 @@ class FirestoreQuestRepository implements QuestRepository { return QuestCard( id: id, title: questTitle(quest), - categoryName: type.label, + categoryName: type.category, type: type, author: authorUid.isEmpty ? QuestAuthor.admin : QuestAuthor.user, stage: authorUid == uid @@ -223,6 +228,7 @@ class FirestoreQuestRepository implements QuestRepository { // 열린 TIME ATTACK 회차의 마감. 있으면 카드 · 상세가 남은 시간을 세고 참여는 회차로 간다. endsAt: openTimeAttack(quest)?.endsAt, publishState: authorUid == uid ? quest.strOr('status') : '', + authorName: quest.strOr('authorName'), ); } @@ -513,6 +519,29 @@ class FirestoreQuestRepository implements QuestRepository { return wallet.data()?.intOr('activityBalance'); } + @override + Future adAvailable() async { + try { + final db = _backend.db; + final ad = (await db.doc('users/${_backend.requireUid}/questProgress/ad').get()).data(); + // 서버 `progress(uid, 'ad', dayKey(now))` 와 같은 규약 — KST 날짜가 다르면 오늘 0회. + final today = ServerClock.now().toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10); + if (ad == null || ad['dayKey'] != today) return true; + final limit = (await _policy()).intOr('adDailyLimit', 5); + return ad.intOr('count') < limit; + } on Exception { + return true; + } + } + + @override + Future spendableBalance() async { + final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get(); + final data = wallet.data(); + if (data == null) return null; + return data.intOr('activityBalance') + data.intOr('supportBalance'); + } + @override Stream progressStream(QuestCard card) { if (card.type != QuestType.steps) return const Stream.empty(); diff --git a/app/lib/features/quest/quest_reward_screen.dart b/app/lib/features/quest/quest_reward_screen.dart index 216b2fc..8f8fa33 100644 --- a/app/lib/features/quest/quest_reward_screen.dart +++ b/app/lib/features/quest/quest_reward_screen.dart @@ -59,7 +59,16 @@ class _QuestRewardScreenState extends State { QuestDetail get detail => widget.detail; int? get adBonusPop => widget.adBonusPop; - int? get rewardPop => widget.reward?.rewardedPop ?? detail.card.rewardPop; + /// 큰 숫자 — 운영 Quest 에서 광고를 봤으면 기본 보상 + 광고 증분 합계(명세 `518:88685` *광고 시청 + /// POP = 보상 POP × 2 or ×3*, 배수는 B-03 운영값). 사용자 생성 Quest 는 배수가 아니라 고정 증분 + /// (B-28 · C-02)이라 기본 보상만 크게 둔다. 두 값 모두 서버 지급액이고, 증분은 아래 줄에 기여 POP 으로 + /// 따로 적는다(C-67 · C-122 유형 구분). + int? get rewardPop { + final base = widget.reward?.rewardedPop ?? detail.card.rewardPop; + final ad = widget.adBonusPop; + if (base == null || ad == null || detail.card.author != QuestAuthor.admin) return base; + return base + ad; + } /// 결과 카드를 PNG 로 굽고 문구 · 해시태그(B-78)와 함께 OS 공유 시트로 보낸다. Future _share(BuildContext button) async { @@ -198,7 +207,10 @@ class _QuestRewardScreenState extends State { Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: FilledButton( - onPressed: () => Navigator.of(context).pop(true), + // 설명 프레임 `518:88326` 「확인 버튼 - 클릭 시, 홈 화면 이동」 — 퀘스트 상세 · 목록까지 + // 닫고 첫 화면(탭 셸)으로 돌아간다. 퀘스트는 HOME 에서만 열린다. + onPressed: () => + Navigator.of(context).popUntil((route) => route.isFirst), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(56), shape: RoundedRectangleBorder( diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index fd42886..8c2e2a2 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -65,6 +65,8 @@ "settingsInquiry": "1:1 inquiry", "settingsAppVersion": "App version", "settingsLatest": "Latest", + "updateAvailableTitle": "A new version is available", + "updateAvailableBody": "Please update FANUZU to the latest version ({version}) from the store.", "settingsLogout": "Log out", "settingsLogoutConfirm": "Do you want to log out?", "settingsWithdraw": "Delete account", @@ -246,6 +248,7 @@ "nebulaHeadline": "Name your fandom planet.", "nebulaBody": "If founding members reach the POP goal within {days} days,\nit's born as an official planet!\nMembers who joined the evolution get a commemorative badge.", "nebulaNameTaken": "This name is already taken.", + "nebulaNameInvalid": "Use 2–20 letters, numbers or spaces (no symbols, emoji, or double/leading/trailing spaces).", "nebulaNameHint": "Fandom name", "nebulaGoal": "Goal ", "nebulaFounders": " / Founders", @@ -443,6 +446,7 @@ "notifKindComment": "Comments", "notifKindCampaign": "Campaign goal", "notifKindQuest": "Quest complete", + "notifKindEvolution": "Planet evolution", "notifTargetPost": "post", "notifTargetCampaign": "campaign", "notifTargetQuest": "quest details", @@ -568,6 +572,14 @@ "blockUnavailable": "This author can't be blocked.", "commonMe": "Me", "planetSizeMedium": "Mid-size fandom", + "planetSizeSmall": "Small fandom", + "planetSizeLarge": "Large fandom", + "galaxyTabPlanet": "Planets", + "galaxyTabNebula": "Nebulae", + "galaxySortMembers": "Members", + "galaxyJoin": "Join", + "galaxyGridView": "Grid view", + "galaxyListView": "List view", "planetPerHead": "Power per member: {value}", "planetJoined": "Joined", "planetJoin": "Join", @@ -634,7 +646,7 @@ "permLocationNeeded": "Location permission is needed to verify your visit.", "permLocationDenied": "Location permission is off. Allow location access in Settings.", "questRewardReceived": " received!", - "questAdBonus": "Ad bonus +{pop} POP", + "questAdBonus": "Ad bonus +{pop} Contribution POP", "questParticipantsCount": "Participants: {count}", "questParticipantsCap": "Participants: {count} / {cap}", "nebulaCreateFailed": "Couldn't create the nebula. Please try again later.", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 7d063ed..c35a820 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -65,6 +65,8 @@ "settingsInquiry": "Consulta 1:1", "settingsAppVersion": "Versión de la app", "settingsLatest": "Última versión", + "updateAvailableTitle": "Hay una nueva versión", + "updateAvailableBody": "Actualiza FANUZU a la última versión ({version}) desde la tienda.", "settingsLogout": "Cerrar sesión", "settingsLogoutConfirm": "¿Quieres cerrar sesión?", "settingsWithdraw": "Eliminar cuenta", @@ -246,6 +248,7 @@ "nebulaHeadline": "Ponle nombre al planeta de tu fandom.", "nebulaBody": "Si los miembros fundadores alcanzan la meta de POP en {days} días,\n¡nacerá como planeta oficial!\nQuienes participen en la evolución recibirán una insignia.", "nebulaNameTaken": "Este nombre ya está en uso.", + "nebulaNameInvalid": "Usa de 2 a 20 letras, números o espacios (sin símbolos, emojis ni espacios dobles o al inicio/final).", "nebulaNameHint": "Nombre del fandom", "nebulaGoal": "Meta ", "nebulaFounders": " / Fundadores", @@ -443,6 +446,7 @@ "notifKindComment": "Comentarios", "notifKindCampaign": "Meta de campaña", "notifKindQuest": "Misión completada", + "notifKindEvolution": "Evolución del planeta", "notifTargetPost": "la publicación", "notifTargetCampaign": "la campaña", "notifTargetQuest": "el detalle de la misión", @@ -568,6 +572,14 @@ "blockUnavailable": "No se puede bloquear a este autor.", "commonMe": "Yo", "planetSizeMedium": "Fandom mediano", + "planetSizeSmall": "Fandom pequeño", + "planetSizeLarge": "Fandom grande", + "galaxyTabPlanet": "Planetas", + "galaxyTabNebula": "Nebulosas", + "galaxySortMembers": "Miembros", + "galaxyJoin": "Unirse", + "galaxyGridView": "Vista de cuadrícula", + "galaxyListView": "Vista de lista", "planetPerHead": "Poder por miembro: {value}", "planetJoined": "Unido", "planetJoin": "Unirse", @@ -634,7 +646,7 @@ "permLocationNeeded": "Se necesita el permiso de ubicación para verificar tu visita.", "permLocationDenied": "El permiso de ubicación está desactivado. Permite el acceso en Ajustes.", "questRewardReceived": " ¡recibidos!", - "questAdBonus": "Extra por anuncio +{pop} POP", + "questAdBonus": "Extra por anuncio +{pop} POP de contribución", "questParticipantsCount": "Participantes: {count}", "questParticipantsCap": "Participantes: {count} / {cap}", "nebulaCreateFailed": "No se pudo crear la nebulosa. Inténtalo más tarde.", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index f227c5f..35f321a 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -65,6 +65,8 @@ "settingsInquiry": "1:1お問い合わせ", "settingsAppVersion": "アプリバージョン", "settingsLatest": "最新バージョン", + "updateAvailableTitle": "新しいバージョンがあります", + "updateAvailableBody": "ストアでFANUZUを最新バージョン({version})にアップデートしてください。", "settingsLogout": "ログアウト", "settingsLogoutConfirm": "ログアウトしますか?", "settingsWithdraw": "退会", @@ -246,6 +248,7 @@ "nebulaHeadline": "ファンダム惑星の名前を決めよう。", "nebulaBody": "創立メンバーが{days}日以内に目標POPを達成すると\n正式な惑星として誕生します!\n進化に参加したメンバーには記念バッジが贈られます。", "nebulaNameTaken": "すでに使用されている名前です。", + "nebulaNameInvalid": "2〜20文字のハングル・英字・数字で入力してください(記号・絵文字・連続/前後の空白は不可)。", "nebulaNameHint": "ファンダム名", "nebulaGoal": "目標 ", "nebulaFounders": " / 創立メンバー", @@ -443,6 +446,7 @@ "notifKindComment": "コメント", "notifKindCampaign": "キャンペーン達成", "notifKindQuest": "クエスト完了", + "notifKindEvolution": "惑星の進化", "notifTargetPost": "投稿", "notifTargetCampaign": "キャンペーン", "notifTargetQuest": "クエスト詳細", @@ -568,6 +572,14 @@ "blockUnavailable": "ブロックできない投稿者です。", "commonMe": "自分", "planetSizeMedium": "中規模ファンダム", + "planetSizeSmall": "小規模ファンダム", + "planetSizeLarge": "大規模ファンダム", + "galaxyTabPlanet": "惑星", + "galaxyTabNebula": "星雲", + "galaxySortMembers": "メンバー数", + "galaxyJoin": "参加", + "galaxyGridView": "グリッド表示", + "galaxyListView": "リスト表示", "planetPerHead": "1人あたり火力:{value}", "planetJoined": "参加中", "planetJoin": "参加する", @@ -634,7 +646,7 @@ "permLocationNeeded": "訪問を確認するには位置情報の権限が必要です。", "permLocationDenied": "位置情報の権限がオフです。設定で位置情報へのアクセスを許可してください。", "questRewardReceived": " 受け取りました!", - "questAdBonus": "広告追加報酬 +{pop} POP", + "questAdBonus": "広告追加報酬 +{pop} 貢献POP", "questParticipantsCount": "参加人数:{count}", "questParticipantsCap": "参加人数:{count} / {cap}", "nebulaCreateFailed": "星雲を作成できませんでした。しばらくしてから再度お試しください。", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index e4d51e7..bc0926d 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -120,6 +120,11 @@ "settingsInquiry": "1:1 문의접수", "settingsAppVersion": "앱 버전", "settingsLatest": "최신 버전", + "updateAvailableTitle": "새 버전이 있어요", + "updateAvailableBody": "스토어에서 FANUZU 를 최신 버전({version})으로 업데이트해 주세요.", + "@updateAvailableBody": { + "placeholders": { "version": { "type": "String" } } + }, "settingsLogout": "로그아웃", "settingsLogoutConfirm": "로그아웃 하시겠습니까?", "settingsWithdraw": "서비스 탈퇴", @@ -577,6 +582,7 @@ } }, "nebulaNameTaken": "이미 사용중인 이름입니다.", + "nebulaNameInvalid": "2~20자 한글·영문·숫자로 입력해 주세요(특수문자·이모지·연속/앞뒤 공백 불가).", "nebulaNameHint": "팬덤 이름", "nebulaGoal": "목표 ", "nebulaFounders": " / 창립멤버", @@ -888,6 +894,7 @@ "notifKindComment": "댓글", "notifKindCampaign": "캠페인 달성", "notifKindQuest": "퀘스트 완료", + "notifKindEvolution": "행성 진화", "notifTargetPost": "게시물", "notifTargetCampaign": "캠페인", "notifTargetQuest": "퀘스트 상세", @@ -1135,6 +1142,14 @@ "blockUnavailable": "차단할 수 없는 작성자입니다.", "commonMe": "나", "planetSizeMedium": "중형 팬덤", + "planetSizeSmall": "소형 팬덤", + "planetSizeLarge": "대형 팬덤", + "galaxyTabPlanet": "행성", + "galaxyTabNebula": "성운", + "galaxySortMembers": "가입자 수", + "galaxyJoin": "가입", + "galaxyGridView": "격자 그리드", + "galaxyListView": "리스트 그리드", "planetPerHead": "1인당 화력 : {value}", "@planetPerHead": { "placeholders": { @@ -1242,7 +1257,7 @@ "permLocationNeeded": "방문을 확인하려면 위치 권한이 필요합니다.", "permLocationDenied": "위치 권한이 꺼져 있습니다. 설정에서 위치 접근을 허용해 주세요.", "questRewardReceived": " 받았어요!", - "questAdBonus": "광고 추가 보상 +{pop} POP", + "questAdBonus": "광고 추가 보상 +{pop} 기여 POP", "@questAdBonus": { "placeholders": { "pop": { diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index fb2600f..c0d47e3 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -494,6 +494,18 @@ abstract class AppLocalizations { /// **'최신 버전'** String get settingsLatest; + /// No description provided for @updateAvailableTitle. + /// + /// In ko, this message translates to: + /// **'새 버전이 있어요'** + String get updateAvailableTitle; + + /// No description provided for @updateAvailableBody. + /// + /// In ko, this message translates to: + /// **'스토어에서 FANUZU 를 최신 버전({version})으로 업데이트해 주세요.'** + String updateAvailableBody(String version); + /// No description provided for @settingsLogout. /// /// In ko, this message translates to: @@ -1580,6 +1592,12 @@ abstract class AppLocalizations { /// **'이미 사용중인 이름입니다.'** String get nebulaNameTaken; + /// No description provided for @nebulaNameInvalid. + /// + /// In ko, this message translates to: + /// **'2~20자 한글·영문·숫자로 입력해 주세요(특수문자·이모지·연속/앞뒤 공백 불가).'** + String get nebulaNameInvalid; + /// No description provided for @nebulaNameHint. /// /// In ko, this message translates to: @@ -2762,6 +2780,12 @@ abstract class AppLocalizations { /// **'퀘스트 완료'** String get notifKindQuest; + /// No description provided for @notifKindEvolution. + /// + /// In ko, this message translates to: + /// **'행성 진화'** + String get notifKindEvolution; + /// No description provided for @notifTargetPost. /// /// In ko, this message translates to: @@ -3512,6 +3536,54 @@ abstract class AppLocalizations { /// **'중형 팬덤'** String get planetSizeMedium; + /// No description provided for @planetSizeSmall. + /// + /// In ko, this message translates to: + /// **'소형 팬덤'** + String get planetSizeSmall; + + /// No description provided for @planetSizeLarge. + /// + /// In ko, this message translates to: + /// **'대형 팬덤'** + String get planetSizeLarge; + + /// No description provided for @galaxyTabPlanet. + /// + /// In ko, this message translates to: + /// **'행성'** + String get galaxyTabPlanet; + + /// No description provided for @galaxyTabNebula. + /// + /// In ko, this message translates to: + /// **'성운'** + String get galaxyTabNebula; + + /// No description provided for @galaxySortMembers. + /// + /// In ko, this message translates to: + /// **'가입자 수'** + String get galaxySortMembers; + + /// No description provided for @galaxyJoin. + /// + /// In ko, this message translates to: + /// **'가입'** + String get galaxyJoin; + + /// No description provided for @galaxyGridView. + /// + /// In ko, this message translates to: + /// **'격자 그리드'** + String get galaxyGridView; + + /// No description provided for @galaxyListView. + /// + /// In ko, this message translates to: + /// **'리스트 그리드'** + String get galaxyListView; + /// No description provided for @planetPerHead. /// /// In ko, this message translates to: @@ -3911,7 +3983,7 @@ abstract class AppLocalizations { /// No description provided for @questAdBonus. /// /// In ko, this message translates to: - /// **'광고 추가 보상 +{pop} POP'** + /// **'광고 추가 보상 +{pop} 기여 POP'** String questAdBonus(String pop); /// No description provided for @questParticipantsCount. diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 3381d38..6f1d38b 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -234,6 +234,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsLatest => 'Latest'; + @override + String get updateAvailableTitle => 'A new version is available'; + + @override + String updateAvailableBody(String version) { + return 'Please update FANUZU to the latest version ($version) from the store.'; + } + @override String get settingsLogout => 'Log out'; @@ -894,6 +902,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nebulaNameTaken => 'This name is already taken.'; + @override + String get nebulaNameInvalid => + 'Use 2–20 letters, numbers or spaces (no symbols, emoji, or double/leading/trailing spaces).'; + @override String get nebulaNameHint => 'Fandom name'; @@ -1533,6 +1545,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notifKindQuest => 'Quest complete'; + @override + String get notifKindEvolution => 'Planet evolution'; + @override String get notifTargetPost => 'post'; @@ -1977,6 +1992,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get planetSizeMedium => 'Mid-size fandom'; + @override + String get planetSizeSmall => 'Small fandom'; + + @override + String get planetSizeLarge => 'Large fandom'; + + @override + String get galaxyTabPlanet => 'Planets'; + + @override + String get galaxyTabNebula => 'Nebulae'; + + @override + String get galaxySortMembers => 'Members'; + + @override + String get galaxyJoin => 'Join'; + + @override + String get galaxyGridView => 'Grid view'; + + @override + String get galaxyListView => 'List view'; + @override String planetPerHead(String value) { return 'Power per member: $value'; @@ -2199,7 +2238,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String questAdBonus(String pop) { - return 'Ad bonus +$pop POP'; + return 'Ad bonus +$pop Contribution POP'; } @override diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 5004711..ac574e5 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -236,6 +236,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settingsLatest => 'Última versión'; + @override + String get updateAvailableTitle => 'Hay una nueva versión'; + + @override + String updateAvailableBody(String version) { + return 'Actualiza FANUZU a la última versión ($version) desde la tienda.'; + } + @override String get settingsLogout => 'Cerrar sesión'; @@ -904,6 +912,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get nebulaNameTaken => 'Este nombre ya está en uso.'; + @override + String get nebulaNameInvalid => + 'Usa de 2 a 20 letras, números o espacios (sin símbolos, emojis ni espacios dobles o al inicio/final).'; + @override String get nebulaNameHint => 'Nombre del fandom'; @@ -1541,6 +1553,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get notifKindQuest => 'Misión completada'; + @override + String get notifKindEvolution => 'Evolución del planeta'; + @override String get notifTargetPost => 'la publicación'; @@ -1989,6 +2004,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get planetSizeMedium => 'Fandom mediano'; + @override + String get planetSizeSmall => 'Fandom pequeño'; + + @override + String get planetSizeLarge => 'Fandom grande'; + + @override + String get galaxyTabPlanet => 'Planetas'; + + @override + String get galaxyTabNebula => 'Nebulosas'; + + @override + String get galaxySortMembers => 'Miembros'; + + @override + String get galaxyJoin => 'Unirse'; + + @override + String get galaxyGridView => 'Vista de cuadrícula'; + + @override + String get galaxyListView => 'Vista de lista'; + @override String planetPerHead(String value) { return 'Poder por miembro: $value'; @@ -2213,7 +2252,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String questAdBonus(String pop) { - return 'Extra por anuncio +$pop POP'; + return 'Extra por anuncio +$pop POP de contribución'; } @override diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 759be99..30e7a3f 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -231,6 +231,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settingsLatest => '最新バージョン'; + @override + String get updateAvailableTitle => '新しいバージョンがあります'; + + @override + String updateAvailableBody(String version) { + return 'ストアでFANUZUを最新バージョン($version)にアップデートしてください。'; + } + @override String get settingsLogout => 'ログアウト'; @@ -860,6 +868,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get nebulaNameTaken => 'すでに使用されている名前です。'; + @override + String get nebulaNameInvalid => + '2〜20文字のハングル・英字・数字で入力してください(記号・絵文字・連続/前後の空白は不可)。'; + @override String get nebulaNameHint => 'ファンダム名'; @@ -1483,6 +1495,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get notifKindQuest => 'クエスト完了'; + @override + String get notifKindEvolution => '惑星の進化'; + @override String get notifTargetPost => '投稿'; @@ -1917,6 +1932,30 @@ class AppLocalizationsJa extends AppLocalizations { @override String get planetSizeMedium => '中規模ファンダム'; + @override + String get planetSizeSmall => '小規模ファンダム'; + + @override + String get planetSizeLarge => '大規模ファンダム'; + + @override + String get galaxyTabPlanet => '惑星'; + + @override + String get galaxyTabNebula => '星雲'; + + @override + String get galaxySortMembers => 'メンバー数'; + + @override + String get galaxyJoin => '参加'; + + @override + String get galaxyGridView => 'グリッド表示'; + + @override + String get galaxyListView => 'リスト表示'; + @override String planetPerHead(String value) { return '1人あたり火力:$value'; @@ -2128,7 +2167,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String questAdBonus(String pop) { - return '広告追加報酬 +$pop POP'; + return '広告追加報酬 +$pop 貢献POP'; } @override diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index fac5751..fde5e19 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -231,6 +231,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settingsLatest => '최신 버전'; + @override + String get updateAvailableTitle => '새 버전이 있어요'; + + @override + String updateAvailableBody(String version) { + return '스토어에서 FANUZU 를 최신 버전($version)으로 업데이트해 주세요.'; + } + @override String get settingsLogout => '로그아웃'; @@ -860,6 +868,10 @@ class AppLocalizationsKo extends AppLocalizations { @override String get nebulaNameTaken => '이미 사용중인 이름입니다.'; + @override + String get nebulaNameInvalid => + '2~20자 한글·영문·숫자로 입력해 주세요(특수문자·이모지·연속/앞뒤 공백 불가).'; + @override String get nebulaNameHint => '팬덤 이름'; @@ -1483,6 +1495,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get notifKindQuest => '퀘스트 완료'; + @override + String get notifKindEvolution => '행성 진화'; + @override String get notifTargetPost => '게시물'; @@ -1918,6 +1933,30 @@ class AppLocalizationsKo extends AppLocalizations { @override String get planetSizeMedium => '중형 팬덤'; + @override + String get planetSizeSmall => '소형 팬덤'; + + @override + String get planetSizeLarge => '대형 팬덤'; + + @override + String get galaxyTabPlanet => '행성'; + + @override + String get galaxyTabNebula => '성운'; + + @override + String get galaxySortMembers => '가입자 수'; + + @override + String get galaxyJoin => '가입'; + + @override + String get galaxyGridView => '격자 그리드'; + + @override + String get galaxyListView => '리스트 그리드'; + @override String planetPerHead(String value) { return '1인당 화력 : $value'; @@ -2129,7 +2168,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String questAdBonus(String pop) { - return '광고 추가 보상 +$pop POP'; + return '광고 추가 보상 +$pop 기여 POP'; } @override diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 66d804a..94d1588 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -231,6 +231,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsLatest => '最新版本'; + @override + String get updateAvailableTitle => '有新版本'; + + @override + String updateAvailableBody(String version) { + return '请在应用商店将 FANUZU 更新到最新版本($version)。'; + } + @override String get settingsLogout => '退出登录'; @@ -860,6 +868,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get nebulaNameTaken => '该名称已被使用。'; + @override + String get nebulaNameInvalid => '请输入2~20个韩文、英文或数字(不可使用特殊字符、表情符号、连续或首尾空格)。'; + @override String get nebulaNameHint => '粉丝圈名称'; @@ -1482,6 +1493,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get notifKindQuest => '任务完成'; + @override + String get notifKindEvolution => '星球进化'; + @override String get notifTargetPost => '帖子'; @@ -1916,6 +1930,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get planetSizeMedium => '中型粉丝圈'; + @override + String get planetSizeSmall => '小型粉丝圈'; + + @override + String get planetSizeLarge => '大型粉丝圈'; + + @override + String get galaxyTabPlanet => '行星'; + + @override + String get galaxyTabNebula => '星云'; + + @override + String get galaxySortMembers => '成员数'; + + @override + String get galaxyJoin => '加入'; + + @override + String get galaxyGridView => '网格视图'; + + @override + String get galaxyListView => '列表视图'; + @override String planetPerHead(String value) { return '人均火力:$value'; @@ -2126,7 +2164,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String questAdBonus(String pop) { - return '广告额外奖励 +$pop POP'; + return '广告额外奖励 +$pop 贡献 POP'; } @override diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 62524f7..bfb42ef 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -65,6 +65,8 @@ "settingsInquiry": "1:1咨询", "settingsAppVersion": "应用版本", "settingsLatest": "最新版本", + "updateAvailableTitle": "有新版本", + "updateAvailableBody": "请在应用商店将 FANUZU 更新到最新版本({version})。", "settingsLogout": "退出登录", "settingsLogoutConfirm": "确定要退出登录吗?", "settingsWithdraw": "注销账号", @@ -246,6 +248,7 @@ "nebulaHeadline": "为我们的粉丝星球取个名字吧。", "nebulaBody": "创始成员在 {days} 天内达成目标 POP,\n即可诞生为正式星球!\n参与进化的成员将获得纪念徽章。", "nebulaNameTaken": "该名称已被使用。", + "nebulaNameInvalid": "请输入2~20个韩文、英文或数字(不可使用特殊字符、表情符号、连续或首尾空格)。", "nebulaNameHint": "粉丝圈名称", "nebulaGoal": "目标 ", "nebulaFounders": " / 创始成员", @@ -443,6 +446,7 @@ "notifKindComment": "评论", "notifKindCampaign": "活动达成", "notifKindQuest": "任务完成", + "notifKindEvolution": "星球进化", "notifTargetPost": "帖子", "notifTargetCampaign": "活动", "notifTargetQuest": "任务详情", @@ -568,6 +572,14 @@ "blockUnavailable": "无法屏蔽该作者。", "commonMe": "我", "planetSizeMedium": "中型粉丝圈", + "planetSizeSmall": "小型粉丝圈", + "planetSizeLarge": "大型粉丝圈", + "galaxyTabPlanet": "行星", + "galaxyTabNebula": "星云", + "galaxySortMembers": "成员数", + "galaxyJoin": "加入", + "galaxyGridView": "网格视图", + "galaxyListView": "列表视图", "planetPerHead": "人均火力:{value}", "planetJoined": "已加入", "planetJoin": "加入", @@ -634,7 +646,7 @@ "permLocationNeeded": "需要定位权限以确认到访。", "permLocationDenied": "定位权限已关闭,请在设置中允许访问位置。", "questRewardReceived": " 已领取!", - "questAdBonus": "广告额外奖励 +{pop} POP", + "questAdBonus": "广告额外奖励 +{pop} 贡献 POP", "questParticipantsCount": "参与人数:{count}", "questParticipantsCap": "参与人数:{count} / {cap}", "nebulaCreateFailed": "无法创建星云,请稍后再试。", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 18c2aaa..28d5242 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -727,6 +727,15 @@ class RemoteAppLocalizations extends AppLocalizations { @override String galaxyErosion(String rate, String progress) => _fill('galaxyErosion', {'rate': rate, 'progress': progress}) ?? _base.galaxyErosion(rate, progress); + @override + String get galaxyGridView => _remote['galaxyGridView'] ?? _base.galaxyGridView; + + @override + String get galaxyJoin => _remote['galaxyJoin'] ?? _base.galaxyJoin; + + @override + String get galaxyListView => _remote['galaxyListView'] ?? _base.galaxyListView; + @override String get galaxyLoadFailed => _remote['galaxyLoadFailed'] ?? _base.galaxyLoadFailed; @@ -757,6 +766,15 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get galaxySetPrimaryConfirm => _remote['galaxySetPrimaryConfirm'] ?? _base.galaxySetPrimaryConfirm; + @override + String get galaxySortMembers => _remote['galaxySortMembers'] ?? _base.galaxySortMembers; + + @override + String get galaxyTabNebula => _remote['galaxyTabNebula'] ?? _base.galaxyTabNebula; + + @override + String get galaxyTabPlanet => _remote['galaxyTabPlanet'] ?? _base.galaxyTabPlanet; + @override String get growthCheckConditions => _remote['growthCheckConditions'] ?? _base.growthCheckConditions; @@ -1294,6 +1312,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get nebulaNameTaken => _remote['nebulaNameTaken'] ?? _base.nebulaNameTaken; + @override + String get nebulaNameInvalid => _remote['nebulaNameInvalid'] ?? _base.nebulaNameInvalid; + @override String get nicknameEdit => _remote['nicknameEdit'] ?? _base.nicknameEdit; @@ -1330,6 +1351,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get notifKindEclipse => _remote['notifKindEclipse'] ?? _base.notifKindEclipse; + @override + String get notifKindEvolution => _remote['notifKindEvolution'] ?? _base.notifKindEvolution; + @override String get notifKindItemUsed => _remote['notifKindItemUsed'] ?? _base.notifKindItemUsed; @@ -1630,9 +1654,15 @@ class RemoteAppLocalizations extends AppLocalizations { @override String planetShape(String n) => _fill('planetShape', {'n': n}) ?? _base.planetShape(n); + @override + String get planetSizeLarge => _remote['planetSizeLarge'] ?? _base.planetSizeLarge; + @override String get planetSizeMedium => _remote['planetSizeMedium'] ?? _base.planetSizeMedium; + @override + String get planetSizeSmall => _remote['planetSizeSmall'] ?? _base.planetSizeSmall; + @override String get planetStageUnknown => _remote['planetStageUnknown'] ?? _base.planetStageUnknown; @@ -2629,6 +2659,12 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get unknownUser => _remote['unknownUser'] ?? _base.unknownUser; + @override + String updateAvailableBody(String version) => _fill('updateAvailableBody', {'version': version}) ?? _base.updateAvailableBody(version); + + @override + String get updateAvailableTitle => _remote['updateAvailableTitle'] ?? _base.updateAvailableTitle; + @override String get zapApplying => _remote['zapApplying'] ?? _base.zapApplying; diff --git a/app/pubspec.lock b/app/pubspec.lock index b9bc053..033c223 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1028,6 +1028,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e904878..6d2f3c4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -132,6 +132,9 @@ dependencies: mobile_scanner: ^7.4.2 # 생성자 상세의 「현장에 붙일 인증 QR」 그리기. 순수 Dart 라 네이티브 하한이 없다. qr_flutter: ^4.1.0 + # 스플래시 업데이트 확인(Figma `510:19053`) — 설치된 앱 버전 읽기. + # 최소 OS 하한 영향 없음: Android minSdk 19(앱 29) · iOS 12.0(앱 15.0). + package_info_plus: ^9.0.1 dev_dependencies: flutter_test: @@ -196,3 +199,5 @@ flutter: - assets/galaxy/ # Figma Iconset 아이콘(lib/core/ui/app_icons.dart). - assets/icons/ + # 발주사 로고 마크(인수 자산 `fanuzu logo only.png` 에서 마크만 자름) — 로그인 · 가입 완료 · 소개. + - assets/brand/fanuzu-mark.png diff --git a/app/test/app_update_test.dart b/app/test/app_update_test.dart new file mode 100644 index 0000000..030c228 --- /dev/null +++ b/app/test/app_update_test.dart @@ -0,0 +1,23 @@ +import 'package:fancake/core/config/feature_flags.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 스플래시 업데이트 확인(Figma `510:19053`) — 설치 버전이 운영 최신 버전보다 낮을 때만 안내. +void main() { + test('버전 비교', () { + expect(isOlderVersion('0.5.0', '0.5.1'), isTrue); + expect(isOlderVersion('0.5.0+7', '0.10.0'), isTrue); + expect(isOlderVersion('0.5.0', '0.5.0'), isFalse); + expect(isOlderVersion('0.6.0', '0.5.9'), isFalse); + expect(isOlderVersion('1.0', '1.0.0'), isFalse); + expect(isOlderVersion('0.5.0', 'abc'), isFalse); + }); + + test('config/features.latestAppVersion 을 읽고, 없으면 null', () async { + final flags = FeatureFlags( + source: Stream.value({FeatureFlags.latestVersionKey: ' 0.6.0 '}), + ); + await Future.delayed(Duration.zero); + expect(flags.latestVersion, '0.6.0'); + expect(FeatureFlags().latestVersion, isNull); + }); +} diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart index 617998e..c06e058 100644 --- a/app/test/backend_repository_test.dart +++ b/app/test/backend_repository_test.dart @@ -198,7 +198,9 @@ void main() { expect(snapshot.popBalance, 12540); expect(snapshot.liveTicker, contains('버프')); expect(snapshot.growth.fandomName, 'BTS'); - expect(snapshot.growth.contributorName, 'BTS_Hong'); + // 설명 프레임 `2097:16391` — 인사 줄은 로그인 사용자 기준. 팬덤의 직전 기여자가 아니다. + expect(snapshot.growth.contributorName, isNot('BTS_Hong')); + expect(snapshot.growth.contributedPop, 0); expect(snapshot.growth.currentPop, 6270); // 서버가 growthPercent 를 주지 않으면 진행도로 낸다. expect(snapshot.growth.growthPercent, 63); @@ -827,14 +829,21 @@ void main() { expect(profile.nickname, '팬'); }); - test('홈 기여자명에 이메일이 남아 있어도 가린다', () async { + test('홈 인사 줄은 로그인 사용자의 닉네임 · 사진이고 이메일 형태는 가린다', () async { final db = await _seed(); - await db.doc('fandoms/bts').set({ - 'name': 'BTS', 'lastContributorName': 'test123@naver.com', - 'lastContributedPop': 10, + await db.doc('users/$_uid').set({ + 'nickname': 'ARMY_Kim', 'photoUrl': 'https://example.com/p.png', }, SetOptions(merge: true)); - final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + var snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.growth.contributorName, 'ARMY_Kim'); + expect(snapshot.growth.photoUrl, 'https://example.com/p.png'); + + await db.doc('users/$_uid').set({ + 'nickname': 'test123@naver.com', 'photoUrl': '', + }, SetOptions(merge: true)); + snapshot = await FirestoreHomeRepository(_backend(db)).load(); expect(snapshot.growth.contributorName, '팬'); + expect(snapshot.growth.photoUrl, isNull); }); }); diff --git a/app/test/f1_screens_test.dart b/app/test/f1_screens_test.dart index d090218..5c94587 100644 --- a/app/test/f1_screens_test.dart +++ b/app/test/f1_screens_test.dart @@ -118,7 +118,7 @@ void main() { testWidgets('이름을 넣고 만들면 기본 행성 변경을 되묻는다', (tester) async { await _pump(tester, const NebulaCreateScreen()); - await tester.enterText(find.byType(TextField), 'BTS_RM'); + await tester.enterText(find.byType(TextField), 'BTS RM'); await tester.pumpAndSettle(); await tester.tap(find.text('성운 생성')); await tester.pumpAndSettle(); @@ -131,13 +131,13 @@ void main() { testWidgets('변경을 누르면 fn-nebula-create 로 만들고 새 성운 ID 를 들고 닫힌다', (tester) async { final repo = _CreateRepo(); final results = await _pushed(tester, NebulaCreateScreen(repository: repo)); - await tester.enterText(find.byType(TextField), 'BTS_RM'); + await tester.enterText(find.byType(TextField), 'BTS RM'); await tester.pumpAndSettle(); await tester.tap(find.text('성운 생성')); await tester.pumpAndSettle(); await tester.tap(find.text('변경')); await tester.pumpAndSettle(); - expect(repo.calls, ['BTS_RM']); + expect(repo.calls, ['BTS RM']); expect(results, ['new-nebula']); expect(find.byType(NebulaCreateScreen), findsNothing); }); @@ -147,7 +147,7 @@ void main() { error: const BackendException('E-FDM-02', '이미 오늘 성운을 생성하셨습니다.'), ); final results = await _pushed(tester, NebulaCreateScreen(repository: repo)); - await tester.enterText(find.byType(TextField), 'BTS_RM'); + await tester.enterText(find.byType(TextField), 'BTS RM'); await tester.pumpAndSettle(); await tester.tap(find.text('성운 생성')); await tester.pumpAndSettle(); @@ -161,7 +161,7 @@ void main() { testWidgets('대표 변경 30일 대기 중이면 성운만 생겼다고 알리고 닫힌다(C-15)', (tester) async { final repo = _CreateRepo(primaryChanged: false); final results = await _pushed(tester, NebulaCreateScreen(repository: repo)); - await tester.enterText(find.byType(TextField), 'BTS_RM'); + await tester.enterText(find.byType(TextField), 'BTS RM'); await tester.pumpAndSettle(); await tester.tap(find.text('성운 생성')); await tester.pumpAndSettle(); @@ -180,7 +180,7 @@ void main() { testWidgets('오늘 이미 만들었으면 사유를 알리고 진행하지 않는다', (tester) async { await _pump(tester, const NebulaCreateScreen(alreadyCreatedToday: true)); - await tester.enterText(find.byType(TextField), 'BTS_RM'); + await tester.enterText(find.byType(TextField), 'BTS RM'); await tester.pumpAndSettle(); await tester.tap(find.text('성운 생성')); await tester.pumpAndSettle(); diff --git a/app/test/f2_screens_test.dart b/app/test/f2_screens_test.dart index 6adb334..7e1613f 100644 --- a/app/test/f2_screens_test.dart +++ b/app/test/f2_screens_test.dart @@ -121,6 +121,20 @@ class _PrimaryRepository extends SampleGalaxyRepository { } } +/// 오늘 성운을 이미 만든 계정. +class _CreatedTodayRepository extends SampleGalaxyRepository { + @override + Future load() async { + final s = await super.load(); + return GalaxySnapshot( + planets: s.planets, + myPop: s.myPop, + memberships: s.memberships, + nebulaCreatedToday: true, + ); + } +} + void main() { group('GALAXY 모아보기 (§2.56 · §2.58) — 기본 행성 변경', () { Future pumpHome(WidgetTester tester, GalaxyRepository repo) async { @@ -146,6 +160,24 @@ void main() { expect(repo.changes, ['newjeans']); }); + testWidgets('그리드 변경 — 격자 ↔ 리스트(540:19580)', (tester) async { + await pumpHome(tester, _PrimaryRepository()); + expect(find.text('NewJeans'), findsNothing); // 격자는 그림만 + await tester.tap(find.bySemanticsLabel('리스트 그리드')); + await tester.pumpAndSettle(); + expect(find.text('NewJeans'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('격자 그리드')); + await tester.pumpAndSettle(); + expect(find.text('NewJeans'), findsNothing); + }); + + testWidgets('오늘 성운을 만들었으면 성운 생성은 초과 안내 모달만(540:19592 · 543:19609)', (tester) async { + await pumpHome(tester, _CreatedTodayRepository()); + await tester.tap(find.text('성운 생성')); + await tester.pumpAndSettle(); + expect(find.text('이미 오늘 성운을 생성하셨습니다.'), findsOneWidget); + }); + testWidgets('30일 대기 중이면 서버 사유(E-FDM-04)를 알린다', (tester) async { final repo = _PrimaryRepository( error: const BackendException('E-FDM-04', '기본 행성은 변경 후 30일이 지나야 다시 바꿀 수 있습니다.'), @@ -465,7 +497,10 @@ void main() { await _pump(tester, const GalaxySearchScreen()); await tester.enterText(find.byType(TextField), 'BTS'); await tester.pumpAndSettle(); - expect(find.textContaining('멤버 12,840명'), findsOneWidget); + // 결과 줄 — Figma `353:218091` 가입자 수 · 「FPI : n」 · 규모 칩. + expect(find.text('12,840'), findsOneWidget); + expect(find.text('FPI : 284,310'), findsOneWidget); + expect(find.text('중형 팬덤'), findsOneWidget); await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pumpAndSettle(); @@ -479,10 +514,47 @@ void main() { await _pump(tester, GalaxySearchScreen(onOpen: (p) => opened = p)); await tester.enterText(find.byType(TextField), 'BTS'); await tester.pumpAndSettle(); - await tester.tap(find.textContaining('멤버 12,840명')); + await tester.tap(find.text('12,840')); expect(opened?.name, 'BTS'); }); + testWidgets('탭(행성 · 성운) · 필터(전체 · 가입자 수 · FPI) — 543:19672 · 543:19683', (tester) async { + await _pump(tester, const GalaxySearchScreen()); + await tester.enterText(find.byType(TextField), 'e'); + await tester.pumpAndSettle(); + expect(find.text('행성'), findsOneWidget); + expect(find.text('성운'), findsOneWidget); + // 가입자 수 순 — NewJeans(11,230) 가 SEVENTEEN(10,450) 보다 위. + await tester.tap(find.text('가입자 수')); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft(find.text('NewJeans')).dy, + lessThan(tester.getTopLeft(find.text('SEVENTEEN')).dy), + ); + // 성운 탭 — 표본은 모두 정식 행성이라 비고, FPI 필터는 없다. + await tester.tap(find.text('성운')); + await tester.pumpAndSettle(); + expect(find.text('NewJeans'), findsNothing); + expect(find.text('FPI'), findsNothing); + }); + + testWidgets('가입 → 가입 처리 후 가입중(543:19694)', (tester) async { + final joined = []; + await _pump( + tester, + GalaxySearchScreen( + repository: const SampleGalaxyRepository(), + onJoin: (p) async => joined.add(p.id), + ), + ); + await tester.enterText(find.byType(TextField), 'IVE'); + await tester.pumpAndSettle(); + await tester.tap(find.text('가입')); + await tester.pumpAndSettle(); + expect(joined, ['ive']); + expect(find.text('가입중'), findsOneWidget); + }); + testWidgets('없는 이름이면 사유를 알린다', (tester) async { await _pump(tester, const GalaxySearchScreen()); await tester.enterText(find.byType(TextField), 'zzzz'); diff --git a/app/test/f3_screens_test.dart b/app/test/f3_screens_test.dart index 04417db..9539c47 100644 --- a/app/test/f3_screens_test.dart +++ b/app/test/f3_screens_test.dart @@ -11,6 +11,7 @@ import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/features/planet/lounge_post_screens.dart'; import 'package:fancake/features/planet/moderation.dart'; import 'package:fancake/features/planet/planet_detail_screen.dart'; +import 'package:fancake/features/planet/widgets/planet_detail_hero.dart'; import 'eclipse_stubs.dart'; @@ -588,6 +589,50 @@ void _fpiRankingTests() { expect(find.byType(FpiUserRankingScreen), findsOneWidget); expect(find.text('나의 BPM 랭킹'), findsOneWidget); }); + + testWidgets('소속 행성 카드를 눌러도 사용자 랭킹으로 간다 (546:14629)', (tester) async { + await pump(tester); + // 맨 위 내 줄 카드는 「(나)」 없이 이름만 둔다. + await tester.tap(find.text('BTS')); + await tester.pumpAndSettle(); + expect(find.byType(FpiUserRankingScreen), findsOneWidget); + }); + }); + + testWidgets('내가 만든 행성은 가입 버튼을 숨긴다 (544:19765)', (tester) async { + Widget hero(bool owner) => MaterialApp( + theme: buildAppTheme(), + home: const Scaffold(body: SizedBox()), + builder: (_, _) => SizedBox( + height: 543, + child: PlanetDetailHero( + name: 'BTS', + members: 850, + totalFpi: 1220, + joined: true, + isOwner: owner, + ), + ), + ); + await tester.pumpWidget(hero(false)); + await tester.pump(); + expect(find.text('가입중'), findsOneWidget); + expect(find.text('소형 팬덤'), findsOneWidget); + await tester.pumpWidget(hero(true)); + await tester.pump(); + expect(find.text('가입중'), findsNothing); + }); + + test('팬덤 규모 — 1,000명 미만 소형 · 50,000명 미만 중형 · 이상 대형 (544:19765)', () { + expect(FandomSize.of(999), FandomSize.small); + expect(FandomSize.of(1000), FandomSize.medium); + expect(FandomSize.of(49999), FandomSize.medium); + expect(FandomSize.of(50000), FandomSize.large); + }); + + test('국기 — 국가 코드 → 국기, 미지정은 흰 깃발 (546:14713)', () { + expect(flagOf('KR'), '🇰🇷'); + expect(flagOf('ZZ'), '🏳'); }); group('사용자 지표 랭킹 화면 (Figma 546:14646)', () { diff --git a/app/test/f4_screens_test.dart b/app/test/f4_screens_test.dart index a88243b..1a4a0ec 100644 --- a/app/test/f4_screens_test.dart +++ b/app/test/f4_screens_test.dart @@ -69,7 +69,7 @@ void main() { final repo = _AdRepo(); await watchAd(tester, repo); expect(repo.claimed, ['imp-1']); - expect(find.text('광고 추가 보상 +20 POP'), findsOneWidget); + expect(find.text('광고 추가 보상 +20 기여 POP'), findsOneWidget); }); testWidgets('불러오지 못하면 기본 보상만 받는다', (tester) async { @@ -193,8 +193,11 @@ void main() { } }); - test('완료 탭은 진행 완료와 보상 완료를 함께 받는다', () { - expect(QuestTab.done.accepts(_stage(card, QuestStage.completed)), isTrue); + // 명세 `518:88405` — 진행완료는 진행중 탭, 보상완료만 완료 탭. + test('진행완료는 진행중 탭, 보상완료만 완료 탭이 받는다', () { + expect(QuestTab.inProgress.accepts(_stage(card, QuestStage.completed)), isTrue); + expect(QuestTab.inProgress.accepts(_stage(card, QuestStage.inProgress)), isTrue); + expect(QuestTab.done.accepts(_stage(card, QuestStage.completed)), isFalse); expect(QuestTab.done.accepts(_stage(card, QuestStage.rewarded)), isTrue); expect(QuestTab.done.accepts(_stage(card, QuestStage.inProgress)), isFalse); }); @@ -328,10 +331,10 @@ void main() { // 시청 후 대기(482:15084)는 지급이 끝나면 걷힌다. expect(find.byType(QuestAdWarpScreen), findsNothing); expect(find.byType(QuestRewardScreen), findsOneWidget); - // 별첨 B **B-28** 이 배수형(×2/×3)을 철회했다. 기본 보상과 광고 추가 보상은 - // 별개 원장 행이고, 화면은 **서버가 돌려준 지급액만** 띄운다(`fnAdReward.rewarded`). - expect(find.text('+10 받았어요!'), findsOneWidget); - expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget); + // 기본 보상과 광고 추가 보상은 별개 원장 행이고, 화면은 **서버가 돌려준 지급액만** 띄운다 + // (`fnAdReward.rewarded`). 운영 Quest 는 명세 `518:88685` 대로 둘의 합계(× 배수)를 크게 보인다. + expect(find.text('+20 받았어요!'), findsOneWidget); + expect(find.text('광고 추가 보상 +10 기여 POP'), findsOneWidget); }); testWidgets('스트리밍은 무보상 참여로 열려 있고 보상 · 광고 화면 없이 참여만 알린다(C-04)', (tester) async { @@ -530,6 +533,46 @@ void main() { } } }); + group('Figma 설명 프레임 대조 (2026-10-06)', () { + QuestCard ugc({int participants = 0, int? capacity, QuestStage stage = QuestStage.notStarted}) => + QuestCard( + id: 'u', title: 't', categoryName: 'c', author: QuestAuthor.user, + stage: stage, goal: 1, progress: 0, rewardPop: 10, + participants: participants, capacity: capacity, + ); + + // 명세 `523:18176` 퀘스트 종료 시점 1 — 참여 인원 충족 시 종료. + test('사용자 퀘스트는 모집 인원이 차면 마감이다', () { + expect(questFull(ugc(participants: 5, capacity: 5)), isTrue); + expect(questFull(ugc(participants: 4, capacity: 5)), isFalse); + expect(questFull(ugc(participants: 3)), isFalse); + }); + + // 명세 `518:88395` — 카테고리 값은 등록된 분류(건강 · 커뮤니티 …)다. 활동명(걷기)이 아니다. + test('카드 카테고리는 활동이 아니라 분류 이름이다', () { + expect(QuestType.steps.category, '건강'); + expect(QuestType.community.category, '커뮤니티'); + expect(QuestType.vote.category, '행성 활동'); + }); + + // 명세 `518:88685` — 광고 시청 POP = 보상 POP × 배수. 운영 Quest 는 합계를 크게 보인다. + testWidgets('운영 퀘스트는 광고를 보면 기본 + 증분 합계를 띄운다', (tester) async { + const card = QuestCard( + id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin, + stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 20, participants: 0, + ); + await pumpWide( + tester, + const QuestRewardScreen( + detail: QuestDetail(card: card, imageLabel: '', description: ''), + reward: QuestReward(rewardedPop: 20, balanceAfter: 0), + adBonusPop: 20, + ), + ); + expect(find.text('+40 받았어요!'), findsOneWidget); + expect(find.text('광고 추가 보상 +20 기여 POP'), findsOneWidget); + }); + }); } /// 지갑이 서버 값으로 바뀐 표본 — 생성 예치 · 수수료 뒤 헤더가 다시 맞는가. diff --git a/app/test/home_screen_test.dart b/app/test/home_screen_test.dart index dd76701..b6c88b8 100644 --- a/app/test/home_screen_test.dart +++ b/app/test/home_screen_test.dart @@ -14,6 +14,9 @@ import 'package:fancake/features/home/widgets/quest_row.dart'; import 'package:fancake/features/inside/inside_detail_screen.dart'; import 'package:fancake/features/quest/widgets/quest_card_tile.dart' show QuestIconBox; +import 'package:fancake/core/router/app_router.dart' show scrollTabToTop; +import 'package:fancake/features/notification/notification_screen.dart'; +import 'package:fancake/features/home/nebula_create_screen.dart' show isValidNebulaName; import 'eclipse_stubs.dart'; class _LiveGrowthRepo extends SampleHomeRepository { @@ -379,4 +382,59 @@ void main() { await tester.pumpAndSettle(); expect(opened, ['https://example.com/a']); }); + + group('설명 프레임 대조(홈 일반)', () { + Future badgeShown(WidgetTester tester, List items) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: HomeHeader( + key: UniqueKey(), + pop: 0, + notifications: SampleNotificationRepository(items: items), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return tester.widget(find.byKey(const ValueKey('home-bell-badge'))).isLabelVisible; + } + + AppNotification n(bool read) => AppNotification( + id: 'n$read', kind: NotificationKind.quest, title: 't', at: DateTime(2026), read: read, + ); + + testWidgets('2097:16385 — 안 읽은 알림이 있으면 알림 아이콘에 배지', (tester) async { + expect(await badgeShown(tester, [n(false), n(true)]), isTrue); + expect(await badgeShown(tester, [n(true)]), isFalse); + }); + + testWidgets('2097:16427 — 열린 탭을 다시 누르면 루트 화면이 맨 위로', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: key, + home: Scaffold( + body: ListView(children: [for (var i = 0; i < 50; i++) SizedBox(height: 100, child: Text('$i'))]), + ), + ), + ); + await tester.drag(find.byType(ListView), const Offset(0, -1500)); + await tester.pumpAndSettle(); + expect(find.text('0'), findsNothing); + scrollTabToTop(key); + await tester.pumpAndSettle(); + expect(find.text('0'), findsOneWidget); + }); + + test('516:87397 — 성운명 규칙은 서버와 같다', () { + for (final ok in ['방탄', 'BTS Army 7', '가' * 20]) { + expect(isValidNebulaName(ok), isTrue, reason: ok); + } + for (final bad in ['가', '가' * 21, ' 방탄', '방탄 ', '방 탄', '방탄!', '방탄😀']) { + expect(isValidNebulaName(bad), isFalse, reason: bad); + } + }); + }); } diff --git a/app/test/onboarding_test.dart b/app/test/onboarding_test.dart index 7f36323..fa9e4d6 100644 --- a/app/test/onboarding_test.dart +++ b/app/test/onboarding_test.dart @@ -110,6 +110,74 @@ void main() { ); }); + testWidgets('프로필 국가 — 사본 국가 리스트 모달(178:23489) 대로 국가명 · 단수 선택 · 「선택」 후 적용', ( + tester, + ) async { + final (auth, repo) = await boot(tester, needs: true); + await auth.signIn(SocialProvider.google); + await tester.pumpAndSettle(); + for (final label in [ + '서비스 이용약관 (필수)', + '개인정보 수집·이용 및 처리방침 동의 (필수)', + '위치기반 서비스 이용약관 (필수)', + '만 14세 이상입니다. (필수)', + ]) { + await tester.tap(find.text(label)); + } + await tester.pump(); + await tester.tap(find.text('완료')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('국가/지역').first); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('대한민국'), + 300, + scrollable: find.byType(Scrollable).last, + ); + await tester.tap(find.text('대한민국')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, '선택')); + await tester.pumpAndSettle(); + expect(find.textContaining('대한민국'), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, '우주팬'); + await tester.pump(); + await tester.tap(find.text('다음')); + await tester.pumpAndSettle(); + expect(repo.profile?.country, 'KR'); + }); + + testWidgets('프로필 뒤로가기(511:86825) — 약관으로 돌아가 동의가 체크된 채 보이고 다시 기록하지 않는다', ( + tester, + ) async { + final (auth, repo) = await boot(tester, needs: true); + await auth.signIn(SocialProvider.google); + await tester.pumpAndSettle(); + for (final label in [ + '서비스 이용약관 (필수)', + '개인정보 수집·이용 및 처리방침 동의 (필수)', + '위치기반 서비스 이용약관 (필수)', + '만 14세 이상입니다. (필수)', + ]) { + await tester.tap(find.text(label)); + } + await tester.pump(); + await tester.tap(find.text('완료')); + await tester.pumpAndSettle(); + final first = repo.agreed; + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + // 필수 4항이 이미 체크돼 있어 「완료」가 바로 켜져 있다. + final done = find.widgetWithText(FilledButton, '완료'); + expect(tester.widget(done).onPressed, isNotNull); + await tester.tap(done); + await tester.pumpAndSettle(); + expect(identical(repo.agreed, first), isTrue); + expect(find.byType(BackButton), findsOneWidget); // ③ 프로필로 돌아왔다. + }); + testWidgets('가입 모드에서 받은 약관은 다시 묻지 않고 기록만 한다', (tester) async { final (auth, repo) = await boot(tester, needs: true); auth.pendingConsent = { diff --git a/app/test/planet_growth_status_test.dart b/app/test/planet_growth_status_test.dart index 57b9b0f..9d7d4a6 100644 --- a/app/test/planet_growth_status_test.dart +++ b/app/test/planet_growth_status_test.dart @@ -29,7 +29,10 @@ void main() { await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: Scaffold(body: widget))); await tester.pumpAndSettle(); expect(find.textContaining('0 POP', findRichText: true), findsNothing); - expect(find.textContaining('님의', findRichText: true), findsNothing); + // HOME 히어로의 「{닉네임} 님의」는 로그인 사용자 인사 줄이라 늘 있다(설명 프레임 `2097:16391`). + if (widget is PlanetGrowthCard) { + expect(find.textContaining('님의', findRichText: true), findsNothing); + } expect(find.text('함께 키워가는 팬덤 행성'), findsOneWidget); } });