UX·FX v1.5 — 햅틱 단계 · 라이브 띠 · HERO 전광판 · Push 우선순위 · Shield · 이클립스 성공·실패 연출
This commit is contained in:
+15
-5
@@ -228,10 +228,13 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
||||
void _showInApp(PushNotice notice) {
|
||||
// 타임어택 발동 · 퀘스트 완료 등 — HOME 카드가 바로 따라오게 다시 읽힌다.
|
||||
homeRefreshSignal.value++;
|
||||
if (!notice.hasText) return;
|
||||
_messengerKey.currentState
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
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+) 시간이 지나면 닫히게 한다.
|
||||
@@ -247,9 +250,16 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
||||
onPressed: () => _openFromPush(notice),
|
||||
),
|
||||
),
|
||||
);
|
||||
)
|
||||
.closed
|
||||
.then((_) {
|
||||
if (identical(_inAppShowing, notice)) _inAppShowing = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// 지금 떠 있는 앱 안 알림. 닫히면 비운다.
|
||||
PushNotice? _inAppShowing;
|
||||
|
||||
void _openFromPush(PushNotice notice) => _router.go(notice.route);
|
||||
|
||||
/// 알림함 항목 → 화면(명세 `518:88353`). INSIDE · 라운지 게시물 이동은 그 알림을 만드는
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -156,6 +156,8 @@ class Repositories {
|
||||
Stream<PlanetVisualState> _openPlanet(String fandomId) => Stream.multi((out) {
|
||||
PlanetVisualState? planet;
|
||||
var isEclipsed = false;
|
||||
var eclipseFailed = false;
|
||||
String? eclipseSuccessId;
|
||||
var isHeroTime = false;
|
||||
Set<String>? 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> fxHaptic(FxIntensity level) => switch (level) {
|
||||
FxIntensity.micro || FxIntensity.small => HapticFeedback.lightImpact(),
|
||||
FxIntensity.medium => HapticFeedback.mediumImpact(),
|
||||
FxIntensity.large || FxIntensity.legendary => HapticFeedback.heavyImpact(),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>? heroEventIds;
|
||||
@@ -180,7 +189,7 @@ class Planet25D extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _Planet25DState extends State<Planet25D>
|
||||
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<Planet25D>
|
||||
? 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<Planet25D>
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_fx = PlanetFxScope.of(context);
|
||||
_syncMotion();
|
||||
}
|
||||
|
||||
@@ -230,10 +243,32 @@ class _Planet25DState extends State<Planet25D>
|
||||
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,11 +287,40 @@ class _Planet25DState extends State<Planet25D>
|
||||
heroEnabled: !widget.isEclipsed && !widget.isHibernating,
|
||||
animate: widget.animate,
|
||||
lowEffects: _lowEffects,
|
||||
builder: (stage) => Opacity(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<PlanetEvolution>
|
||||
_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,7 +469,28 @@ class _HeroPainter extends CustomPainter {
|
||||
).createShader(Rect.fromCircle(center: point, radius: radius * 2)),
|
||||
);
|
||||
} else {
|
||||
final burst = (p - 0.5) * 2;
|
||||
paintOrbitBurst(canvas, size, (p - 0.5) * 2, color, lowEffects);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_HeroPainter old) =>
|
||||
old.progress != progress ||
|
||||
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
|
||||
@@ -498,12 +519,4 @@ class _HeroPainter extends CustomPainter {
|
||||
Paint()..color = color.withValues(alpha: (1 - burst) * 0.8),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_HeroPainter old) =>
|
||||
old.progress != progress ||
|
||||
old.lowEffects != lowEffects ||
|
||||
old.ending != ending;
|
||||
}
|
||||
|
||||
@@ -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<CampaignDetailScreen> {
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
final before = _raised;
|
||||
setState(() {
|
||||
_spent += applied;
|
||||
_raised += applied;
|
||||
@@ -1010,6 +1012,8 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
|
||||
_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;
|
||||
|
||||
@@ -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<EclipseDefendScreen>
|
||||
/// 서버가 받아들인 내 누적 참여량.
|
||||
int _mine = 0;
|
||||
|
||||
/// 받아들인 흔들기 일련번호 — 바뀔 때마다 Shield Pulse 한 번(v1.5 §5 「유효 Shake 마다 Shield Pulse / 원형 파동」).
|
||||
int _pulse = 0;
|
||||
|
||||
/// 화면에 보이는 내 횟수 — 서버 확정값에 아직 보내지 않았거나 보내는 중인 흔들기를 더한다.
|
||||
/// 흔드는 동안 서버 왕복(약 0.7초 묶음)을 기다리면 숫자가 늦게 올라 「얼마나 잘 흔들고
|
||||
/// 있는지」 알 수 없었다. 확정값이 오면 그것으로 맞춰진다.
|
||||
@@ -127,10 +131,13 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
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<EclipseDefendScreen>
|
||||
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<double>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<String> heroNicknames,
|
||||
required List<LiveEvent> 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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<EclipseState?>(
|
||||
@@ -261,29 +271,37 @@ class _LiveTickerSlot extends StatelessWidget {
|
||||
create: () => fandomId.isEmpty
|
||||
? Stream<EclipseState?>.value(null)
|
||||
: eclipse.watch(fandomId: fandomId),
|
||||
builder: (context, eclipseState) => LiveStreamBuilder<List<LiveEvent>>(
|
||||
builder: (context, eclipseState) => LiveStreamBuilder<PlanetVisualState>(
|
||||
key: ValueKey('live-planet-$fandomId'),
|
||||
create: () => fandomId.isEmpty
|
||||
? const Stream<PlanetVisualState>.empty()
|
||||
: watchPlanet(fandomId),
|
||||
builder: (context, planet) => LiveStreamBuilder<List<LiveEvent>>(
|
||||
key: ValueKey('live-$fandomId'),
|
||||
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
|
||||
create: () => fandomId.isEmpty
|
||||
? Stream.value(const <LiveEvent>[])
|
||||
: 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);
|
||||
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,
|
||||
|
||||
@@ -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<void> _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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<HeroParticipationSheet>
|
||||
_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;
|
||||
|
||||
@@ -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<MusicShakePlayScreen> {
|
||||
if (_done || !mounted) return;
|
||||
_done = true;
|
||||
// 완료 햅틱 Medium — UX·FX 정렬 기준 v1.5 「Music/Location Quest 완료」.
|
||||
HapticFeedback.mediumImpact();
|
||||
fxHaptic(FxIntensity.medium);
|
||||
Navigator.of(context).pop(_judge);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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" } } }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = <Object?>[];
|
||||
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<String> names = const [],
|
||||
List<LiveEvent> 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<Opacity>(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<DateTime>.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());
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user