퀘스트 완료 햅틱 · 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
@@ -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++;
+12
View File
@@ -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(