diff --git a/app/firebase/functions/src/notify/inbox.ts b/app/firebase/functions/src/notify/inbox.ts index 3c406f1..6e75730 100644 --- a/app/firebase/functions/src/notify/inbox.ts +++ b/app/firebase/functions/src/notify/inbox.ts @@ -25,6 +25,10 @@ export interface InboxItem { title: string; source: string; targetPath?: string; + /** 「내 행성」 사건의 팬덤 — Push 탭 시 그 행성 · 이클립스로 가고, 라이브 띠와 같은 사건인지 가린다. */ + fandomId?: string; + /** 이클립스 단계 — `start`(C-78 위기) · `defended`(결과, 통보 N-9). */ + phase?: 'start' | 'defended'; } export async function notifyOnce(uid: string, id: string, item: InboxItem): Promise { @@ -74,7 +78,7 @@ export async function notifyEclipseTransition( await notifyMembers(fandomId, `eclipse_${fandomId}_${roundId}_${started ? 'start' : 'defended'}`, { eventType: 'eclipse', type: 'fandomEvent', title: started ? '내 행성의 이클립스가 발생했어요.' : '내 행성의 이클립스를 방어했어요.', - source: name, + source: name, fandomId, phase: started ? 'start' : 'defended', }); return started ? 'started' : 'defended'; } @@ -84,7 +88,7 @@ export async function notifyHeroDrop(fandomId: string, dropId: string, heroNickn const hero = typeof heroNickname === 'string' && heroNickname ? heroNickname : '팬'; return notifyMembers(fandomId, `herodrop_${fandomId}_${dropId}`, { eventType: 'item_used', type: 'fandomEvent', - title: `${hero}님의 고귀한 기여로 영웅의 선물이 발동되었습니다!`, source: '아이템', + title: `${hero}님의 고귀한 기여로 영웅의 선물이 발동되었습니다!`, source: '아이템', fandomId, }); } @@ -120,16 +124,21 @@ export async function notifyQuestDone( * 트리거가 한 번만 돈다. 재시도는 두지 않는다 — 재전달 시 같은 Push 가 두 번 갈 수 있다. * ponytail: 회원 전원 알림(이클립스 · 영웅의 선물)은 회원 수만큼 함수가 돈다. 수십만이 되면 토큰 묶음 발송으로. */ -export async function pushInboxItem(uid: string, item: Partial): Promise { +export async function pushInboxItem( + uid: string, item: Partial & { campaignId?: string }, +): Promise { const category = item.type; if (!category || !PUSH_CATEGORIES.includes(category) || !item.title) return null; + // 앱이 탭 시 실제 화면으로 가고(v1.5 8장 Deep Link) C-78 단계를 가르는 값만 싣는다. + const extra = { targetPath: item.targetPath, campaignId: item.campaignId, phase: item.phase }; return sendUserPush(uid, { category, title: 'FANUZU', body: item.title, + ...(item.fandomId ? { fandomId: item.fandomId } : {}), data: { kind: item.eventType ?? '', - ...(item.targetPath ? { targetPath: item.targetPath } : {}), + ...Object.fromEntries(Object.entries(extra).filter((e): e is [string, string] => typeof e[1] === 'string' && e[1] !== '')), }, }); } diff --git a/app/firebase/functions/test/inbox.test.ts b/app/firebase/functions/test/inbox.test.ts index e7ef6d3..6219467 100644 --- a/app/firebase/functions/test/inbox.test.ts +++ b/app/firebase/functions/test/inbox.test.ts @@ -45,6 +45,8 @@ it('이클립스 발생 · 방어는 회원 전원에게 한 건씩, 재전달 const titles = items.map((n) => n.title).sort(); expect(titles).toEqual(['내 행성의 이클립스가 발생했어요.', '내 행성의 이클립스를 방어했어요.']); expect(items.every((n) => n.eventType === 'eclipse' && n.source === 'BTS' && !n.targetPath)).toBe(true); + // 앱이 단계(발생 = 위기 · 방어 = 결과)와 행성을 가른다. + expect(items.map((n) => `${n.phase}:${n.fandomId}`).sort()).toEqual([`defended:${FANDOM}`, `start:${FANDOM}`]); } }); @@ -54,6 +56,7 @@ it('영웅의 선물 발동은 회원 전원에게 발동자 닉네임으로', a expect(hero).toHaveLength(1); expect(hero[0]!.title).toBe('팜하니짱님의 고귀한 기여로 영웅의 선물이 발동되었습니다!'); expect(hero[0]!.source).toBe('아이템'); + expect(hero[0]!.fandomId).toBe(FANDOM); }); it('퀘스트 완료는 횟수가 오른 때만 본인에게, 상세 경로를 싣는다', async () => { diff --git a/app/firebase/functions/test/push_user.test.ts b/app/firebase/functions/test/push_user.test.ts index bbf9d94..8602b5a 100644 --- a/app/firebase/functions/test/push_user.test.ts +++ b/app/firebase/functions/test/push_user.test.ts @@ -74,3 +74,11 @@ it('토큰이 없으면 보내지 않는다 · 구분을 모르는 항목은 건 expect(await pushInboxItem(U, { title: 'x' })).toBeNull(); expect(sent).toHaveLength(0); }); + +it('탭 이동 · C-78 단계에 쓰는 값을 싣는다 — 팬덤 · 이클립스 단계 · 캠페인 (v1.5 8장 Deep Link)', async () => { + await getFirestore().doc(`users/${U}`).set({ fcmTokens: ['t1'] }); + expect(await pushInboxItem(U, { ...eclipse, fandomId: 'bts', phase: 'defended' })).toBe('sent'); + expect(await pushInboxItem(U, { eventType: 'campaign', type: 'essential', title: '정산', source: '', campaignId: 'c1' })).toBe('sent'); + expect(sent[0]!.data).toEqual({ category: 'fandomEvent', fandomId: 'bts', kind: 'eclipse', phase: 'defended' }); + expect(sent[1]!.data).toEqual({ category: 'essential', kind: 'campaign', campaignId: 'c1' }); +}); diff --git a/app/lib/app.dart b/app/lib/app.dart index 7ffec13..3118d51 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -17,7 +17,10 @@ import 'features/home/home_screen.dart' show homeRefreshSignal; import 'core/theme/app_theme.dart'; import 'features/auth/auth_repository.dart'; import 'features/campaign/campaign_detail_screen.dart'; +import 'features/eclipse/eclipse_defend_screen.dart' show eclipseDefendRoute; +import 'features/eclipse/widgets/live_proof.dart' show liveTickerShowing; import 'features/notification/notification_screen.dart'; +import 'features/planet/planet_detail_screen.dart'; import 'features/quest/quest_detail_screen.dart'; import 'features/translation/translation_comments.dart'; @@ -228,7 +231,14 @@ class _FancakeAppState extends State with WidgetsBindingObserver { void _showInApp(PushNotice notice) { // 타임어택 발동 · 퀘스트 완료 등 — HOME 카드가 바로 따라오게 다시 읽힌다. homeRefreshSignal.value++; - if (!notice.hasText || !shouldShowInApp(notice, _inAppShowing)) return; + if (!notice.hasText || + !shouldShowInApp( + notice, + _inAppShowing, + ticker: _homeOnTop ? liveTickerShowing : null, + )) { + return; + } final messenger = _messengerKey.currentState; if (messenger == null) return; messenger.hideCurrentSnackBar(); @@ -260,7 +270,39 @@ class _FancakeAppState extends State with WidgetsBindingObserver { /// 지금 떠 있는 앱 안 알림. 닫히면 비운다. PushNotice? _inAppShowing; - void _openFromPush(PushNotice notice) => _router.go(notice.route); + /// HOME 탭이 맨 위인가 — 루트에 덮인 화면이 없을 때만 라이브 띠가 보인다. + bool get _homeOnTop => + _router.routerDelegate.currentConfiguration.uri.path == '/home' && + !(_router.routerDelegate.navigatorKey.currentState?.canPop() ?? false); + + /// 알림을 눌렀을 때 — 탭으로 간 뒤 실제 해당 화면을 연다(v1.5 8장 Deep Link · 9장 Eclipse). + void _openFromPush(PushNotice notice) { + _router.go(notice.route); + final route = _pushRoute(notice.target); + if (route == null) return; + // 탭 전환이 그려진 다음에 연다. 그 전에 열면 go 가 새로 짠 화면 스택이 덮는다. + WidgetsBinding.instance.addPostFrameCallback( + (_) => _router.routerDelegate.navigatorKey.currentState?.push(route), + ); + } + + Route? _pushRoute(String? target) => switch (target?.split('/')) { + ['eclipse', final id] when id.isNotEmpty => eclipseDefendRoute( + repository: _repos.eclipse, + fandomId: id, + ), + ['planets', final id] when id.isNotEmpty => MaterialPageRoute( + builder: (_) => PlanetDetailScreen( + repository: _repos.planet(id), + watchPlanet: _repos.watchPlanet, + eclipse: _repos.eclipse, + ), + ), + _ => switch (target == null ? null : _notificationScreen(target)) { + final screen? => MaterialPageRoute(builder: (_) => screen), + null => null, + }, + }; /// 알림함 항목 → 화면(명세 `518:88353`). INSIDE · 라운지 게시물 이동은 그 알림을 만드는 /// 3차 범위와 함께 붙는다 — 지금은 경로를 모르는 것으로 두어 이동하지 않는다. diff --git a/app/lib/core/push/push_messaging.dart b/app/lib/core/push/push_messaging.dart index 1ea8852..597f010 100644 --- a/app/lib/core/push/push_messaging.dart +++ b/app/lib/core/push/push_messaging.dart @@ -1,7 +1,8 @@ import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:firebase_messaging/firebase_messaging.dart' show FirebaseMessaging; +import 'package:firebase_messaging/firebase_messaging.dart' + show FirebaseMessaging; import 'package:flutter/foundation.dart'; import '../data/backend.dart'; @@ -134,10 +135,12 @@ class PushMessaging { required void Function(PushNotice notice) onForeground, required void Function(PushNotice notice) onOpened, }) async { - _foreground = FirebaseMessaging.onMessage - .listen((m) => onForeground(PushNotice.of(m))); - _opened = FirebaseMessaging.onMessageOpenedApp - .listen((m) => onOpened(PushNotice.of(m))); + _foreground = FirebaseMessaging.onMessage.listen( + (m) => onForeground(PushNotice.of(m)), + ); + _opened = FirebaseMessaging.onMessageOpenedApp.listen( + (m) => onOpened(PushNotice.of(m)), + ); _tokenRefresh = _transport.onTokenRefresh.listen(_saveToken); final initial = await _transport.getInitialMessage(); @@ -160,9 +163,7 @@ class PushMessaging { Future clearOnSignOut() async { late final Future run; run = _unsubscribeAll() - .catchError( - (Object e) => debugPrint('[Push] topic cleanup failed: $e'), - ) + .catchError((Object e) => debugPrint('[Push] topic cleanup failed: $e')) .whenComplete(() { if (identical(_pendingClear, run)) _pendingClear = null; }); @@ -265,58 +266,116 @@ class PushNotice { required this.body, required this.kind, this.fandomId, + this.phase = '', + this.targetPath, + this.questId, + this.campaignId, }); - factory PushNotice.of(RemoteMessage message) => PushNotice( - title: message.notification?.title ?? '', - body: message.notification?.body ?? '', - kind: message.data['kind']?.toString() ?? '', - fandomId: message.data['fandomId']?.toString(), - ); + factory PushNotice.of(RemoteMessage message) { + String? str(String key) => switch (message.data[key]?.toString()) { + null || '' => null, + final v => v, + }; + return PushNotice( + title: message.notification?.title ?? '', + body: message.notification?.body ?? '', + kind: str('kind') ?? '', + fandomId: str('fandomId'), + phase: str('phase') ?? '', + targetPath: str('targetPath'), + questId: str('questId'), + campaignId: str('campaignId'), + ); + } final String title; final String body; - /// 서버가 실은 분기 키 — `briefing` · `eclipse`. + /// 서버가 실은 분기 키 — 팬덤 토픽 `briefing` · `eclipse` · `timeattack` · `evolution`, + /// 알림함 개인 Push 는 그 항목의 `eventType`(`eclipse` · `item_used` · `quest` · `campaign` · `evolution`). final String kind; final String? fandomId; - /// 탭했을 때 열 경로. - /// - /// 브리핑 상세 화면은 아직 라우트가 없어 홈의 브리핑 블록으로 보낸다. 이클립스는 - /// 갤럭시에서 행성으로 들어가는 동선이 이미 있다. + /// 이클립스 알림함 항목의 단계 — `start` · `defended`. 방어 성공은 위기가 아니라 결과다(N-9). + final String phase; + + /// 알림함 항목이 실은 이동 경로(`quests/{id}`) · 타임어택 퀘스트 · 캠페인 정산 알림의 캠페인. + final String? targetPath; + final String? questId; + final String? campaignId; + + /// 탭했을 때 먼저 갈 탭. 그 위에 [target] 화면을 연다. String get route => switch (kind) { 'eclipse' => '/galaxy', _ => '/home', }; + /// 탭했을 때 열 화면 — UX·FX v1.5 8장 「Deep Link — 공유/Push 에서 진입한 Planet/Campaign/Briefing 의 + /// 관련 화면으로」 · 9장 Eclipse 「탭 시 해당 Planet/Eclipse 로 Deep Link」 · HERO TIME 「해당 Planet 즉시 + /// 참여 CTA」. 경로 표기는 알림함과 같다(`quests/{id}` · `campaigns/{id}`), 행성은 `planets/{id}`, + /// 진행 중 이클립스는 `eclipse/{id}`. 모르면 null — 탭만 연다(브리핑은 HOME 블록이 그 화면이다). + String? get target { + if (targetPath case final p? when kind == 'quest' || kind == 'campaign') { + return p; + } + return switch ((kind, fandomId)) { + ('campaign', _) when campaignId != null => 'campaigns/$campaignId', + ('timeattack', _) when questId != null => 'quests/$questId', + ('eclipse', final f?) when phase != 'defended' => 'eclipse/$f', + ('eclipse' || 'evolution' || 'item_used', final f?) => 'planets/$f', + _ => null, + }; + } + /// 표시할 내용이 있는가. 데이터 전용 메시지는 화면에 띄우지 않는다. bool get hasText => title.isNotEmpty || body.isNotEmpty; - /// 원장 C-78 우선순위. 서버가 지금 싣는 `kind` 만 나눈다 — 모르는 종류는 단순 보상 유도형과 같이 - /// 가장 뒤([PushTier.other])라 상위 이벤트를 가리지 않는다. - PushTier get tier => switch (kind) { - 'eclipse' => PushTier.crisis, - 'timeattack' => PushTier.collective, - 'evolution' => PushTier.achievement, - _ => PushTier.other, - }; + PushTier get tier => pushTierOf(kind, phase: phase); + /// 같은 사건인가 — 문구가 아니라 사건으로 본다. 진화는 팬덤 토픽 Push 와 참여자 알림함 Push 가 + /// 문구만 다르게 함께 온다(v1.5 §1.1 「동일 이벤트가 과도하게 중첩되지 않도록」). bool sameAs(PushNotice other) => kind == other.kind && - title == other.title && - body == other.body && - fandomId == other.fandomId; + fandomId == other.fandomId && + phase == other.phase && + target == other.target; + + /// HOME 라이브 띠가 같은 사건을 띄우고 있을 때의 키([liveTickerKey]). 방어 성공은 띠에 없다. + String? get tickerKey => fandomId == null || phase == 'defended' + ? null + : liveTickerKey(kind, fandomId!); } +/// 라이브 띠 · Push 가 같은 사건을 가리키는 키 — Push `kind` 표기 + 팬덤. +String liveTickerKey(String kind, String fandomId) => '$kind:$fandomId'; + /// 원장 C-78 「Push 우선순위」 — ① 위기 ② 공동행동 ③ 성취 ④ 결과, 그 밖(단순 보상 유도형 포함)은 맨 뒤. enum PushTier { crisis, collective, achievement, result, other } +/// 원장 C-78 단계 배정 — Push 와 알림함이 같은 표를 쓴다. +/// +/// - 위기: 이클립스 사전 경고 · 발생 +/// - 공동행동: TIME ATTACK 시작 — **임시값(해석 확인 Q-C-10 A안)** +/// - 성취: Evolution · HERO TIME 발동(영웅의 선물, 알림함 `item_used`) +/// - 결과: Quest 완료 · Campaign 정산 · 이클립스 **방어 성공**(통보 N-9) +/// - 그 밖: AI 브리핑(**임시값 Q-C-10 A안** — 네 단계 예시에 없음) · 모르는 종류 +PushTier pushTierOf(String kind, {String phase = ''}) => switch (kind) { + 'eclipse' when phase == 'defended' => PushTier.result, + 'eclipse' => PushTier.crisis, + 'timeattack' => PushTier.collective, + 'evolution' || 'item_used' => PushTier.achievement, + 'quest' || 'campaign' => PushTier.result, + _ => PushTier.other, +}; + /// 앱 사용 중 수신한 알림을 띄울지 — UX·FX v1.5 §1.1 「Push 와 인앱 티커는 동일 이벤트가 과도하게 /// 중첩되지 않도록 노출 빈도를 제어하고 핵심 이벤트는 우선순위를 높게」 · 9장 Eclipse 「앱 사용 중 동일 /// 내용의 과도한 중복 Push」 금지. /// -/// [showing] 은 지금 떠 있는 알림. 같은 알림은 다시 띄우지 않고, 더 낮은 순위가 높은 순위를 덮지 않는다. -bool shouldShowInApp(PushNotice next, PushNotice? showing) => - showing == null || - (!next.sameAs(showing) && next.tier.index <= showing.tier.index); +/// [showing] 은 지금 떠 있는 알림. 같은 사건은 다시 띄우지 않고, 더 낮은 순위가 높은 순위를 덮지 않는다. +/// [ticker] 는 지금 보이는 HOME 라이브 띠의 사건([liveTickerKey]) — 띠가 이미 알리는 사건은 띄우지 않는다. +bool shouldShowInApp(PushNotice next, PushNotice? showing, {String? ticker}) => + (ticker == null || next.tickerKey != ticker) && + (showing == null || + (!next.sameAs(showing) && next.tier.index <= showing.tier.index)); diff --git a/app/lib/features/eclipse/widgets/live_proof.dart b/app/lib/features/eclipse/widgets/live_proof.dart index 8bddbeb..fa08bbb 100644 --- a/app/lib/features/eclipse/widgets/live_proof.dart +++ b/app/lib/features/eclipse/widgets/live_proof.dart @@ -22,7 +22,12 @@ String liveEventMessage(LiveEvent event) => switch (event.kind) { }; /// 홈 라이브 띠(전광판)에 올릴 한 줄. [hero] 면 띠가 HERO TIME 참여 진입이 된다. -typedef TickerLine = ({String text, bool hero}); +/// [push] 는 같은 사건을 알리는 Push 의 `kind` — 띠가 떠 있으면 앱 안 Push 를 겹쳐 띄우지 않는다(v1.5 §1.1). +typedef TickerLine = ({String text, bool hero, String? push}); + +/// 지금 화면에 보이는 HOME 라이브 띠의 사건 — `liveTickerKey(kind, fandomId)`. 띠가 그릴 때 적는다. +/// ponytail: 전역 하나. HOME 이 띠를 하나만 그리므로 충분하다 — 띠가 여러 곳에 생기면 집합으로. +String? liveTickerShowing; /// 띠 한 줄 고르기 — 원장 C-78 순서(위기 → 성취)대로 핵심 이벤트가 먼저 오른다. /// @@ -48,12 +53,14 @@ TickerLine? tickerLine({ return ( text: appL10n.eclipseLiveOccurred(eclipse.fandomName), hero: false, + push: 'eclipse', ); } if (eclipse.canShake(now) && eclipse.defense >= 2) { return ( text: appL10n.liveEclipseDefending('${eclipse.defense}'), hero: false, + push: 'eclipse', ); } } @@ -63,8 +70,12 @@ TickerLine? tickerLine({ ? appL10n.liveHeroTime(fandomName) : appL10n.liveHeroTimeBy(fandomName, heroNicknames.join(' · ')), hero: true, + // HERO TIME 발동 알림(영웅의 선물)은 알림함 `item_used` 로 온다. + push: 'item_used', ); } - if (event != null) return (text: liveEventMessage(event), hero: false); - return fallback == null ? null : (text: fallback, hero: false); + if (event != null) { + return (text: liveEventMessage(event), hero: false, push: null); + } + return fallback == null ? null : (text: fallback, hero: false, push: null); } diff --git a/app/lib/features/galaxy/widgets/zap_feedback.dart b/app/lib/features/galaxy/widgets/zap_feedback.dart index c84d349..0733439 100644 --- a/app/lib/features/galaxy/widgets/zap_feedback.dart +++ b/app/lib/features/galaxy/widgets/zap_feedback.dart @@ -19,8 +19,12 @@ class ZapFeedback extends StatefulWidget { required this.target, required this.radius, this.fandomName, + this.quest = false, }); + /// 퀘스트 보상 확정의 행성 반응인가 — 결과 문구를 퀘스트 반영으로 바꾼다. + final bool quest; + /// 기여가 반영된 행성 이름 — 9/21 회신 6절 「N POP 이 [팬덤] 성장에 반영되었습니다」. final String? fandomName; @@ -114,6 +118,12 @@ class _ZapFeedbackState extends State child: Text( widget.pending ? context.l10n.zapApplying + : widget.quest + ? (widget.fandomName ?? '').isNotEmpty + ? context.l10n.questReflected( + widget.fandomName!, + ) + : context.l10n.zapDone : result?.contributedPop == null ? context.l10n.zapDone : (widget.fandomName ?? '').isNotEmpty diff --git a/app/lib/features/galaxy/zap_controller.dart b/app/lib/features/galaxy/zap_controller.dart index bd25e5d..21230df 100644 --- a/app/lib/features/galaxy/zap_controller.dart +++ b/app/lib/features/galaxy/zap_controller.dart @@ -20,8 +20,30 @@ class ZapController extends ChangeNotifier { ({String fandomId, int count, String key})? _batch; bool get needsRetry => _batch != null && !sending; + /// 지금 결과가 ZAP 이 아니라 퀘스트 보상 확정의 행성 반응인가([react]). + bool questReaction = false; + + /// 퀘스트 보상 확정 뒤 행성 반응 — v1.5 4장 「성공 햅틱 → 보상/기여 에너지 이동 → Planet 반응 → + /// 실제 지급/성장 결과」의 에너지 이동 · 반응 두 단계. ZAP 과 같은 이동 · 흡수 연출을 쓰고 잔액은 + /// 건드리지 않는다. 서버가 보상을 확정한 뒤에만 부른다(C-79). + Future react() async { + if (_disposed || sending || pending > 0) return; + questReaction = true; + touchSerial++; + notifyListeners(); + // 에너지가 행성에 닿은 뒤 흡수 — ZapFeedback 이동 시간(380ms)과 같다. + await Future.delayed(const Duration(milliseconds: 380)); + if (_disposed || !questReaction) return; + result = ZapResult( + txId: 'quest-${DateTime.now().microsecondsSinceEpoch}', + balanceAfter: -1, + ); + notifyListeners(); + } + void tap(String fandomId) { if (_disposed || sending || _batch != null || fandomId.isEmpty) return; + questReaction = false; if (pending == 0) fxHaptic(FxIntensity.small); pending++; touchSerial++; diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index cd88905..3548252 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -14,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../core/format.dart'; +import '../../core/push/push_messaging.dart' show liveTickerKey; import '../../core/i18n/app_locale.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/sheet_route.dart'; @@ -43,6 +44,7 @@ import '../notification/notification_screen.dart'; import '../quest/quest_data.dart' show QuestRepository; import '../quest/quest_list_screen.dart'; import '../quest/quest_detail_screen.dart'; +import '../quest/quest_reward_screen.dart' show questPlanetReaction; import 'home_repository_firestore.dart' show kstDayKey; import 'widgets/quest_row.dart'; import 'widgets/briefing_share.dart'; @@ -125,10 +127,14 @@ class _HomeScreenState extends State with WidgetsBindingObserver { void _onZapChanged() => setState(() {}); + /// 퀘스트 보상 확정 뒤 HOME 으로 돌아오면 행성이 반응한다(v1.5 4장). + void _questReaction() => _zap?.react(); + @override void dispose() { WidgetsBinding.instance.removeObserver(this); homeRefreshSignal.removeListener(_refresh); + questPlanetReaction.removeListener(_questReaction); _zap?.dispose(); super.dispose(); } @@ -143,6 +149,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { super.initState(); WidgetsBinding.instance.addObserver(this); homeRefreshSignal.addListener(_refresh); + questPlanetReaction.addListener(_questReaction); _snapshot = widget.repository.load(); if (widget.galaxyRepository case final repository?) { _galaxy = repository.load(); @@ -292,6 +299,11 @@ class _LiveTickerSlot extends StatelessWidget { now: DateTime.now(), fallback: fallback, ); + // 앱 안 Push 중복 제어용 — 이 띠가 보이는 동안만 적는다(위에 화면이 덮이면 지운다). + liveTickerShowing = + line?.push == null || !(ModalRoute.isCurrentOf(context) ?? true) + ? null + : liveTickerKey(line!.push!, fandomId); if (line == null) return const SizedBox.shrink(); final hero = line.hero && onHero != null; return LiveTicker( diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart index ad4bf81..2b8c050 100644 --- a/app/lib/features/home/widgets/home_hero.dart +++ b/app/lib/features/home/widgets/home_hero.dart @@ -275,6 +275,7 @@ class _HomeHeroState extends State { child: IgnorePointer( child: ZapFeedback( fandomName: widget.growth.fandomName, + quest: zap.questReaction, touchSerial: zap.touchSerial, pending: zap.pending > 0 || zap.sending, result: zap.result, diff --git a/app/lib/features/notification/notification_data.dart b/app/lib/features/notification/notification_data.dart index 51551ab..9864cf6 100644 --- a/app/lib/features/notification/notification_data.dart +++ b/app/lib/features/notification/notification_data.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/data/backend.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/push/push_messaging.dart' show PushTier, pushTierOf; /// 알림함이 그리는 값과 그 출처 — 화면 대조 원장 F0 · Figma `229:11265`. /// @@ -85,6 +86,8 @@ class AppNotification { this.read = false, this.source, this.targetPath, + this.eventType, + this.phase = '', }); final String id; @@ -104,6 +107,22 @@ class AppNotification { final String? targetPath; String? get target => kind.navigable ? targetPath : null; + + /// 서버 문서의 `eventType` 원문. 없으면(표본) [kind] 로 읽는다. + final String? eventType; + + /// 이클립스 항목의 단계 — `start` · `defended`. + final String phase; + + /// 원장 C-78 단계 — 앱 안 Push 와 같은 표([pushTierOf]). 알림함은 이 단계로 시각 위계를 준다(v1.5 8장). + PushTier get tier => pushTierOf( + eventType ?? + switch (kind) { + NotificationKind.itemUsed => 'item_used', + _ => kind.name, + }, + phase: phase, + ); } /// 날짜 머리글. 명세 — *오늘 및 어제를 제외한 과거는 yyyy.mm.dd 로 표시*. @@ -231,6 +250,8 @@ class FirestoreNotificationRepository implements NotificationRepository { title: doc.data().strOr('title', appL10n.homeNotificationsTooltip), at: doc.data().dateOr('createdAt', _epoch), read: doc.data().boolOr('read'), + eventType: doc.data().strOr('eventType'), + phase: doc.data().strOr('phase'), // 서버가 아직 쓰지 않는 필드다. 비면 종류 이름으로 물러난다. source: switch (doc.data().strOr('source')) { '' => null, diff --git a/app/lib/features/notification/notification_screen.dart b/app/lib/features/notification/notification_screen.dart index 95e700a..7f8025d 100644 --- a/app/lib/features/notification/notification_screen.dart +++ b/app/lib/features/notification/notification_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../../core/push/push_messaging.dart' show PushTier; import '../../core/theme/tokens.dart'; import '../../core/ui/app_icons.dart'; import 'notification_data.dart'; @@ -144,7 +145,7 @@ class _NotificationScreenState extends State { decoration: BoxDecoration( color: fill, shape: BoxShape.circle, - border: Border.all(color: const Color(0xFF333333)), + border: Border.all(color: _tierRing(item.tier)), ), child: ImageIcon(icon, size: 14, color: AppColors.onCosmic), ), @@ -228,4 +229,14 @@ TextStyle _style(Color color, {double height = 1.3}) => TextStyle( ), }; +/// 원장 C-78 위계 — 위기 · 공동행동 · 성취 · 결과(v1.5 8장 「알림 우선순위 표현」). Figma 는 배지 획이 모두 +/// #333333 이라 결과 · 그 밖은 그대로 두고, 위 세 단계만 획 색으로 올린다. +/// 색은 **임시값** — 자료에 위계별 색 정의가 없어 기존 토큰(위기 = 경고 적색 · 공동행동 = 강조 · 성취 = 금)을 쓴다. +Color _tierRing(PushTier tier) => switch (tier) { + PushTier.crisis => AppColors.neonMagenta, + PushTier.collective => AppColors.cosmicAccent, + PushTier.achievement => AppColors.gold, + PushTier.result || PushTier.other => const Color(0xFF333333), +}; + AssetImage _icon(String name) => AssetImage('assets/icons/$name.png'); diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart index 8d96723..f03505e 100644 --- a/app/lib/features/quest/quest_detail_screen.dart +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -22,6 +22,7 @@ import 'quest_create_screen.dart' show QuestLinkCard; import 'quest_data.dart'; import 'quest_evidence.dart'; import 'qr_scan_screen.dart'; +import '../../core/ui/fx_intensity.dart'; import 'quest_reward_screen.dart'; import 'widgets/quest_card_tile.dart'; import 'widgets/time_attack_countdown.dart'; @@ -1059,6 +1060,8 @@ class _QuestDetailScreenState extends State { _busy = false; _stage = QuestStage.rewarded; }); + // 서버가 확정한 새 완료에만 성공 햅틱(v1.5 4장 Quest 완료 UX 첫 단계 · C-79 서버 확정 뒤). + if (!reward.duplicate) fxHaptic(questSuccessFx(detail.card.type)); // 시청 · 스트리밍은 무보상 참여다(C-04) — 보상 · 광고 화면 없이 참여만 알리고 링크를 연다. if (detail.card.type == QuestType.streaming && detail.card.endsAt == null) { _toast(context.l10n.questStreamJoined); diff --git a/app/lib/features/quest/quest_reward_screen.dart b/app/lib/features/quest/quest_reward_screen.dart index 8f8fa33..001ebd1 100644 --- a/app/lib/features/quest/quest_reward_screen.dart +++ b/app/lib/features/quest/quest_reward_screen.dart @@ -5,12 +5,23 @@ import 'package:flutter/rendering.dart'; import 'package:share_plus/share_plus.dart'; import '../../core/theme/tokens.dart'; +import '../../core/ui/fx_intensity.dart'; import '../campaign/campaign_share.dart' show shareHashtags; import '../home/home_screen.dart' show formatPop; import 'music_shake_play_screen.dart' show ShakeTally; import 'quest_data.dart'; import '../../core/i18n/app_locale.dart'; +/// 퀘스트 완료 성공 햅틱 강도 — UX·FX v1.5 §1 「Small: 일반 Quest」 · 「Medium: Music/Location Quest 완료」. +FxIntensity questSuccessFx(QuestType type) => switch (type) { + QuestType.musicShake || QuestType.place => FxIntensity.medium, + _ => FxIntensity.small, +}; + +/// 퀘스트 보상 확정 → HOME 행성 반응(v1.5 4장 · 진척현황 B-3 4-2 · 4-3). 보상 화면 「확인」이 올리고, +/// HOME 이 받아 에너지 이동 · 행성 흡수와 「퀘스트 완료가 {팬덤} 성장에 반영」을 띄운다. +final questPlanetReaction = ValueNotifier(0); + /// 보상 완료 — 화면 대조 원장 F4 · Figma `251:113651`(퀘스트 완료) · `482:15084`(광고 시청 후). /// /// 명세 `518:88630` — *보상 완료 아이콘 · 보상 POP 텍스트 · 중앙 정렬* @@ -209,8 +220,10 @@ class _QuestRewardScreenState extends State { child: FilledButton( // 설명 프레임 `518:88326` 「확인 버튼 - 클릭 시, 홈 화면 이동」 — 퀘스트 상세 · 목록까지 // 닫고 첫 화면(탭 셸)으로 돌아간다. 퀘스트는 HOME 에서만 열린다. - onPressed: () => - Navigator.of(context).popUntil((route) => route.isFirst), + onPressed: () { + questPlanetReaction.value++; + 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 0a3de07..38e88b6 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -904,5 +904,6 @@ "questQrForPlace": "Verification QR to post on site", "liveEclipseDefending": "[LIVE] {count} fans defending together now", "liveHeroTime": "[LIVE] HERO TIME on planet {fandom}", - "liveHeroTimeBy": "[LIVE] HERO TIME on planet {fandom} · by {name}" + "liveHeroTimeBy": "[LIVE] HERO TIME on planet {fandom} · by {name}", + "questReflected": "Quest completion counted toward {fandom}'s growth" } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 2573fe3..b997918 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -904,5 +904,6 @@ "questQrForPlace": "QR de verificación para colocar en el lugar", "liveEclipseDefending": "[LIVE] {count} fans defendiendo juntos ahora", "liveHeroTime": "[LIVE] HERO TIME en el planeta {fandom}", - "liveHeroTimeBy": "[LIVE] HERO TIME en el planeta {fandom} · by {name}" + "liveHeroTimeBy": "[LIVE] HERO TIME en el planeta {fandom} · by {name}", + "questReflected": "Tu misión completada se reflejó en el crecimiento de {fandom}" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 5bca77b..d4ddd36 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -904,5 +904,6 @@ "questQrForPlace": "現地に掲示する認証QR", "liveEclipseDefending": "[LIVE] いま{count}人のファンが一緒に防衛中", "liveHeroTime": "[LIVE] {fandom}惑星 HERO TIME 進行中", - "liveHeroTimeBy": "[LIVE] {fandom}惑星 HERO TIME · by {name}" + "liveHeroTimeBy": "[LIVE] {fandom}惑星 HERO TIME · by {name}", + "questReflected": "クエスト達成が{fandom}の成長に反映されました" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 023980b..725dd37 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1864,5 +1864,7 @@ "liveHeroTime": "[LIVE] {fandom} 행성 HERO TIME 진행 중", "@liveHeroTime": { "placeholders": { "fandom": { "type": "String" } } }, "liveHeroTimeBy": "[LIVE] {fandom} 행성 HERO TIME · by {name}", - "@liveHeroTimeBy": { "placeholders": { "fandom": { "type": "String" }, "name": { "type": "String" } } } + "@liveHeroTimeBy": { "placeholders": { "fandom": { "type": "String" }, "name": { "type": "String" } } }, + "questReflected": "퀘스트 완료가 {fandom} 성장에 반영되었습니다", + "@questReflected": { "placeholders": { "fandom": { "type": "String" } } } } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index c929194..fb2ec8d 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -5530,6 +5530,12 @@ abstract class AppLocalizations { /// In ko, this message translates to: /// **'[LIVE] {fandom} 행성 HERO TIME · by {name}'** String liveHeroTimeBy(String fandom, String name); + + /// No description provided for @questReflected. + /// + /// In ko, this message translates to: + /// **'퀘스트 완료가 {fandom} 성장에 반영되었습니다'** + String questReflected(String fandom); } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 8a64563..e8b081c 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -3038,4 +3038,9 @@ class AppLocalizationsEn extends AppLocalizations { String liveHeroTimeBy(String fandom, String name) { return '[LIVE] HERO TIME on planet $fandom · by $name'; } + + @override + String questReflected(String fandom) { + return 'Quest completion counted toward $fandom\'s growth'; + } } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 8d5ba34..0a9d896 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -3038,4 +3038,9 @@ class AppLocalizationsEs extends AppLocalizations { String liveHeroTimeBy(String fandom, String name) { return '[LIVE] HERO TIME en el planeta $fandom · by $name'; } + + @override + String questReflected(String fandom) { + return 'Tu misión completada se reflejó en el crecimiento de $fandom'; + } } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 63bbef4..32b4e99 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -3038,4 +3038,9 @@ class AppLocalizationsJa extends AppLocalizations { String liveHeroTimeBy(String fandom, String name) { return '[LIVE] $fandom惑星 HERO TIME · by $name'; } + + @override + String questReflected(String fandom) { + return 'クエスト達成が$fandomの成長に反映されました'; + } } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index ec7bb08..83881b7 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -3038,4 +3038,9 @@ class AppLocalizationsKo extends AppLocalizations { String liveHeroTimeBy(String fandom, String name) { return '[LIVE] $fandom 행성 HERO TIME · by $name'; } + + @override + String questReflected(String fandom) { + return '퀘스트 완료가 $fandom 성장에 반영되었습니다'; + } } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 83aa76e..9a6caba 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -3038,4 +3038,9 @@ class AppLocalizationsZh extends AppLocalizations { String liveHeroTimeBy(String fandom, String name) { return '[LIVE] $fandom行星 HERO TIME · by $name'; } + + @override + String questReflected(String fandom) { + return '任务完成已计入$fandom的成长'; + } } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 1717f10..0328cd2 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -904,5 +904,6 @@ "questQrForPlace": "张贴在现场的认证二维码", "liveEclipseDefending": "[LIVE] 现在有{count}位粉丝一起防御中", "liveHeroTime": "[LIVE] {fandom}行星 HERO TIME 进行中", - "liveHeroTimeBy": "[LIVE] {fandom}行星 HERO TIME · by {name}" + "liveHeroTimeBy": "[LIVE] {fandom}行星 HERO TIME · by {name}", + "questReflected": "任务完成已计入{fandom}的成长" } diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 9cbe7c5..f24882a 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -2113,6 +2113,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get questQrForPlace => _remote['questQrForPlace'] ?? _base.questQrForPlace; + @override + String questReflected(String fandom) => _fill('questReflected', {'fandom': fandom}) ?? _base.questReflected(fandom); + @override String get questReject => _remote['questReject'] ?? _base.questReject; diff --git a/app/test/quest_react_push_test.dart b/app/test/quest_react_push_test.dart new file mode 100644 index 0000000..4ac590a --- /dev/null +++ b/app/test/quest_react_push_test.dart @@ -0,0 +1,302 @@ +import 'package:fancake/core/push/push_messaging.dart'; +import 'package:fancake/core/theme/tokens.dart'; +import 'package:fancake/core/ui/fx_intensity.dart'; +import 'package:fancake/features/eclipse/widgets/live_proof.dart'; +import 'package:fancake/features/galaxy/galaxy_data.dart'; +import 'package:fancake/features/galaxy/widgets/zap_feedback.dart'; +import 'package:fancake/features/galaxy/zap_controller.dart'; +import 'package:fancake/features/notification/notification_screen.dart'; +import 'package:fancake/features/quest/quest_data.dart'; +import 'package:fancake/features/quest/quest_reward_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'eclipse_stubs.dart'; + +/// UX·FX v1.5 2차 약속분(10/8) — 진척현황 부록 B-3 1-2 · 4-2 · 4-3(퀘스트 완료 → 성공 햅틱 · 행성 반응), +/// 1-4 · 8-6(Push ↔ 라이브 띠 중복 제어 · 알림 위계 · Push 탭 실제 화면 이동), 원장 C-78 단계 배정. +void main() { + group('C-78 단계 배정 — Push · 알림함 같은 표', () { + test('위기 · 공동행동 · 성취 · 결과 · 그 밖', () { + expect(pushTierOf('eclipse'), PushTier.crisis); + expect(pushTierOf('eclipse', phase: 'start'), PushTier.crisis); + expect(pushTierOf('eclipse', phase: 'defended'), PushTier.result); + expect(pushTierOf('timeattack'), PushTier.collective); + expect(pushTierOf('evolution'), PushTier.achievement); + expect( + pushTierOf('item_used'), + PushTier.achievement, + reason: 'HERO TIME 발동 = 성취', + ); + expect(pushTierOf('quest'), PushTier.result); + expect(pushTierOf('campaign'), PushTier.result); + expect(pushTierOf('briefing'), PushTier.other); + expect(pushTierOf(''), PushTier.other); + }); + + test('알림함 항목도 같은 단계 — 표본(eventType 없음)은 종류로 읽는다', () { + AppNotification n( + NotificationKind kind, { + String? event, + String phase = '', + }) => AppNotification( + id: 'x', + kind: kind, + title: 't', + at: DateTime(2026), + eventType: event, + phase: phase, + ); + expect(n(NotificationKind.itemUsed).tier, PushTier.achievement); + expect( + n(NotificationKind.eclipse, event: 'eclipse', phase: 'defended').tier, + PushTier.result, + ); + // 승계 알림처럼 eventType 이 빈 문서는 아이템 사용으로 떨어지지만 위계는 올리지 않는다. + expect(n(NotificationKind.itemUsed, event: '').tier, PushTier.other); + }); + }); + + group('Push 탭 → 실제 화면 (v1.5 8장 Deep Link · 9장)', () { + PushNotice n( + String kind, { + String? fandomId, + String phase = '', + String? targetPath, + String? questId, + String? campaignId, + }) => PushNotice( + title: 't', + body: 'b', + kind: kind, + fandomId: fandomId, + phase: phase, + targetPath: targetPath, + questId: questId, + campaignId: campaignId, + ); + + test('종류마다 해당 화면', () { + expect(n('quest', targetPath: 'quests/q1').target, 'quests/q1'); + expect(n('campaign', campaignId: 'c1').target, 'campaigns/c1'); + expect( + n('timeattack', questId: 'q2', fandomId: 'bts').target, + 'quests/q2', + ); + expect(n('eclipse', fandomId: 'bts').target, 'eclipse/bts'); + expect( + n('eclipse', fandomId: 'bts', phase: 'defended').target, + 'planets/bts', + ); + expect(n('evolution', fandomId: 'bts').target, 'planets/bts'); + expect(n('item_used', fandomId: 'bts').target, 'planets/bts'); + expect( + n('briefing', fandomId: 'bts').target, + isNull, + reason: 'HOME 브리핑 블록이 그 화면', + ); + expect(n('eclipse').target, isNull, reason: '행성을 모르면 탭만'); + }); + + test('같은 사건은 문구가 달라도 한 번 — 진화 토픽 Push + 참여자 알림함 Push', () { + final topic = PushNotice( + title: '진화', + body: 'a', + kind: 'evolution', + fandomId: 'bts', + ); + const inbox = PushNotice( + title: 'FANUZU', + body: 'b', + kind: 'evolution', + fandomId: 'bts', + ); + expect(shouldShowInApp(inbox, topic), isFalse); + expect( + shouldShowInApp( + const PushNotice( + title: 'FANUZU', + body: 'b', + kind: 'evolution', + fandomId: 'twice', + ), + topic, + ), + isTrue, + ); + }); + + test('라이브 띠가 이미 알리는 사건은 띄우지 않는다 (v1.5 §1.1 Push ↔ 인앱 티커)', () { + final ticker = liveTickerKey('eclipse', 'bts'); + expect( + shouldShowInApp( + n('eclipse', fandomId: 'bts', phase: 'start'), + null, + ticker: ticker, + ), + isFalse, + ); + expect( + shouldShowInApp(n('eclipse', fandomId: 'twice'), null, ticker: ticker), + isTrue, + reason: '다른 행성', + ); + expect( + shouldShowInApp( + n('eclipse', fandomId: 'bts', phase: 'defended'), + null, + ticker: ticker, + ), + isTrue, + reason: '방어 성공은 띠에 없다', + ); + expect( + shouldShowInApp( + n('item_used', fandomId: 'bts'), + null, + ticker: liveTickerKey('item_used', 'bts'), + ), + isFalse, + reason: 'HERO TIME 전광판', + ); + }); + + test('띠 한 줄은 같은 사건의 Push 종류를 들고 있다', () { + final start = DateTime.now().subtract(const Duration(seconds: 5)); + TickerLine? line({eclipse, bool hero = false}) => tickerLine( + eclipse: eclipse, + heroTime: hero, + heroNicknames: const [], + events: const [], + fandomName: 'BTS', + now: DateTime.now(), + fallback: 'f', + ); + expect(line(eclipse: eclipseFixture(startAt: start))!.push, 'eclipse'); + expect(line(hero: true)!.push, 'item_used'); + expect(line()!.push, isNull); + }); + }); + + testWidgets('알림함 위계 — 위기 · 공동행동 · 성취만 배지 획 색을 올린다 (v1.5 8장)', (tester) async { + final now = DateTime.now(); + AppNotification item( + String id, + NotificationKind kind, + String event, { + String phase = '', + }) => AppNotification( + id: id, + kind: kind, + title: id, + at: now, + eventType: event, + phase: phase, + ); + await tester.pumpWidget( + MaterialApp( + home: NotificationScreen( + repository: SampleNotificationRepository( + items: [ + item( + 'crisis', + NotificationKind.eclipse, + 'eclipse', + phase: 'start', + ), + item('achieve', NotificationKind.itemUsed, 'item_used'), + item('result', NotificationKind.quest, 'quest'), + ], + ), + ), + ), + ); + await tester.pump(); + Color ring(String title) { + final row = find + .ancestor(of: find.text(title), matching: find.byType(Row)) + .last; + final badge = tester.widget( + find.descendant(of: row, matching: find.byType(Container)).first, + ); + return ((badge.decoration! as BoxDecoration).border! as Border).top.color; + } + + expect(ring('crisis'), AppColors.neonMagenta); + expect(ring('achieve'), AppColors.gold); + expect(ring('result'), const Color(0xFF333333), reason: 'Figma 기본 획'); + }); + + group('퀘스트 완료 → 성공 햅틱 · 행성 반응 (v1.5 4장)', () { + test('Music/Location 은 Medium, 그 밖은 Small (v1.5 §1)', () { + expect(questSuccessFx(QuestType.musicShake), FxIntensity.medium); + expect(questSuccessFx(QuestType.place), FxIntensity.medium); + expect(questSuccessFx(QuestType.steps), FxIntensity.small); + expect(questSuccessFx(QuestType.attend), FxIntensity.small); + }); + + testWidgets('보상 화면 「확인」이 HOME 행성 반응을 올린다', (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, + ); + tester.view.physicalSize = const Size(1080, 2600); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + const MaterialApp( + home: QuestRewardScreen( + detail: QuestDetail(card: card, imageLabel: '', description: ''), + reward: QuestReward(rewardedPop: 20, balanceAfter: 0), + ), + ), + ); + final before = questPlanetReaction.value; + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + expect(questPlanetReaction.value, before + 1); + }); + + testWidgets('행성 반응 — 에너지 이동 뒤 흡수 · 퀘스트 반영 문구, 잔액은 그대로', (tester) async { + final zap = ZapController(SampleGalaxyRepository(), onFailure: (_) {}); + addTearDown(zap.dispose); + zap.balance = 100; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListenableBuilder( + listenable: zap, + builder: (_, _) => ZapFeedback( + fandomName: 'BTS', + quest: zap.questReaction, + touchSerial: zap.touchSerial, + pending: zap.pending > 0 || zap.sending, + result: zap.result, + source: Offset.zero, + target: const Offset(100, 100), + radius: 40, + ), + ), + ), + ), + ); + final reacting = zap.react(); + await tester.pump(); + expect(zap.touchSerial, 1, reason: '먼저 에너지 이동'); + expect(zap.result, isNull, reason: '흡수는 이동 뒤'); + await tester.pump(const Duration(milliseconds: 400)); + await reacting; + await tester.pump(); + expect(find.text('퀘스트 완료가 BTS 성장에 반영되었습니다'), findsOneWidget); + expect(zap.balance, 100); + await tester.pump(const Duration(seconds: 2)); + }); + }); +} diff --git a/app/test/uxfx_round2_test.dart b/app/test/uxfx_round2_test.dart index cb5e649..feb0e39 100644 --- a/app/test/uxfx_round2_test.dart +++ b/app/test/uxfx_round2_test.dart @@ -73,7 +73,7 @@ void main() { expect(shouldShowInApp(n('briefing'), n('eclipse')), isFalse); expect(shouldShowInApp(n('evolution'), n('timeattack')), isFalse); expect(shouldShowInApp(n('eclipse'), n('briefing')), isTrue); - expect(shouldShowInApp(n('evolution', 'x'), n('evolution', 'y')), isTrue); + expect(shouldShowInApp(n('evolution', 'x'), n('quest', 'y')), isTrue); }); test('위기 → 공동행동 → 성취 순', () { expect(n('eclipse').tier, PushTier.crisis);