퀘스트 완료 햅틱 · HOME 행성 반응 · Push·라이브 띠 중복 제어 · 알림함 위계 · Push 탭 화면 이동

This commit is contained in:
Sn-Kinos
2026-10-08 15:48:56 +09:00
parent 0b95454b28
commit 93150a0d5b
28 changed files with 619 additions and 52 deletions
+93 -34
View File
@@ -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<void> clearOnSignOut() async {
late final Future<void> 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));