diff --git a/app/lib/app.dart b/app/lib/app.dart index 1934c37..7ffec13 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -228,28 +228,38 @@ class _FancakeAppState extends State with WidgetsBindingObserver { void _showInApp(PushNotice notice) { // 타임어택 발동 · 퀘스트 완료 등 — HOME 카드가 바로 따라오게 다시 읽힌다. homeRefreshSignal.value++; - if (!notice.hasText) return; - _messengerKey.currentState - ?..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - // action 이 있으면 기본으로 남아 있으므로(Flutter 3.29+) 시간이 지나면 닫히게 한다. - persist: false, - duration: const Duration(seconds: 4), - content: Text( - [notice.title, notice.body].where((s) => s.isNotEmpty).join('\n'), - maxLines: 3, - overflow: TextOverflow.ellipsis, + if (!notice.hasText || !shouldShowInApp(notice, _inAppShowing)) return; + final messenger = _messengerKey.currentState; + if (messenger == null) return; + messenger.hideCurrentSnackBar(); + _inAppShowing = notice; + messenger + .showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + // action 이 있으면 기본으로 남아 있으므로(Flutter 3.29+) 시간이 지나면 닫히게 한다. + persist: false, + duration: const Duration(seconds: 4), + content: Text( + [notice.title, notice.body].where((s) => s.isNotEmpty).join('\n'), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + action: SnackBarAction( + label: (_messengerKey.currentContext ?? context).l10n.commonView, + onPressed: () => _openFromPush(notice), + ), ), - action: SnackBarAction( - label: (_messengerKey.currentContext ?? context).l10n.commonView, - onPressed: () => _openFromPush(notice), - ), - ), - ); + ) + .closed + .then((_) { + if (identical(_inAppShowing, notice)) _inAppShowing = null; + }); } + /// 지금 떠 있는 앱 안 알림. 닫히면 비운다. + PushNotice? _inAppShowing; + void _openFromPush(PushNotice notice) => _router.go(notice.route); /// 알림함 항목 → 화면(명세 `518:88353`). INSIDE · 라운지 게시물 이동은 그 알림을 만드는 diff --git a/app/lib/core/data/planet_visual_state.dart b/app/lib/core/data/planet_visual_state.dart index 12dcc4f..2631cf0 100644 --- a/app/lib/core/data/planet_visual_state.dart +++ b/app/lib/core/data/planet_visual_state.dart @@ -4,6 +4,8 @@ class PlanetVisualState { this.stage, this.level, this.isEclipsed = false, + this.eclipseFailed = false, + this.eclipseSuccessId, this.isHibernating = false, this.isHeroTime = false, this.heroEventIds, @@ -42,6 +44,13 @@ class PlanetVisualState { final String? planetType; final int? level; final bool isEclipsed; + + /// 최근 이클립스 회차가 서버 판정 실패로 끝났는가 — v1.5 §5 실패 「밝기·Glow·Particle 감소」. + /// 다음 회차가 열리면(최신 회차가 바뀌면) 풀린다. + final bool eclipseFailed; + + /// 서버 판정 성공 회차 ID — 바뀌는 순간 Orbit Burst 한 번(v1.5 §5 성공). + final String? eclipseSuccessId; final bool isHibernating; final bool isHeroTime; diff --git a/app/lib/core/data/repositories.dart b/app/lib/core/data/repositories.dart index f5fbb6a..33b3f07 100644 --- a/app/lib/core/data/repositories.dart +++ b/app/lib/core/data/repositories.dart @@ -156,6 +156,8 @@ class Repositories { Stream _openPlanet(String fandomId) => Stream.multi((out) { PlanetVisualState? planet; var isEclipsed = false; + var eclipseFailed = false; + String? eclipseSuccessId; var isHeroTime = false; Set? heroEventIds; Timer? heroExpiry; @@ -174,6 +176,8 @@ class Repositories { level: current.level, evolutionImminent: current.evolutionImminent, isEclipsed: isEclipsed, + eclipseFailed: eclipseFailed, + eclipseSuccessId: eclipseSuccessId, isHeroTime: isHeroTime, heroEventIds: heroEventIds, heroEndsAt: heroEndsAt, @@ -191,9 +195,9 @@ class Repositories { planet = PlanetVisualState.fromDocument(s.data() ?? const {}); emit(); }, onError: out.addError); - final danger = eclipse.watch(fandomId: fandomId).listen((state) { - isEclipsed = - state != null && !state.defended && !state.isExpired(DateTime.now()); + // 판정 결과까지 받는다 — 활성 회차만 보면 마감 순간 null 이 되어 성공 · 실패를 가를 수 없다. + final danger = eclipse.watch(fandomId: fandomId, includeResults: true).listen((state) { + (isEclipsed, eclipseFailed, eclipseSuccessId) = eclipseLook(state, DateTime.now()); emit(); }, onError: out.addError); // 기존 선물 이벤트만 읽는다. 구매 시도/로컬 캐시는 축제 확정이 아니다. @@ -264,3 +268,15 @@ class Repositories { // 눌러도 같은 상세가 열린다. : SamplePlanetRepository(fandomId: fandomId); } + +/// 최근 이클립스 회차 → 행성 표현 (진행 중 감소 · 실패 감소 · 성공 회차 ID). +/// 판정 대기(마감 후 결과 전)는 셋 다 아니다 — 오류를 실패처럼 보이지 않는다(v1.5 §5 집계 오류). +(bool, bool, String?) eclipseLook(EclipseState? state, DateTime now) { + if (state == null) return (false, false, null); + final won = state.defended || state.result == EclipseResult.success; + return ( + state.result == null && !won && !state.isExpired(now), + state.result == EclipseResult.failure, + won ? state.eclipseId : null, + ); +} diff --git a/app/lib/core/push/push_messaging.dart b/app/lib/core/push/push_messaging.dart index 8f99411..1ea8852 100644 --- a/app/lib/core/push/push_messaging.dart +++ b/app/lib/core/push/push_messaging.dart @@ -292,4 +292,31 @@ class PushNotice { /// 표시할 내용이 있는가. 데이터 전용 메시지는 화면에 띄우지 않는다. 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, + }; + + bool sameAs(PushNotice other) => + kind == other.kind && + title == other.title && + body == other.body && + fandomId == other.fandomId; } + +/// 원장 C-78 「Push 우선순위」 — ① 위기 ② 공동행동 ③ 성취 ④ 결과, 그 밖(단순 보상 유도형 포함)은 맨 뒤. +enum PushTier { crisis, collective, achievement, result, 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); diff --git a/app/lib/core/ui/fx_intensity.dart b/app/lib/core/ui/fx_intensity.dart new file mode 100644 index 0000000..e4812ef --- /dev/null +++ b/app/lib/core/ui/fx_intensity.dart @@ -0,0 +1,22 @@ +import 'package:flutter/services.dart'; + +/// UX·FX 정렬 기준 v1.5 §1 공통 강도 5단계 — 기능마다 햅틱을 따로 고르지 않게 한 곳에 둔다. +/// +/// | 강도 | 대표 상황(원문) | 햅틱 원칙(원문) | +/// |---|---|---| +/// | Micro | 출석 · 좋아요 등 경량 행동 | Light 1회 또는 생략 | +/// | Small | 일반 ZAP · 일반 Quest | Light | +/// | Medium | Music/Location Quest 완료 · Level Up | Medium | +/// | Large | Campaign 주요 기여 · Eclipse 공동 방어 | Medium~Strong, 반복 억제 | +/// | Legendary | Planet Evolution · Campaign 100% · HERO GIFT | Strong 1회 + 필요 시 짧은 패턴 | +/// +/// Large 는 원문이 「Medium~Strong」 범위라 Medium 과 구분되도록 Strong 을 쓴다(해석 확인 항목). +/// Legendary 의 「짧은 패턴」은 원문에 길이 · 횟수가 없어 Strong 1회만 낸다(해석 확인 항목). +enum FxIntensity { micro, small, medium, large, legendary } + +/// 강도에 맞는 햅틱 한 번. +Future fxHaptic(FxIntensity level) => switch (level) { + FxIntensity.micro || FxIntensity.small => HapticFeedback.lightImpact(), + FxIntensity.medium => HapticFeedback.mediumImpact(), + FxIntensity.large || FxIntensity.legendary => HapticFeedback.heavyImpact(), +}; diff --git a/app/lib/core/ui/live_planet.dart b/app/lib/core/ui/live_planet.dart index 9a404d5..f69ccd3 100644 --- a/app/lib/core/ui/live_planet.dart +++ b/app/lib/core/ui/live_planet.dart @@ -45,6 +45,8 @@ class LivePlanet25D extends StatelessWidget { level: state?.level, evolutionImminent: state?.evolutionImminent ?? false, isEclipsed: state?.isEclipsed ?? false, + eclipseFailed: state?.eclipseFailed ?? false, + eclipseSuccessId: state?.eclipseSuccessId, isHibernating: state?.isHibernating ?? false, isHeroTime: state?.isHeroTime ?? false, heroEventIds: state?.heroEventIds, diff --git a/app/lib/core/ui/planet_25d.dart b/app/lib/core/ui/planet_25d.dart index 5870775..1724595 100644 --- a/app/lib/core/ui/planet_25d.dart +++ b/app/lib/core/ui/planet_25d.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../theme/tokens.dart'; import 'planet_evolution.dart'; +import 'planet_fx_scope.dart'; import 'adaptive_planet_effects.dart'; import '../i18n/app_locale.dart'; @@ -134,6 +135,8 @@ class Planet25D extends StatefulWidget { this.level, this.evolutionImminent = false, this.isEclipsed = false, + this.eclipseFailed = false, + this.eclipseSuccessId, this.isHibernating = false, this.isHeroTime = false, this.heroEventIds, @@ -161,6 +164,12 @@ class Planet25D extends StatefulWidget { final int? stage; final int? level; final bool isEclipsed; + + /// 이클립스 실패 확정 — 파괴 표현 없이 밝기 · Glow · Particle 만 줄인다(v1.5 §5). + final bool eclipseFailed; + + /// 이클립스 성공 회차. 바뀌면 광량 복원 뒤 짧은 Orbit Burst(v1.5 §5 「그림자 이탈 → 복원 → Orbit Burst」). + final String? eclipseSuccessId; final bool isHibernating; final bool isHeroTime; final Set? heroEventIds; @@ -180,7 +189,7 @@ class Planet25D extends StatefulWidget { } class _Planet25DState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { int get _quality => widget.lowEffects ? 2 : PlanetEffectsQuality.of(context); bool get _lowEffects => _quality >= 2; bool get _celebrating => @@ -198,6 +207,9 @@ class _Planet25DState extends State ? 0.5 : _celebrating ? 1.3 + : widget.eclipseFailed + // 실패의 감소 폭은 원문에 수치가 없어 이클립스 진행 중과 같은 값을 쓴다(해석 확인 항목). + ? 0.5 : 1; late final AnimationController _c = AnimationController( vsync: this, @@ -217,6 +229,7 @@ class _Planet25DState extends State @override void didChangeDependencies() { super.didChangeDependencies(); + _fx = PlanetFxScope.of(context); _syncMotion(); } @@ -230,10 +243,32 @@ class _Planet25DState extends State if (_motionEnabled) _c.repeat(); } _syncMotion(); + _maybeBurst(old); + } + + /// 이클립스 성공 연출 — 광량 복원([_restore]) 뒤 Orbit Burst([orbitBurstDuration]). + late final AnimationController _burst = AnimationController( + vsync: this, + duration: _restore + orbitBurstDuration, + )..addStatusListener((status) { + if (status == AnimationStatus.completed) _fx?.setActive(this, false); + }); + static const _restore = Duration(seconds: 2); + PlanetFxActivity? _fx; + + void _maybeBurst(Planet25D old) { + final id = widget.eclipseSuccessId; + if (id == null || id == old.eclipseSuccessId || !_motionEnabled) return; + // 한 화면에 강한 연출은 하나 — 진화 · HERO 가 재생 중이면 겹치지 않는다(v1.5 §1.1). + if (_fx?.busy ?? false) return; + _fx?.setActive(this, true); + _burst.forward(from: 0); } @override void dispose() { + _fx?.setActive(this, false); + _burst.dispose(); _c.dispose(); super.dispose(); } @@ -252,10 +287,39 @@ class _Planet25DState extends State heroEnabled: !widget.isEclipsed && !widget.isHibernating, animate: widget.animate, lowEffects: _lowEffects, - builder: (stage) => Opacity( - key: const ValueKey('planet-surface-light'), - opacity: light.clamp(0.0, 1.0), - child: _planet(context, stage, light), + builder: (stage) => Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Opacity( + key: const ValueKey('planet-surface-light'), + opacity: light.clamp(0.0, 1.0), + child: _planet(context, stage, light), + ), + if (_burst.isAnimating) + Positioned.fill( + child: IgnorePointer( + child: AnimatedBuilder( + animation: _burst, + builder: (context, _) { + final total = _burst.duration!.inMilliseconds; + final p = + ((_burst.value * total - _restore.inMilliseconds) / + orbitBurstDuration.inMilliseconds) + .clamp(0.0, 1.0); + return CustomPaint( + key: const ValueKey('eclipse-orbit-burst'), + painter: _OrbitBurstPainter( + p, + widget.glow, + _lowEffects, + ), + ); + }, + ), + ), + ), + ], ), ), ); @@ -818,3 +882,25 @@ class _SustainedAura extends CustomPainter { bool shouldRepaint(_SustainedAura old) => old.color != color || old.lowEffects != lowEffects; } + +/// 이클립스 성공 Orbit Burst 길이. 원문은 「짧은」만 적어 Level Up 경량 연출(850ms)과 같게 둔다(해석 확인 항목). +const orbitBurstDuration = Duration(milliseconds: 850); + +class _OrbitBurstPainter extends CustomPainter { + const _OrbitBurstPainter(this.progress, this.color, this.lowEffects); + final double progress; + final Color color; + final bool lowEffects; + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0 || progress >= 1) return; + paintOrbitBurst(canvas, size, progress, color, lowEffects); + } + + @override + bool shouldRepaint(_OrbitBurstPainter old) => + old.progress != progress || + old.color != color || + old.lowEffects != lowEffects; +} diff --git a/app/lib/core/ui/planet_evolution.dart b/app/lib/core/ui/planet_evolution.dart index c373764..44c3acc 100644 --- a/app/lib/core/ui/planet_evolution.dart +++ b/app/lib/core/ui/planet_evolution.dart @@ -1,11 +1,11 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import '../theme/tokens.dart'; import 'planet_fx_scope.dart'; import '../i18n/app_locale.dart'; +import 'fx_intensity.dart'; /// v1.5 §2.1/6. 진화·HERO 발동은 하나의 타임라인을 쓰며 첫 조회는 기준점이다. class PlanetEvolution extends StatefulWidget { @@ -206,9 +206,9 @@ class _PlanetEvolutionState extends State _fx.forward(from: 0); if (ending) return; if (evolution || hero) { - HapticFeedback.heavyImpact(); + fxHaptic(FxIntensity.legendary); // Evolution · HERO GIFT } else { - HapticFeedback.mediumImpact(); + fxHaptic(FxIntensity.medium); // Level Up } } @@ -469,35 +469,7 @@ class _HeroPainter extends CustomPainter { ).createShader(Rect.fromCircle(center: point, radius: radius * 2)), ); } else { - final burst = (p - 0.5) * 2; - final paint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 2 * (1 - burst) + 0.5 - ..color = color.withValues(alpha: (1 - burst) * 0.65); - canvas.save(); - canvas.translate(c.dx, c.dy); - canvas.rotate(-0.32); - for (var i = 0; i < 2; i++) { - canvas.drawOval( - Rect.fromCenter( - center: Offset.zero, - width: r * (0.8 + burst * 2.2 - i * 0.15), - height: r * (0.3 + burst - i * 0.05), - ), - paint, - ); - } - canvas.restore(); - final count = lowEffects ? 8 : 24; - for (var i = 0; i < count; i++) { - final a = i * 2.399; - final reach = r * (0.3 + burst * (0.8 + (i % 3) * 0.12)); - canvas.drawCircle( - c + Offset(math.cos(a), math.sin(a) * 0.7) * reach, - 1 + i % 3 * 0.4, - Paint()..color = color.withValues(alpha: (1 - burst) * 0.8), - ); - } + paintOrbitBurst(canvas, size, (p - 0.5) * 2, color, lowEffects); } } @@ -507,3 +479,44 @@ class _HeroPainter extends CustomPainter { old.lowEffects != lowEffects || old.ending != ending; } + +/// Orbit Burst — 기울어진 궤도 고리가 바깥으로 퍼지고 입자가 흩어진다. [burst] 0 → 1. +/// HERO 발동의 Ring Wave 와 이클립스 성공(v1.5 §5 「짧은 Orbit Burst」)이 같이 쓴다. +void paintOrbitBurst( + Canvas canvas, + Size size, + double burst, + Color color, + bool lowEffects, +) { + final c = size.center(Offset.zero); + final r = size.shortestSide * 0.46; + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 * (1 - burst) + 0.5 + ..color = color.withValues(alpha: (1 - burst) * 0.65); + canvas.save(); + canvas.translate(c.dx, c.dy); + canvas.rotate(-0.32); + for (var i = 0; i < 2; i++) { + canvas.drawOval( + Rect.fromCenter( + center: Offset.zero, + width: r * (0.8 + burst * 2.2 - i * 0.15), + height: r * (0.3 + burst - i * 0.05), + ), + paint, + ); + } + canvas.restore(); + final count = lowEffects ? 8 : 24; + for (var i = 0; i < count; i++) { + final a = i * 2.399; + final reach = r * (0.3 + burst * (0.8 + (i % 3) * 0.12)); + canvas.drawCircle( + c + Offset(math.cos(a), math.sin(a) * 0.7) * reach, + 1 + i % 3 * 0.4, + Paint()..color = color.withValues(alpha: (1 - burst) * 0.8), + ); + } +} diff --git a/app/lib/features/campaign/campaign_detail_screen.dart b/app/lib/features/campaign/campaign_detail_screen.dart index d1597f2..fa0ed8a 100644 --- a/app/lib/features/campaign/campaign_detail_screen.dart +++ b/app/lib/features/campaign/campaign_detail_screen.dart @@ -22,6 +22,7 @@ import 'widgets/campaign_list_card.dart' show CampaignAuthor; import 'widgets/campaign_rank.dart'; import 'widgets/campaign_share_dialog.dart'; import 'widgets/campaign_style.dart'; +import '../../core/ui/fx_intensity.dart'; /// 캠페인 상세 — 화면 대조 원장 F5 · Figma `272:245738`. /// @@ -998,6 +999,7 @@ class _CampaignDetailScreenState extends State { return; } if (!mounted) return; + final before = _raised; setState(() { _spent += applied; _raised += applied; @@ -1010,6 +1012,8 @@ class _CampaignDetailScreenState extends State { _detail = fresh; _raised = fresh.card.raisedPop; }); + // v1.5 §1 Legendary 「Campaign 100%」 — 서버가 다시 준 모금액으로 이번 후원이 목표를 넘긴 때만 1회. + if (campaignJustCompleted(before, fresh.card.raisedPop, fresh.card.goalPop)) fxHaptic(FxIntensity.legendary); } } on Object catch (_) { // 다시 읽기 실패는 후원 결과와 무관하다 — 앞 화면을 그대로 둔다. @@ -1301,3 +1305,7 @@ class _Round extends StatelessWidget { ), ); } + +/// 이번 후원 전 모금액 [before] 가 목표 [goal] 미만이었고 서버 확정 모금액 [raised] 가 목표에 닿았는가. +bool campaignJustCompleted(int before, int raised, int goal) => + goal > 0 && before < goal && raised >= goal; diff --git a/app/lib/features/eclipse/eclipse_defend_screen.dart b/app/lib/features/eclipse/eclipse_defend_screen.dart index 4c7e63b..fe08114 100644 --- a/app/lib/features/eclipse/eclipse_defend_screen.dart +++ b/app/lib/features/eclipse/eclipse_defend_screen.dart @@ -15,6 +15,7 @@ import '../quest/quest_evidence.dart' show ShakeProbe, deviceShakes; import 'eclipse_data.dart'; import '../../core/i18n/app_locale.dart'; import '../../core/ui/sheet_route.dart'; +import '../../core/ui/fx_intensity.dart'; /// 「흔들어 방어」 — 팬덤 공동 Shake 참여 화면. /// @@ -67,6 +68,9 @@ class _EclipseDefendScreenState extends State /// 서버가 받아들인 내 누적 참여량. int _mine = 0; + /// 받아들인 흔들기 일련번호 — 바뀔 때마다 Shield Pulse 한 번(v1.5 §5 「유효 Shake 마다 Shield Pulse / 원형 파동」). + int _pulse = 0; + /// 화면에 보이는 내 횟수 — 서버 확정값에 아직 보내지 않았거나 보내는 중인 흔들기를 더한다. /// 흔드는 동안 서버 왕복(약 0.7초 묶음)을 기다리면 숫자가 늦게 올라 「얼마나 잘 흔들고 /// 있는지」 알 수 없었다. 확정값이 오면 그것으로 맞춰진다. @@ -127,10 +131,13 @@ class _EclipseDefendScreenState extends State return; final qualify = _current?.qualifyShakes ?? 0; final before = _shown; - setState(() => _pending += 1); + setState(() { + _pending += 1; + _pulse += 1; + }); // 화면을 보지 않고 흔들어도 알 수 있게 — 한 번 셀 때마다 짧게, 인정 기준을 넘는 순간 길게. if (qualify > 0 && before < qualify && _shown >= qualify) { - HapticFeedback.heavyImpact(); + fxHaptic(FxIntensity.large); } else { HapticFeedback.selectionClick(); } @@ -444,6 +451,7 @@ class _EclipseDefendScreenState extends State child: _DefendPlanet( remaining: remaining, pending: _pending, + pulse: _pulse, image: _imageOf(widget.nebula), ), ), @@ -704,8 +712,12 @@ class _DefendPlanet extends StatelessWidget { required this.remaining, required this.pending, required this.image, + this.pulse = 0, }); + /// 흔들기 일련번호. 0 이면 아직 파동이 없다. + final int pulse; + final AssetImage image; /// 남은 시간 비율 1 → 0. @@ -755,6 +767,21 @@ class _DefendPlanet extends StatelessWidget { CustomPaint(painter: _RemainingRing(remaining)), ), _at(67.5, 300, 240, 240, Image(image: image)), + // Shield Pulse — 흔들기마다 행성 가장자리에서 원형 파동이 한 번 퍼진다. 동작 줄이기면 그리지 않는다. + if (pulse > 0 && !MediaQuery.disableAnimationsOf(context)) + _at( + 48, + 280, + 280, + 280, + TweenAnimationBuilder( + key: ValueKey('shield-pulse-$pulse'), + tween: Tween(begin: 0, end: 1), + duration: shieldPulseDuration, + builder: (context, t, _) => + CustomPaint(painter: ShieldPulsePainter(t)), + ), + ), // 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다. _meteor( 38, @@ -923,3 +950,31 @@ class _OutcomeDisc extends CustomPainter { bool shouldRepaint(_OutcomeDisc old) => old.color != color || old.check != check; } + + +/// Shield Pulse 한 번의 길이. 원문에 수치가 없어 ZAP 에너지 이동(380ms)과 같은 짧은 길이를 쓴다(해석 확인 항목). +const shieldPulseDuration = Duration(milliseconds: 380); + +/// 행성 가장자리(반경 240/280)에서 바깥으로 퍼지며 사라지는 원형 파동. [t] 0 → 1. +class ShieldPulsePainter extends CustomPainter { + const ShieldPulsePainter(this.t); + + final double t; + + @override + void paint(Canvas canvas, Size size) { + if (t >= 1) return; + final r = size.shortestSide / 2; + canvas.drawCircle( + size.center(Offset.zero), + r * (240 / 280 + t * (1 - 240 / 280 + 0.1)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 * (1 - t) + 1 + ..color = AppColors.neonEmerald.withValues(alpha: 0.8 * (1 - t)), + ); + } + + @override + bool shouldRepaint(ShieldPulsePainter old) => old.t != t; +} diff --git a/app/lib/features/eclipse/widgets/live_proof.dart b/app/lib/features/eclipse/widgets/live_proof.dart index 5de8a47..8bddbeb 100644 --- a/app/lib/features/eclipse/widgets/live_proof.dart +++ b/app/lib/features/eclipse/widgets/live_proof.dart @@ -14,13 +14,57 @@ import '../../../core/i18n/app_locale.dart'; /// 별첨 B 「Global Ticker」로 Eclipse 외의 종류가 뒤에 들어오므로 종류마다 한 줄씩 /// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다. String liveEventMessage(LiveEvent event) => switch (event.kind) { - LiveEventKind.eclipseDefense => - appL10n.liveEclipseDefense(event.nickname), - LiveEventKind.artistHotTime => - appL10n.liveHotTime(event.nickname), - LiveEventKind.heroGift => - appL10n.liveHeroGift(event.nickname), - LiveEventKind.campaign => - appL10n.liveCampaign(event.nickname), + LiveEventKind.eclipseDefense => appL10n.liveEclipseDefense(event.nickname), + LiveEventKind.artistHotTime => appL10n.liveHotTime(event.nickname), + LiveEventKind.heroGift => appL10n.liveHeroGift(event.nickname), + LiveEventKind.campaign => appL10n.liveCampaign(event.nickname), LiveEventKind.notice => '[LIVE] ${event.nickname}', }; + +/// 홈 라이브 띠(전광판)에 올릴 한 줄. [hero] 면 띠가 HERO TIME 참여 진입이 된다. +typedef TickerLine = ({String text, bool hero}); + +/// 띠 한 줄 고르기 — 원장 C-78 순서(위기 → 성취)대로 핵심 이벤트가 먼저 오른다. +/// +/// 1. 이클립스 발생 소식 — 그 뒤 이벤트(방어 참여 등)가 아직 없을 때. +/// 2. 방어 중 집약 — UX·FX v1.5 §1.1 「개인 이벤트를 연속 티커로 쌓기보다 집단 상태로 집약」 · +/// §5.1 「참여자 수 변화 중심」 · 기능명세 §2.10~§2.12 「지금 N 명이 함께 방어 중」. N 은 서버가 +/// 인정한 고유 참여자 수([EclipseState.defense])이며 2명 이상일 때 집약한다(「다수」 해석 — 해석 확인 항목). +/// 3. HERO TIME — v1.5 §6 전광판 「공개 닉네임 + 대상 팬덤 + 진행 중 CTA」. +/// 4. 최신 이벤트 한 건, 없으면 [fallback]. +TickerLine? tickerLine({ + required EclipseState? eclipse, + required bool heroTime, + required List heroNicknames, + required List events, + required String fandomName, + required DateTime now, + String? fallback, +}) { + final event = events.isEmpty ? null : events.first; + if (eclipse != null && eclipse.result == null) { + final started = eclipse.startAt; + if (event == null || started == null || event.at.isBefore(started)) { + return ( + text: appL10n.eclipseLiveOccurred(eclipse.fandomName), + hero: false, + ); + } + if (eclipse.canShake(now) && eclipse.defense >= 2) { + return ( + text: appL10n.liveEclipseDefending('${eclipse.defense}'), + hero: false, + ); + } + } + if (heroTime) { + return ( + text: heroNicknames.isEmpty + ? appL10n.liveHeroTime(fandomName) + : appL10n.liveHeroTimeBy(fandomName, heroNicknames.join(' · ')), + hero: true, + ); + } + if (event != null) return (text: liveEventMessage(event), hero: false); + return fallback == null ? null : (text: fallback, hero: false); +} diff --git a/app/lib/features/galaxy/zap_controller.dart b/app/lib/features/galaxy/zap_controller.dart index e702a34..bd25e5d 100644 --- a/app/lib/features/galaxy/zap_controller.dart +++ b/app/lib/features/galaxy/zap_controller.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import '../../core/data/backend.dart'; import 'galaxy_data.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/fx_intensity.dart'; /// HOME과 Galaxy의 동일한 기여 거래: 연타 합산, 확정 결과, 동일 키 재확인. class ZapController extends ChangeNotifier { @@ -22,7 +22,7 @@ class ZapController extends ChangeNotifier { void tap(String fandomId) { if (_disposed || sending || _batch != null || fandomId.isEmpty) return; - if (pending == 0) HapticFeedback.lightImpact(); + if (pending == 0) fxHaptic(FxIntensity.small); pending++; touchSerial++; notifyListeners(); diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index f3e2af5..cd88905 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -247,11 +247,21 @@ class _LiveTickerSlot extends StatelessWidget { required this.eclipse, required this.fandomId, required this.fallback, + required this.fandomName, + required this.watchPlanet, + this.onHero, }); final EclipseRepository eclipse; final String fandomId; final String? fallback; + final String fandomName; + + /// HERO TIME 여부 · 공개 닉네임 — 행성 FX 와 같은 서버 확정 원천. + final PlanetWatch watchPlanet; + + /// HERO TIME 줄을 눌렀을 때 — 이 행성의 진행 중 HERO TIME 참여로 바로 간다(v1.5 §6 「진입」). + final VoidCallback? onHero; @override Widget build(BuildContext context) => LiveStreamBuilder( @@ -261,28 +271,36 @@ class _LiveTickerSlot extends StatelessWidget { create: () => fandomId.isEmpty ? Stream.value(null) : eclipse.watch(fandomId: fandomId), - builder: (context, eclipseState) => LiveStreamBuilder>( - key: ValueKey('live-$fandomId'), - // 띠는 한 줄만 그리므로 최신 몇 건만 받는다. + builder: (context, eclipseState) => LiveStreamBuilder( + key: ValueKey('live-planet-$fandomId'), create: () => fandomId.isEmpty - ? Stream.value(const []) - : eclipse.liveEvents(fandomId: fandomId, limit: 5), - builder: (context, events) { - final event = (events == null || events.isEmpty) ? null : events.first; - final started = eclipseState?.startAt; - // 발생 소식은 그 뒤에 온 이벤트(방어 참여 등)가 없을 때 띠에 오른다. - final eclipseNews = - eclipseState != null && - eclipseState.result == null && - (event == null || started == null || event.at.isBefore(started)); - final latest = eclipseNews - ? context.l10n.eclipseLiveOccurred(eclipseState.fandomName) - : event == null - ? fallback - : liveEventMessage(event); - if (latest == null) return const SizedBox.shrink(); - return LiveTicker(text: latest); - }, + ? const Stream.empty() + : watchPlanet(fandomId), + builder: (context, planet) => LiveStreamBuilder>( + key: ValueKey('live-$fandomId'), + // 띠는 한 줄만 그리므로 최신 몇 건만 받는다. + create: () => fandomId.isEmpty + ? Stream.value(const []) + : eclipse.liveEvents(fandomId: fandomId, limit: 5), + builder: (context, events) { + final line = tickerLine( + eclipse: eclipseState, + heroTime: planet?.isHeroTime == true, + heroNicknames: planet?.heroNicknames ?? const [], + events: events ?? const [], + fandomName: fandomName, + now: DateTime.now(), + fallback: fallback, + ); + if (line == null) return const SizedBox.shrink(); + final hero = line.hero && onHero != null; + return LiveTicker( + text: line.text, + action: hero ? context.l10n.heroParticipate : null, + onTap: hero ? onHero : null, + ); + }, + ), ), ); } @@ -532,6 +550,9 @@ class _HomeBody extends StatelessWidget { eclipse: eclipse, fandomId: data.fandomId, fallback: data.liveTicker, + fandomName: data.growth.fandomName, + watchPlanet: watchPlanet, + onHero: _heroParticipate(context), ), onClose: () => onMap(false), onSearch: () => _openSearch(context), @@ -547,6 +568,9 @@ class _HomeBody extends StatelessWidget { eclipse: eclipse, fandomId: data.fandomId, fallback: data.liveTicker, + fandomName: data.growth.fandomName, + watchPlanet: watchPlanet, + onHero: _heroParticipate(context), ), growth: growth ?? data.growth, fandomId: data.fandomId, diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart index ea90a15..ad4bf81 100644 --- a/app/lib/features/home/widgets/home_hero.dart +++ b/app/lib/features/home/widgets/home_hero.dart @@ -8,7 +8,6 @@ import '../../../core/ui/hero_time_status.dart'; import 'growth_conditions.dart'; import 'dart:math' as math; -import 'package:flutter/services.dart' show HapticFeedback; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; @@ -21,6 +20,7 @@ import '../../../core/data/planet_visual_state.dart'; import '../home_data.dart'; import '../home_screen.dart' show formatPop; import '../../../core/i18n/app_locale.dart'; +import '../../../core/ui/fx_intensity.dart'; /// 홈 상단 히어로 — Figma 사본 `252:159354` 홈의 `Frame 1984081117`. /// @@ -1073,7 +1073,7 @@ class _TapShakeState extends State<_TapShake> with SingleTickerProviderStateMixi Future _tap() async { final onTap = widget.onTap; if (onTap == null || _shake.isAnimating) return; - HapticFeedback.lightImpact(); + fxHaptic(FxIntensity.micro); if (!MediaQuery.disableAnimationsOf(context)) await _shake.forward(from: 0); if (mounted) onTap(); } diff --git a/app/lib/features/home/widgets/live_ticker.dart b/app/lib/features/home/widgets/live_ticker.dart index 4e4b029..5e18f9e 100644 --- a/app/lib/features/home/widgets/live_ticker.dart +++ b/app/lib/features/home/widgets/live_ticker.dart @@ -10,13 +10,59 @@ import '../../../core/theme/tokens.dart'; /// /// 명세 `516:87664` *동작: 이벤트 발생 시 텍스트 우측에서 좌측으로 1회 슬라이드* — 문구가 /// 바뀔 때마다 오른쪽 밖에서 한 번 미끄러져 들어와 멈춘다. +/// +/// [action] 이 있으면 문구 뒤에 행동 문구를 붙이고 띠 전체를 [onTap] 으로 누를 수 있다 — +/// UX·FX v1.5 §6 전광판 「HERO TIME 진행 중 CTA · 탭 시 해당 Planet 의 진행 중 HERO TIME 으로 이동」. class LiveTicker extends StatelessWidget { - const LiveTicker({super.key, required this.text}); + const LiveTicker({super.key, required this.text, this.action, this.onTap}); final String text; + final String? action; + final VoidCallback? onTap; + + static const _style = TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 14 / 12, + letterSpacing: -0.3, + color: AppColors.onCosmic, + ); @override - Widget build(BuildContext context) => ClipRect( + Widget build(BuildContext context) { + final line = Text.rich( + TextSpan( + text: text, + children: [ + if (action case final cta?) + TextSpan( + text: ' $cta', + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppColors.cosmicAccent, + ), + ), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _style, + ); + final ticker = _slide(line); + return onTap == null + ? ticker + : Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: ticker, + ), + ); + } + + Widget _slide(Widget line) => ClipRect( child: Container( height: 22, padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), @@ -33,19 +79,7 @@ class LiveTicker extends StatelessWidget { child: child, ), ), - child: Text( - text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: AppFonts.body, - fontSize: 12, - fontWeight: FontWeight.w500, - height: 14 / 12, - letterSpacing: -0.3, - color: AppColors.onCosmic, - ), - ), + child: line, ), ), ); diff --git a/app/lib/features/planet/hero_participation_sheet.dart b/app/lib/features/planet/hero_participation_sheet.dart index b40f2d2..9b3fef5 100644 --- a/app/lib/features/planet/hero_participation_sheet.dart +++ b/app/lib/features/planet/hero_participation_sheet.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:sensors_plus/sensors_plus.dart'; import '../../core/data/planet_visual_state.dart'; import '../../core/data/backend.dart'; @@ -8,6 +7,7 @@ import '../../core/ui/live_stream.dart'; import 'planet_data.dart'; import '../../core/i18n/app_locale.dart'; import '../quest/quest_evidence.dart' show ShakeDetector; +import '../../core/ui/fx_intensity.dart'; /// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다. class HeroParticipationSheet extends StatefulWidget { @@ -99,7 +99,7 @@ class _HeroParticipationSheetState extends State _received[id] = receipt; _pendingId = null; }); - if (_foreground) HapticFeedback.lightImpact(); + if (_foreground) fxHaptic(FxIntensity.small); } catch (error) { if (!mounted) return; final code = error is BackendException ? error.code : null; diff --git a/app/lib/features/quest/music_shake_play_screen.dart b/app/lib/features/quest/music_shake_play_screen.dart index 27a7694..9203729 100644 --- a/app/lib/features/quest/music_shake_play_screen.dart +++ b/app/lib/features/quest/music_shake_play_screen.dart @@ -4,7 +4,6 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; @@ -13,6 +12,7 @@ import '../../core/i18n/app_locale.dart'; import '../../core/theme/tokens.dart'; import 'quest_data.dart'; import 'quest_evidence.dart'; +import '../../core/ui/fx_intensity.dart'; /// 플레이어 상태 한 번 — 재생 중인가 · 영상 시각 · 끝났는가. typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended}); @@ -145,7 +145,7 @@ class _MusicShakePlayScreenState extends State { if (_done || !mounted) return; _done = true; // 완료 햅틱 Medium — UX·FX 정렬 기준 v1.5 「Music/Location Quest 완료」. - HapticFeedback.mediumImpact(); + fxHaptic(FxIntensity.medium); Navigator.of(context).pop(_judge); } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index e0104a1..1d0d3e0 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -900,5 +900,8 @@ "questExtraVerify": "Extra verification (with GPS)", "questScanQr": "Scan the on-site QR", "questScanQrDenied": "Can't open the camera. Allow camera access in Settings.", - "questQrForPlace": "Verification QR to post on site" + "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}" } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index e7feccd..d188bed 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -900,5 +900,8 @@ "questExtraVerify": "Verificación adicional (con GPS)", "questScanQr": "Escanea el QR del lugar", "questScanQrDenied": "No se puede abrir la cámara. Permite el acceso en Ajustes.", - "questQrForPlace": "QR de verificación para colocar en el lugar" + "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}" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 5ab9edb..188ca09 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -900,5 +900,8 @@ "questExtraVerify": "追加認証(GPSと一緒に)", "questScanQr": "現地のQRをスキャン", "questScanQrDenied": "カメラを開けません。設定でカメラへのアクセスを許可してください。", - "questQrForPlace": "現地に掲示する認証QR" + "questQrForPlace": "現地に掲示する認証QR", + "liveEclipseDefending": "[LIVE] いま{count}人のファンが一緒に防衛中", + "liveHeroTime": "[LIVE] {fandom}惑星 HERO TIME 進行中", + "liveHeroTimeBy": "[LIVE] {fandom}惑星 HERO TIME · by {name}" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index f9645de..69a1396 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1857,5 +1857,11 @@ "questExtraVerify": "추가 인증 (GPS 와 함께)", "questScanQr": "현장 QR 스캔", "questScanQrDenied": "카메라를 열 수 없어요. 설정에서 카메라 권한을 허용해 주세요.", - "questQrForPlace": "현장에 붙일 인증 QR" + "questQrForPlace": "현장에 붙일 인증 QR", + "liveEclipseDefending": "[LIVE] 지금 {count}명의 팬이 함께 방어 중", + "@liveEclipseDefending": { "placeholders": { "count": { "type": "String" } } }, + "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" } } } } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index ee2d3a3..b53b6d0 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -5521,6 +5521,24 @@ abstract class AppLocalizations { /// In ko, this message translates to: /// **'현장에 붙일 인증 QR'** String get questQrForPlace; + + /// No description provided for @liveEclipseDefending. + /// + /// In ko, this message translates to: + /// **'[LIVE] 지금 {count}명의 팬이 함께 방어 중'** + String liveEclipseDefending(String count); + + /// No description provided for @liveHeroTime. + /// + /// In ko, this message translates to: + /// **'[LIVE] {fandom} 행성 HERO TIME 진행 중'** + String liveHeroTime(String fandom); + + /// No description provided for @liveHeroTimeBy. + /// + /// In ko, this message translates to: + /// **'[LIVE] {fandom} 행성 HERO TIME · by {name}'** + String liveHeroTimeBy(String fandom, String name); } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 6aa6125..59e8f43 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -3135,4 +3135,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get questQrForPlace => 'Verification QR to post on site'; + + @override + String liveEclipseDefending(String count) { + return '[LIVE] $count fans defending together now'; + } + + @override + String liveHeroTime(String fandom) { + return '[LIVE] HERO TIME on planet $fandom'; + } + + @override + String liveHeroTimeBy(String fandom, String name) { + return '[LIVE] HERO TIME on planet $fandom · by $name'; + } } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index a295a2e..cb83426 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -3151,4 +3151,19 @@ class AppLocalizationsEs extends AppLocalizations { @override String get questQrForPlace => 'QR de verificación para colocar en el lugar'; + + @override + String liveEclipseDefending(String count) { + return '[LIVE] $count fans defendiendo juntos ahora'; + } + + @override + String liveHeroTime(String fandom) { + return '[LIVE] HERO TIME en el planeta $fandom'; + } + + @override + String liveHeroTimeBy(String fandom, String name) { + return '[LIVE] HERO TIME en el planeta $fandom · by $name'; + } } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index a727201..30e1260 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -3028,4 +3028,19 @@ class AppLocalizationsJa extends AppLocalizations { @override String get questQrForPlace => '現地に掲示する認証QR'; + + @override + String liveEclipseDefending(String count) { + return '[LIVE] いま$count人のファンが一緒に防衛中'; + } + + @override + String liveHeroTime(String fandom) { + return '[LIVE] $fandom惑星 HERO TIME 進行中'; + } + + @override + String liveHeroTimeBy(String fandom, String name) { + return '[LIVE] $fandom惑星 HERO TIME · by $name'; + } } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index db7595c..09ece4a 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -3032,4 +3032,19 @@ class AppLocalizationsKo extends AppLocalizations { @override String get questQrForPlace => '현장에 붙일 인증 QR'; + + @override + String liveEclipseDefending(String count) { + return '[LIVE] 지금 $count명의 팬이 함께 방어 중'; + } + + @override + String liveHeroTime(String fandom) { + return '[LIVE] $fandom 행성 HERO TIME 진행 중'; + } + + @override + String liveHeroTimeBy(String fandom, String name) { + return '[LIVE] $fandom 행성 HERO TIME · by $name'; + } } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 715f9cc..7554db2 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -3023,4 +3023,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get questQrForPlace => '张贴在现场的认证二维码'; + + @override + String liveEclipseDefending(String count) { + return '[LIVE] 现在有$count位粉丝一起防御中'; + } + + @override + String liveHeroTime(String fandom) { + return '[LIVE] $fandom行星 HERO TIME 进行中'; + } + + @override + String liveHeroTimeBy(String fandom, String name) { + return '[LIVE] $fandom行星 HERO TIME · by $name'; + } } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index c9779fd..cad4752 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -900,5 +900,8 @@ "questExtraVerify": "附加认证(与GPS一起)", "questScanQr": "扫描现场二维码", "questScanQrDenied": "无法打开相机。请在设置中允许相机权限。", - "questQrForPlace": "张贴在现场的认证二维码" + "questQrForPlace": "张贴在现场的认证二维码", + "liveEclipseDefending": "[LIVE] 现在有{count}位粉丝一起防御中", + "liveHeroTime": "[LIVE] {fandom}行星 HERO TIME 进行中", + "liveHeroTimeBy": "[LIVE] {fandom}行星 HERO TIME · by {name}" } diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 631f14d..e7444f8 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -1216,12 +1216,21 @@ class RemoteAppLocalizations extends AppLocalizations { @override String liveCampaign(String name) => _fill('liveCampaign', {'name': name}) ?? _base.liveCampaign(name); + @override + String liveEclipseDefending(String count) => _fill('liveEclipseDefending', {'count': count}) ?? _base.liveEclipseDefending(count); + @override String liveEclipseDefense(String name) => _fill('liveEclipseDefense', {'name': name}) ?? _base.liveEclipseDefense(name); @override String liveHeroGift(String name) => _fill('liveHeroGift', {'name': name}) ?? _base.liveHeroGift(name); + @override + String liveHeroTime(String fandom) => _fill('liveHeroTime', {'fandom': fandom}) ?? _base.liveHeroTime(fandom); + + @override + String liveHeroTimeBy(String fandom, String name) => _fill('liveHeroTimeBy', {'fandom': fandom, 'name': name}) ?? _base.liveHeroTimeBy(fandom, name); + @override String liveHotTime(String name) => _fill('liveHotTime', {'name': name}) ?? _base.liveHotTime(name); diff --git a/app/test/uxfx_round2_test.dart b/app/test/uxfx_round2_test.dart new file mode 100644 index 0000000..cb5e649 --- /dev/null +++ b/app/test/uxfx_round2_test.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:fancake/core/data/repositories.dart' show eclipseLook; +import 'package:fancake/core/push/push_messaging.dart'; +import 'package:fancake/core/theme/app_theme.dart'; +import 'package:fancake/core/ui/fx_intensity.dart'; +import 'package:fancake/core/ui/planet_25d.dart'; +import 'package:fancake/features/campaign/campaign_detail_screen.dart' + show campaignJustCompleted; +import 'package:fancake/features/eclipse/eclipse_data.dart'; +import 'package:fancake/features/eclipse/eclipse_defend_screen.dart'; +import 'package:fancake/features/eclipse/widgets/live_proof.dart'; +import 'package:fancake/features/home/widgets/live_ticker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'eclipse_stubs.dart'; + +/// UX·FX 정렬 기준 v1.5 2차(10/8) — 햅틱 5단계 · 티커 집약 · Push 위계 · Shield Pulse · +/// Orbit Burst · 실패 Glow 감소 · 전광판 HERO (진척현황 부록 B-3 1-1 · 1-3 · 1-4 · 5-3 · 5-6 · 5-7 · 6-2). +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + '강도 5단계 햅틱 — Micro·Small Light · Medium Medium · Large·Legendary Strong (v1.5 §1)', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') + calls.add(call.arguments); + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + for (final level in FxIntensity.values) { + await fxHaptic(level); + } + expect(calls, [ + 'HapticFeedbackType.lightImpact', + 'HapticFeedbackType.lightImpact', + 'HapticFeedbackType.mediumImpact', + 'HapticFeedbackType.heavyImpact', + 'HapticFeedbackType.heavyImpact', + ]); + }, + ); + + test('Campaign 100% Legendary 는 이번 후원으로 목표를 넘긴 때만', () { + expect(campaignJustCompleted(900, 1000, 1000), isTrue); + expect(campaignJustCompleted(900, 999, 1000), isFalse); + expect( + campaignJustCompleted(1000, 1200, 1000), + isFalse, + reason: '이미 달성한 캠페인', + ); + }); + + group('앱 사용 중 Push — C-78 우선순위 · 같은 알림 중복 금지 (v1.5 §1.1 · 9장)', () { + PushNotice n(String kind, [String title = 't']) => + PushNotice(title: title, body: 'b', kind: kind, fandomId: 'bts'); + + test('떠 있는 것이 없으면 띄운다', () { + expect(shouldShowInApp(n('briefing'), null), isTrue); + }); + test('같은 알림은 다시 띄우지 않는다', () { + expect(shouldShowInApp(n('eclipse'), n('eclipse')), isFalse); + }); + test('낮은 순위는 높은 순위를 덮지 않고, 높은 순위는 덮는다', () { + 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); + }); + test('위기 → 공동행동 → 성취 순', () { + expect(n('eclipse').tier, PushTier.crisis); + expect(n('timeattack').tier, PushTier.collective); + expect(n('evolution').tier, PushTier.achievement); + expect(n('briefing').tier, PushTier.other); + }); + }); + + group('라이브 띠 — 위기 우선 · 방어 집약 · HERO 전광판', () { + final start = DateTime.now().subtract(const Duration(seconds: 5)); + final join = LiveEvent(id: 'e', nickname: '아미', at: DateTime.now()); + TickerLine? line({ + EclipseState? eclipse, + bool hero = false, + List names = const [], + List events = const [], + }) => tickerLine( + eclipse: eclipse, + heroTime: hero, + heroNicknames: names, + events: events, + fandomName: 'BTS', + now: DateTime.now(), + fallback: 'fallback', + ); + + test('발생 직후는 발생 소식', () { + expect( + line(eclipse: eclipseFixture(startAt: start))!.text, + 'BTS 행성에 이클립스가 발생했어요!', + ); + }); + test('방어 참여가 오면 고유 참여자 2명 이상부터 집약한다', () { + expect( + line( + eclipse: eclipseFixture(startAt: start, defense: 3), + events: [join], + )!.text, + '[LIVE] 지금 3명의 팬이 함께 방어 중', + ); + expect( + line( + eclipse: eclipseFixture(startAt: start, defense: 1), + events: [join], + )!.text, + '[LIVE] 아미님이 이클립스 방어에 합류했습니다!', + ); + }); + test('HERO TIME 은 공개 닉네임 + 팬덤으로 오르고 참여 진입이 된다', () { + final hero = line(hero: true, names: ['아미'], events: [join])!; + expect(hero.text, '[LIVE] BTS 행성 HERO TIME · by 아미'); + expect(hero.hero, isTrue); + expect(line(hero: true)!.text, '[LIVE] BTS 행성 HERO TIME 진행 중'); + }); + test('위기(이클립스)가 성취(HERO)보다 먼저', () { + final l = line(eclipse: eclipseFixture(startAt: start), hero: true)!; + expect(l.hero, isFalse); + }); + test('아무것도 없으면 폴백', () { + expect(line()!.text, 'fallback'); + }); + }); + + testWidgets('HERO 전광판은 눌러서 참여로 간다', (tester) async { + var tapped = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LiveTicker(text: 'HERO', action: 'GO', onTap: () => tapped++), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(LiveTicker)); + expect(tapped, 1); + }); + + test('이클립스 회차 → 행성 표현 (진행 · 실패 · 성공 · 판정 대기)', () { + final now = DateTime.now(); + final past = now.subtract(const Duration(seconds: 1)); + expect(eclipseLook(null, now), (false, false, null)); + expect(eclipseLook(eclipseFixture(), now), (true, false, null)); + expect( + eclipseLook( + eclipseFixture(endsAt: past, result: EclipseResult.failure), + now, + ), + (false, true, null), + ); + expect( + eclipseLook( + eclipseFixture(endsAt: past, result: EclipseResult.success), + now, + ), + (false, false, 'ecl-1'), + ); + expect(eclipseLook(eclipseFixture(endsAt: past), now), ( + false, + false, + null, + ), reason: '판정 대기를 실패처럼 보이지 않는다'); + expect( + eclipseLook( + eclipseFixture(endsAt: past, result: EclipseResult.cancelled), + now, + ), + (false, false, null), + ); + }); + + group('행성 — 실패 감소 · 성공 Orbit Burst (v1.5 §5)', () { + Widget planet({bool failed = false, String? success}) => MaterialApp( + home: Center( + child: Planet25D( + image: 'assets/figma/planet-home.png', + size: 200, + stage: 3, + eclipseFailed: failed, + eclipseSuccessId: success, + ), + ), + ); + double light(WidgetTester tester) => tester + .widget(find.byKey(const ValueKey('planet-surface-light'))) + .opacity; + + testWidgets('실패 확정이면 밝기를 줄인다(파괴 표현 없음)', (tester) async { + await tester.pumpWidget(planet(failed: true)); + expect(light(tester), 0.5); + await tester.pumpWidget(planet()); + await tester.pumpAndSettle(); + expect(light(tester), 1); + }); + + testWidgets('성공 회차가 새로 오면 복원 뒤 Orbit Burst 한 번 · 첫 값은 기준점', (tester) async { + Planet25D.animationsEnabled = true; + addTearDown(() => Planet25D.animationsEnabled = false); + await tester.pumpWidget(planet(success: 'old')); + await tester.pump(); + expect(find.byKey(const ValueKey('eclipse-orbit-burst')), findsNothing); + await tester.pumpWidget(planet(success: 'ecl-2')); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byKey(const ValueKey('eclipse-orbit-burst')), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); + }); + + testWidgets('유효 흔들기마다 Shield Pulse (v1.5 §5 수동 방어)', (tester) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + final shakes = StreamController.broadcast(); + addTearDown(shakes.close); + final repo = FakeEclipseRepository( + state: eclipseFixture( + startAt: DateTime.now().subtract(const Duration(seconds: 1)), + endsAt: DateTime.now().add(const Duration(seconds: 59)), + ), + ); + addTearDown(repo.dispose); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: EclipseDefendScreen( + repository: repo, + fandomId: 'bts', + shakes: () => shakes.stream, + ), + ), + ); + for (var i = 0; i < 4; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } + expect(find.byKey(const ValueKey('shield-pulse-1')), findsNothing); + shakes.add(DateTime.now()); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.byKey(const ValueKey('shield-pulse-1')), findsOneWidget); + shakes.add(DateTime.now()); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.byKey(const ValueKey('shield-pulse-2')), findsOneWidget); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } + await tester.pumpWidget(const SizedBox()); + }); +}