퀘스트 완료 햅틱 · HOME 행성 반응 · Push·라이브 띠 중복 제어 · 알림함 위계 · Push 탭 화면 이동
This commit is contained in:
+44
-2
@@ -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<FancakeApp> 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<FancakeApp> 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<void>? _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차 범위와 함께 붙는다 — 지금은 경로를 모르는 것으로 두어 이동하지 않는다.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ZapFeedback>
|
||||
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
|
||||
|
||||
@@ -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<void> react() async {
|
||||
if (_disposed || sending || pending > 0) return;
|
||||
questReaction = true;
|
||||
touchSerial++;
|
||||
notifyListeners();
|
||||
// 에너지가 행성에 닿은 뒤 흡수 — ZapFeedback 이동 시간(380ms)과 같다.
|
||||
await Future<void>.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++;
|
||||
|
||||
@@ -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<HomeScreen> 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<HomeScreen> 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(
|
||||
|
||||
@@ -275,6 +275,7 @@ class _HomeHeroState extends State<HomeHero> {
|
||||
child: IgnorePointer(
|
||||
child: ZapFeedback(
|
||||
fandomName: widget.growth.fandomName,
|
||||
quest: zap.questReaction,
|
||||
touchSerial: zap.touchSerial,
|
||||
pending: zap.pending > 0 || zap.sending,
|
||||
result: zap.result,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NotificationScreen> {
|
||||
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');
|
||||
|
||||
@@ -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<QuestDetailScreen> {
|
||||
_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);
|
||||
|
||||
@@ -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<int>(0);
|
||||
|
||||
/// 보상 완료 — 화면 대조 원장 F4 · Figma `251:113651`(퀘스트 완료) · `482:15084`(광고 시청 후).
|
||||
///
|
||||
/// 명세 `518:88630` — *보상 완료 아이콘 · 보상 POP 텍스트 · 중앙 정렬*
|
||||
@@ -209,8 +220,10 @@ class _QuestRewardScreenState extends State<QuestRewardScreen> {
|
||||
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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}の成長に反映されました"
|
||||
}
|
||||
|
||||
@@ -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" } } }
|
||||
}
|
||||
|
||||
@@ -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<AppLocalizations> {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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の成長に反映されました';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 성장에 반영되었습니다';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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的成长';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}的成长"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user