이클립스 흐름 — 배너 진입 · 이벤트 버튼 · 오버레이 · 방어 진행 표시 · 흔들기 감지 통일
This commit is contained in:
@@ -131,7 +131,12 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const policy = await loadPolicy();
|
const policy = await loadPolicy();
|
||||||
const amount = Math.round(policy.stepsPop * GRADE_MULTIPLIER[grade]);
|
// 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면
|
||||||
|
// 옛 공통값으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`).
|
||||||
|
const base = quest.get('rewardPop');
|
||||||
|
const amount = Math.round(
|
||||||
|
(typeof base === 'number' && base >= 0 ? base : policy.stepsPop) * GRADE_MULTIPLIER[grade],
|
||||||
|
);
|
||||||
const result = await grantPop({
|
const result = await grantPop({
|
||||||
uid,
|
uid,
|
||||||
popType: 'activity',
|
popType: 'activity',
|
||||||
|
|||||||
@@ -507,6 +507,18 @@ describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Camp
|
|||||||
expect(await count()).toBe(2);
|
expect(await count()).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Music Shake 보상은 퀘스트의 rewardPop × 등급 배수다', async () => {
|
||||||
|
await db().doc('quests/music-reward').set({
|
||||||
|
type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 1, rewardPop: 30,
|
||||||
|
});
|
||||||
|
const reply = await call(fnQuestVerifyMusicShake, {
|
||||||
|
questId: 'music-reward', trackId: 't1', totalSegments: 10,
|
||||||
|
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 },
|
||||||
|
});
|
||||||
|
expect(reply.grade).toBe('S');
|
||||||
|
expect(reply.rewarded).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
|
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
|
||||||
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
|
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
|
||||||
const shake = () => call(fnQuestVerifyMusicShake, {
|
const shake = () => call(fnQuestVerifyMusicShake, {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class EclipseState {
|
|||||||
required this.defenseCount,
|
required this.defenseCount,
|
||||||
required this.defended,
|
required this.defended,
|
||||||
this.qualifyShakes = 60,
|
this.qualifyShakes = 60,
|
||||||
|
this.startAt,
|
||||||
|
this.scheduled = false,
|
||||||
this.endsAt,
|
this.endsAt,
|
||||||
this.result,
|
this.result,
|
||||||
this.mockNote,
|
this.mockNote,
|
||||||
@@ -60,7 +62,21 @@ class EclipseState {
|
|||||||
|
|
||||||
/// 서버 확정 판정. 단말 타이머 만료는 failure가 아니다.
|
/// 서버 확정 판정. 단말 타이머 만료는 failure가 아니다.
|
||||||
final EclipseResult? result;
|
final EclipseResult? result;
|
||||||
bool get canDefend => result == null && !defended && endsAt != null && !isExpired(DateTime.now());
|
bool get canDefend => !scheduled && canShake(DateTime.now());
|
||||||
|
|
||||||
|
/// 흔들기를 받을 때인가 — 발생 시각이 지났고 아직 열려 있다. 서버가 회차를 `active` 로
|
||||||
|
/// 바꾸기 전(스케줄러 지연 수 초)의 흔들기는 모아 두었다가 [canDefend] 가 되면 보낸다.
|
||||||
|
bool canShake(DateTime now) =>
|
||||||
|
result == null && !defended && endsAt != null && hasStarted(now) && !isExpired(now);
|
||||||
|
|
||||||
|
/// 발생 시각. 사전 알림(`warnAt`)부터 문서가 있으므로 그 전에는 남은 시간을 센다.
|
||||||
|
/// 없으면 이미 시작한 회차로 본다.
|
||||||
|
final DateTime? startAt;
|
||||||
|
|
||||||
|
/// 서버 회차가 아직 `scheduled`(경보 단계)인가. 방어 호출은 `active` 에서만 받는다.
|
||||||
|
final bool scheduled;
|
||||||
|
|
||||||
|
bool hasStarted(DateTime now) => startAt == null || !startAt!.isAfter(now);
|
||||||
|
|
||||||
/// 방어 마감. 없으면 남은 시간을 띄우지 않는다.
|
/// 방어 마감. 없으면 남은 시간을 띄우지 않는다.
|
||||||
final DateTime? endsAt;
|
final DateTime? endsAt;
|
||||||
@@ -172,7 +188,13 @@ enum LiveEventKind {
|
|||||||
abstract interface class EclipseRepository {
|
abstract interface class EclipseRepository {
|
||||||
/// 활성 이클립스. [fandomId] 를 주지 않으면 내 소속 팬덤을 본다.
|
/// 활성 이클립스. [fandomId] 를 주지 않으면 내 소속 팬덤을 본다.
|
||||||
/// 기본은 활성 회차만, includeResults는 방어 화면에서 마감 후 판정까지 받는다.
|
/// 기본은 활성 회차만, includeResults는 방어 화면에서 마감 후 판정까지 받는다.
|
||||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false});
|
/// includeScheduled는 사전 알림(`warnAt`)부터의 `scheduled` 회차도 받는다 — HOME 이클립스
|
||||||
|
/// 이벤트 버튼(`516:88125` 「발생 N분 전 노출」)과 이클립스 상세가 쓴다.
|
||||||
|
Stream<EclipseState?> watch({
|
||||||
|
String? fandomId,
|
||||||
|
bool includeResults = false,
|
||||||
|
bool includeScheduled = false,
|
||||||
|
});
|
||||||
|
|
||||||
/// 최근 티커 이벤트. 최신순.
|
/// 최근 티커 이벤트. 최신순.
|
||||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20});
|
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20});
|
||||||
@@ -214,8 +236,11 @@ class SampleEclipseRepository implements EclipseRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) =>
|
Stream<EclipseState?> watch({
|
||||||
Stream.value(_snapshot(fandomId: fandomId));
|
String? fandomId,
|
||||||
|
bool includeResults = false,
|
||||||
|
bool includeScheduled = false,
|
||||||
|
}) => Stream.value(_snapshot(fandomId: fandomId));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) {
|
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import 'dart:math' as math;
|
|||||||
import 'dart:ui' show ImageFilter;
|
import 'dart:ui' show ImageFilter;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sensors_plus/sensors_plus.dart';
|
|
||||||
|
|
||||||
import '../../core/data/backend.dart';
|
import '../../core/data/backend.dart';
|
||||||
import '../../core/theme/tokens.dart';
|
import '../../core/theme/tokens.dart';
|
||||||
import '../../core/ui/app_icons.dart';
|
import '../../core/ui/app_icons.dart';
|
||||||
import '../../core/ui/live_stream.dart';
|
import '../../core/ui/live_stream.dart';
|
||||||
import '../home/home_screen.dart' show formatPop;
|
import '../home/home_screen.dart' show formatPop;
|
||||||
|
import '../quest/quest_evidence.dart' show deviceShakes;
|
||||||
import 'eclipse_data.dart';
|
import 'eclipse_data.dart';
|
||||||
import '../../core/i18n/app_locale.dart';
|
import '../../core/i18n/app_locale.dart';
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
bool _foreground = true;
|
bool _foreground = true;
|
||||||
String? _roundId;
|
String? _roundId;
|
||||||
StreamSubscription<AccelerometerEvent>? _accel;
|
StreamSubscription<DateTime>? _accel;
|
||||||
|
|
||||||
/// 아직 서버에 보내지 않은 흔들기 수.
|
/// 아직 서버에 보내지 않은 흔들기 수.
|
||||||
int _pending = 0;
|
int _pending = 0;
|
||||||
@@ -63,23 +63,16 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
bool _sending = false;
|
bool _sending = false;
|
||||||
({String fandomId, String eclipseId, String seq, int shakes})? _batch;
|
({String fandomId, String eclipseId, String seq, int shakes})? _batch;
|
||||||
|
|
||||||
/// 흔들기로 볼 가속도 크기(m/s²). 중력(9.8)을 넘는 순간만 센다.
|
|
||||||
static const _shakeThreshold = 18.0;
|
|
||||||
|
|
||||||
/// 한 번의 흔들기로 세는 최소 간격. 이보다 촘촘한 값은 같은 동작의 진동이다.
|
|
||||||
static const _shakeDebounce = Duration(milliseconds: 220);
|
|
||||||
|
|
||||||
/// 묶어 보내는 시간창. 흔들 때마다 부르면 호출이 초당 여러 번 나간다.
|
/// 묶어 보내는 시간창. 흔들 때마다 부르면 호출이 초당 여러 번 나간다.
|
||||||
static const _burstWindow = Duration(milliseconds: 700);
|
static const _burstWindow = Duration(milliseconds: 700);
|
||||||
|
|
||||||
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_accel = accelerometerEventStream().listen(
|
// 흔들기 판정 규칙(문턱 · 표본 주기)은 Music Shake 와 같다 — [ShakeDetector].
|
||||||
_onAccel,
|
_accel = deviceShakes().listen(
|
||||||
|
(_) => _shake(),
|
||||||
onError: (_) {
|
onError: (_) {
|
||||||
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 화면은 그대로 두고
|
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 화면은 그대로 두고
|
||||||
// 아래의 「직접 누르기」로 참여할 수 있게 한다.
|
// 아래의 「직접 누르기」로 참여할 수 있게 한다.
|
||||||
@@ -106,21 +99,10 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAccel(AccelerometerEvent event) {
|
|
||||||
final magnitude = math.sqrt(
|
|
||||||
event.x * event.x + event.y * event.y + event.z * event.z,
|
|
||||||
);
|
|
||||||
if (!magnitude.isFinite || magnitude < _shakeThreshold) return;
|
|
||||||
final now = DateTime.now();
|
|
||||||
if (now.difference(_lastShake) < _shakeDebounce) return;
|
|
||||||
_lastShake = now;
|
|
||||||
_shake();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _shake() {
|
void _shake() {
|
||||||
if (!_foreground ||
|
if (!_foreground ||
|
||||||
ModalRoute.of(context)?.isCurrent == false ||
|
ModalRoute.of(context)?.isCurrent == false ||
|
||||||
_current?.canDefend != true ||
|
_current?.canShake(DateTime.now()) != true ||
|
||||||
(_batch != null && !_sending))
|
(_batch != null && !_sending))
|
||||||
return;
|
return;
|
||||||
setState(() => _pending += 1);
|
setState(() => _pending += 1);
|
||||||
@@ -221,6 +203,8 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
create: () => widget.repository.watch(
|
create: () => widget.repository.watch(
|
||||||
fandomId: widget.fandomId,
|
fandomId: widget.fandomId,
|
||||||
includeResults: true,
|
includeResults: true,
|
||||||
|
// 경보 단계부터 들어온다 — HOME 이클립스 이벤트 버튼(`516:88125`) → 이클립스 상세.
|
||||||
|
includeScheduled: true,
|
||||||
),
|
),
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
_current = state;
|
_current = state;
|
||||||
@@ -233,11 +217,16 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
_burst?.cancel();
|
_burst?.cancel();
|
||||||
_burst = null;
|
_burst = null;
|
||||||
}
|
}
|
||||||
if (state?.canDefend != true) {
|
if (state == null ||
|
||||||
|
(!state.canShake(DateTime.now()) && state.hasStarted(DateTime.now()))) {
|
||||||
_pending = 0;
|
_pending = 0;
|
||||||
_burst?.cancel();
|
_burst?.cancel();
|
||||||
_burst = null;
|
_burst = null;
|
||||||
}
|
}
|
||||||
|
// 발생 직후 서버가 회차를 `active` 로 바꾸기 전에 모인 흔들기를 이제 보낸다.
|
||||||
|
if (state != null && state.canDefend && _pending > 0 && _batch == null) {
|
||||||
|
_scheduleFlush();
|
||||||
|
}
|
||||||
if (state == null) {
|
if (state == null) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -246,162 +235,257 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final result = state.result;
|
// 단계는 시각으로 갈린다 — 상세(발생 전) → 오버레이(발생 직전) → 방어 → 결과.
|
||||||
if (state.defended || result == EclipseResult.success) {
|
return EclipseTicker(
|
||||||
// 사본 `홈/이클립스 성공`(`373:537406`).
|
builder: (context, now) => _phase(context, state, now),
|
||||||
return _Outcome(
|
|
||||||
title: context.l10n.eclipseSuccessTitle,
|
|
||||||
success: true,
|
|
||||||
extra: [
|
|
||||||
if (_mine >= state.qualifyShakes)
|
|
||||||
_Note(context.l10n.eclipseQualified),
|
|
||||||
if (_batch != null) _retryButton(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (result == EclipseResult.failure ||
|
|
||||||
result == EclipseResult.cancelled ||
|
|
||||||
state.isExpired(DateTime.now())) {
|
|
||||||
// 실패는 사본 변형 `494:14292`. 취소·판정 대기는 사본에 없어 같은 틀에 정책 문구를 얹는다.
|
|
||||||
final (title, message) = switch (result) {
|
|
||||||
EclipseResult.failure => (
|
|
||||||
context.l10n.eclipseFailTitle,
|
|
||||||
context.l10n.eclipseFailBody,
|
|
||||||
),
|
|
||||||
EclipseResult.cancelled => (
|
|
||||||
context.l10n.eclipseCancelTitle,
|
|
||||||
context.l10n.eclipseCancelBody,
|
|
||||||
),
|
|
||||||
_ => (
|
|
||||||
context.l10n.eclipsePendingTitle,
|
|
||||||
context.l10n.eclipsePendingBody,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
return _Outcome(
|
|
||||||
title: title,
|
|
||||||
success: false,
|
|
||||||
// 판정 대기 중에는 닫을 결과가 아직 없다.
|
|
||||||
confirm: result != null,
|
|
||||||
extra: [
|
|
||||||
_Note(message),
|
|
||||||
if (_mine >= state.qualifyShakes)
|
|
||||||
_Note(context.l10n.eclipseQualified),
|
|
||||||
if (_batch != null) _retryButton(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 사본 `홈/이클립스 방어`(흔들기 전) → `홈/이클립스 상세`(흔드는 중).
|
|
||||||
final started = _mine > 0 || _pending > 0 || _batch != null;
|
|
||||||
return _Countdown(
|
|
||||||
endsAt: state.endsAt,
|
|
||||||
builder: (context, seconds) => Column(
|
|
||||||
children: [
|
|
||||||
const _CloseBar(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
SizedBox(
|
|
||||||
height: 68,
|
|
||||||
child: Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
children: [
|
|
||||||
if (!started && seconds != null)
|
|
||||||
TextSpan(
|
|
||||||
text: '$seconds',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'Pretendard',
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: !started && seconds != null
|
|
||||||
? context.l10n.eclipseShakeTimed
|
|
||||||
: context.l10n.eclipseShake,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: _titleStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
// 짧은 화면에서는 아래 안내·버튼이 밀려나지 않게 행성 자리가 먼저 줄어든다.
|
|
||||||
Flexible(
|
|
||||||
child: SizedBox(
|
|
||||||
height: 400,
|
|
||||||
child: _DefendPlanet(shaking: started, pending: _pending),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
_Note(
|
|
||||||
// 소모가 없다는 것을 화면이 직접 말한다 — 「POP 이 빠지나?」를
|
|
||||||
// 묻게 두지 않는다(설계서 v1.1 §9-#5).
|
|
||||||
'${context.l10n.eclipseMyCount(formatPop(_mine))}'
|
|
||||||
'${_pending > 0
|
|
||||||
? context.l10n.eclipsePendingSuffix('$_pending')
|
|
||||||
: _sending
|
|
||||||
? context.l10n.eclipseSendingSuffix
|
|
||||||
: ''}'
|
|
||||||
'${_mine >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}\n'
|
|
||||||
'${context.l10n.eclipseNoPopNote('${state.qualifyShakes}')}',
|
|
||||||
),
|
|
||||||
if (_batch != null) _retryButton(),
|
|
||||||
// 센서가 없는 단말에서도 참여할 수 있게 둔다. 판정은 서버가 하므로
|
|
||||||
// 이 버튼이 흔들기보다 유리하지 않다(상한·속도 제한이 같다). 사본에 없는
|
|
||||||
// 보조 입력이라 채움 없는 낮은 강조로 둔다.
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
AppSpace.s4,
|
|
||||||
AppSpace.s2,
|
|
||||||
AppSpace.s4,
|
|
||||||
AppSpace.s4,
|
|
||||||
),
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: state.canDefend && (_batch == null || _sending)
|
|
||||||
? _shake
|
|
||||||
: null,
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.cosmicSurfaceContainer,
|
|
||||||
foregroundColor: AppColors.onCosmicVariant,
|
|
||||||
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
|
|
||||||
),
|
|
||||||
child: Text(context.l10n.eclipseTapToJoin),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 흔들기 전(`373:445267`)은 남은 시간을 제목의 「N초간」으로 보여 준다.
|
|
||||||
// 흔드는 중(`373:444865`)은 맨 아래 `Frame 1984081868`(72 높이) 가운데.
|
|
||||||
if (started && seconds != null)
|
|
||||||
SizedBox(
|
|
||||||
height: 72,
|
|
||||||
child: Center(
|
|
||||||
child: Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
text: context.l10n.eclipseTimeLeft,
|
|
||||||
children: [
|
|
||||||
TextSpan(
|
|
||||||
text: context.l10n.eclipseSeconds('$seconds'),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.onCosmic,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: AppFonts.body,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
height: 1.5,
|
|
||||||
color: AppColors.onCosmicMuted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 발생 직전 오버레이 길이. 사본 `373:444976` 의 카운트다운이 「3」이고, 상세
|
||||||
|
/// `373:444865` 는 「발생 남은 시간 : 10초」에도 아직 상세다 — 그래서 10초가 아니라 3초다.
|
||||||
|
static const overlayLead = Duration(seconds: 3);
|
||||||
|
|
||||||
|
Widget _phase(BuildContext context, EclipseState state, DateTime now) {
|
||||||
|
final result = state.result;
|
||||||
|
if (state.defended || result == EclipseResult.success) {
|
||||||
|
// 사본 `홈/이클립스 성공`(`373:537406`).
|
||||||
|
return _Outcome(
|
||||||
|
title: context.l10n.eclipseSuccessTitle,
|
||||||
|
success: true,
|
||||||
|
extra: [
|
||||||
|
if (_mine >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
|
||||||
|
if (_batch != null) _retryButton(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result == EclipseResult.failure ||
|
||||||
|
result == EclipseResult.cancelled ||
|
||||||
|
state.isExpired(now)) {
|
||||||
|
// 실패는 사본 변형 `494:14292`. 취소·판정 대기는 사본에 없어 같은 틀에 정책 문구를 얹는다.
|
||||||
|
final (title, message) = switch (result) {
|
||||||
|
EclipseResult.failure => (
|
||||||
|
context.l10n.eclipseFailTitle,
|
||||||
|
context.l10n.eclipseFailBody,
|
||||||
|
),
|
||||||
|
EclipseResult.cancelled => (
|
||||||
|
context.l10n.eclipseCancelTitle,
|
||||||
|
context.l10n.eclipseCancelBody,
|
||||||
|
),
|
||||||
|
_ => (context.l10n.eclipsePendingTitle, context.l10n.eclipsePendingBody),
|
||||||
|
};
|
||||||
|
return _Outcome(
|
||||||
|
title: title,
|
||||||
|
success: false,
|
||||||
|
// 판정 대기 중에는 닫을 결과가 아직 없다.
|
||||||
|
confirm: result != null,
|
||||||
|
extra: [
|
||||||
|
_Note(message),
|
||||||
|
if (_mine >= state.qualifyShakes) _Note(context.l10n.eclipseQualified),
|
||||||
|
if (_batch != null) _retryButton(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!state.hasStarted(now)) {
|
||||||
|
final left = state.startAt!.difference(now);
|
||||||
|
// 사본 `홈/이클립스 상세`(`373:444865`) — 침식되는 행성 · 맨 아래 「이클립스 발생 남은 시간」.
|
||||||
|
final detail = Column(
|
||||||
|
children: [
|
||||||
|
const _CloseBar(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
height: 68,
|
||||||
|
child: Text(
|
||||||
|
context.l10n.eclipseShake,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: _titleStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Flexible(
|
||||||
|
child: SizedBox(height: 400, child: _ErodingPlanet()),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// `516:88184` 남은 시간 노출 영역 — 하단 정렬, 끝나면 방어 화면으로.
|
||||||
|
SizedBox(
|
||||||
|
height: 72,
|
||||||
|
child: Center(
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
text: context.l10n.eclipseTimeLeft,
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: ' ${eclipseLeftLabel(left)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: AppFonts.body,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppColors.onCosmicMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (left > overlayLead) return detail;
|
||||||
|
// 사본 `373:444976` — 검정 막 · 위 「행성 운석 충돌이 시작됩니다!」 · 가운데 카운트다운.
|
||||||
|
// `516:88197` 「0 이 되면 오버레이 종료 후 방어 이벤트 시작」.
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
detail,
|
||||||
|
const Positioned.fill(child: ColoredBox(color: Color(0xB3000000))),
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 56 + 24),
|
||||||
|
SizedBox(
|
||||||
|
height: 68,
|
||||||
|
child: Text(
|
||||||
|
context.l10n.eclipseOverlayTitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: _titleStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'${_ceilSeconds(left)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: AppFonts.display,
|
||||||
|
fontSize: 62,
|
||||||
|
height: 1.4,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사본 `홈/이클립스 방어`(`373:445267`) — 「60초간 휴대폰을 흔들어…」의 숫자가 실시간
|
||||||
|
// 잔여 시간이다(`516:88225` 60초 타이머). 행성 둘레 진행 막대는 잔여 시간 비율
|
||||||
|
// (`516:88231`), 운석은 시간이 흐를수록 행성으로 다가온다.
|
||||||
|
final left = state.endsAt!.difference(now);
|
||||||
|
final window = state.startAt == null
|
||||||
|
? const Duration(seconds: 60)
|
||||||
|
: state.endsAt!.difference(state.startAt!);
|
||||||
|
final remaining = window.inMilliseconds <= 0
|
||||||
|
? 0.0
|
||||||
|
: (left.inMilliseconds / window.inMilliseconds).clamp(0.0, 1.0);
|
||||||
|
final canShake = state.canShake(now);
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const _CloseBar(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
height: 68,
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '${_ceilSeconds(left)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(text: context.l10n.eclipseShakeTimed),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: _titleStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// 짧은 화면에서는 아래 진행 표시·버튼이 밀려나지 않게 행성 자리가 먼저 줄어든다.
|
||||||
|
Flexible(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 400,
|
||||||
|
child: _DefendPlanet(remaining: remaining, pending: _pending),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// 진행 표시 — 내 유효 Shake / 인정 횟수(C-25) · 팬덤 고유 참여자 / 목표(C-26).
|
||||||
|
// 사본에 없는 정책 정보라 행성 아래에 낮은 강조로 둔다.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_Meter(
|
||||||
|
key: const ValueKey('eclipse-my-shakes'),
|
||||||
|
label:
|
||||||
|
'${context.l10n.eclipseMyShakes(formatPop(_mine), '${state.qualifyShakes}')}'
|
||||||
|
'${_pending > 0
|
||||||
|
? context.l10n.eclipsePendingSuffix('$_pending')
|
||||||
|
: _sending
|
||||||
|
? context.l10n.eclipseSendingSuffix
|
||||||
|
: ''}'
|
||||||
|
'${_mine >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}',
|
||||||
|
value: state.qualifyShakes <= 0
|
||||||
|
? 0
|
||||||
|
: (_mine / state.qualifyShakes).clamp(0.0, 1.0),
|
||||||
|
color: AppColors.cosmicAccent,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpace.s3),
|
||||||
|
_Meter(
|
||||||
|
key: const ValueKey('eclipse-fandom'),
|
||||||
|
label: context.l10n.eclipseBannerDefense(
|
||||||
|
formatPop(state.defense),
|
||||||
|
formatPop(state.targetDefense),
|
||||||
|
),
|
||||||
|
value: state.progress,
|
||||||
|
color: AppColors.neonEmerald,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpace.s3),
|
||||||
|
// 소모가 없다는 것을 화면이 직접 말한다 — 「POP 이 빠지나?」를 묻게 두지 않는다(설계서 v1.1 §9-#5).
|
||||||
|
_Note(context.l10n.eclipseNoPopNote('${state.qualifyShakes}')),
|
||||||
|
if (_batch != null) _retryButton(),
|
||||||
|
// 센서가 없는 단말에서도 참여할 수 있게 둔다. 판정은 서버가 하므로 이 버튼이
|
||||||
|
// 흔들기보다 유리하지 않다(상한·속도 제한이 같다). 사본에 없는 보조 입력이라
|
||||||
|
// 채움 없는 낮은 강조로 둔다.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpace.s4,
|
||||||
|
AppSpace.s2,
|
||||||
|
AppSpace.s4,
|
||||||
|
AppSpace.s4,
|
||||||
|
),
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: canShake && (_batch == null || _sending) ? _shake : null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.cosmicSurfaceContainer,
|
||||||
|
foregroundColor: AppColors.onCosmicVariant,
|
||||||
|
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
|
||||||
|
),
|
||||||
|
child: Text(context.l10n.eclipseTapToJoin),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _ceilSeconds(Duration d) => math.max(0, (d.inMilliseconds / 1000).ceil());
|
||||||
|
|
||||||
|
/// 남은 시간 표기 — 1분 미만은 「N초」, 그 이상은 「M분 N초」. HOME 버튼과 상세가 같이 쓴다.
|
||||||
|
String eclipseLeftLabel(Duration left) {
|
||||||
|
final s = _ceilSeconds(left);
|
||||||
|
return s < 60
|
||||||
|
? appL10n.eclipseSec('$s')
|
||||||
|
: appL10n.eclipseMinSec('${s ~/ 60}', '${s % 60}');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 사본 제목 — ABeeZee 24 · 행간 140% · 흰색 · 가운데.
|
/// 사본 제목 — ABeeZee 24 · 행간 140% · 흰색 · 가운데.
|
||||||
@@ -455,24 +539,68 @@ class _Note extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 초 단위로 다시 그린다. 마감은 서버 값이라 재진입해도 처음부터 세지 않는다.
|
/// 진행 한 줄 — 글(14 흰색) 위, 6 높이 막대 아래.
|
||||||
class _Countdown extends StatefulWidget {
|
class _Meter extends StatelessWidget {
|
||||||
const _Countdown({required this.endsAt, required this.builder});
|
const _Meter({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
final DateTime? endsAt;
|
final String label;
|
||||||
final Widget Function(BuildContext context, int? seconds) builder;
|
final double value;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_Countdown> createState() => _CountdownState();
|
Widget build(BuildContext context) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: AppFonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.4,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpace.s1),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: value,
|
||||||
|
minHeight: 6,
|
||||||
|
backgroundColor: AppColors.cosmicOutline,
|
||||||
|
valueColor: AlwaysStoppedAnimation(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CountdownState extends State<_Countdown> {
|
/// 화면을 짧은 주기로 다시 그린다. 시각은 서버 값(`startAt` · `endsAt`)이라 재진입해도
|
||||||
|
/// 처음부터 세지 않는다. 1초 주기면 표시가 최대 1초 늦어 오버레이 「3·2·1」이 어긋난다.
|
||||||
|
class EclipseTicker extends StatefulWidget {
|
||||||
|
const EclipseTicker({super.key, required this.builder});
|
||||||
|
|
||||||
|
final Widget Function(BuildContext context, DateTime now) builder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EclipseTicker> createState() => _EclipseTickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EclipseTickerState extends State<EclipseTicker> {
|
||||||
late final Timer _timer;
|
late final Timer _timer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
_timer = Timer.periodic(
|
||||||
|
const Duration(milliseconds: 250),
|
||||||
|
(_) => setState(() {}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -482,31 +610,20 @@ class _CountdownState extends State<_Countdown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => widget.builder(context, DateTime.now());
|
||||||
final at = widget.endsAt;
|
|
||||||
final ms = at?.difference(DateTime.now()).inMilliseconds;
|
|
||||||
return widget.builder(
|
|
||||||
context,
|
|
||||||
ms == null || ms <= 0 ? null : (ms / 1000).ceil(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 행성 + 운석 — 사본 `Frame 1984081426`(355×400).
|
const _planet = AssetImage('assets/figma/planet-home.png');
|
||||||
|
|
||||||
|
// 사본 좌표(프레임 원점 x10 · y220 기준).
|
||||||
|
Widget _at(double x, double y, double w, double h, Widget child) =>
|
||||||
|
Positioned(left: x - 10, top: y - 220, width: w, height: h, child: child);
|
||||||
|
|
||||||
|
/// 침식되는 행성 — 사본 `홈/이클립스 상세`(`373:444865`) `Frame 355×400`.
|
||||||
///
|
///
|
||||||
/// 흔들기 전은 검정 30% 원(280) 안의 행성(240)과 운석 셋, 흔드는 중은 행성 양옆에
|
/// 행성(240) 양옆에 흐린 사본(`image 9~12`, 흐림 4)을 겹쳐 그림자가 번지는 모습이다.
|
||||||
/// 흐린 사본을 겹친 「가려지는」 모습(`373:444966` 등, 흐림 4)이다.
|
class _ErodingPlanet extends StatelessWidget {
|
||||||
class _DefendPlanet extends StatelessWidget {
|
const _ErodingPlanet();
|
||||||
const _DefendPlanet({required this.shaking, required this.pending});
|
|
||||||
|
|
||||||
final bool shaking;
|
|
||||||
final int pending;
|
|
||||||
|
|
||||||
static const _planet = AssetImage('assets/figma/planet-home.png');
|
|
||||||
|
|
||||||
// 사본 좌표(프레임 원점 x10 · y220 기준).
|
|
||||||
static Widget _at(double x, double y, double w, double h, Widget child) =>
|
|
||||||
Positioned(left: x - 10, top: y - 220, width: w, height: h, child: child);
|
|
||||||
|
|
||||||
static Widget _shadow(double x, double y, double size, {double blur = 0}) =>
|
static Widget _shadow(double x, double y, double size, {double blur = 0}) =>
|
||||||
_at(
|
_at(
|
||||||
@@ -523,6 +640,49 @@ class _DefendPlanet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 355,
|
||||||
|
height: 400,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
_shadow(156, 348, 192, blur: 2),
|
||||||
|
_shadow(27, 347, 192, blur: 2),
|
||||||
|
_shadow(47, 320, 220),
|
||||||
|
_shadow(108, 320, 220),
|
||||||
|
_at(67.5, 300, 240, 240, const Image(image: _planet)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 방어 중 행성 — 사본 `홈/이클립스 방어`(`373:445267`) `Frame 355×400`.
|
||||||
|
///
|
||||||
|
/// 검정 30% 원(280) 안의 행성(240)과 운석 셋. 원 둘레의 진행 막대(`373:537356`
|
||||||
|
/// `Group 572` 그라디언트 원 · `516:88231`)는 잔여 시간 비율 [remaining] 이고, 운석은
|
||||||
|
/// 시간이 흐를수록(1 − [remaining]) 행성 쪽으로 다가온다.
|
||||||
|
class _DefendPlanet extends StatelessWidget {
|
||||||
|
const _DefendPlanet({required this.remaining, required this.pending});
|
||||||
|
|
||||||
|
/// 남은 시간 비율 1 → 0.
|
||||||
|
final double remaining;
|
||||||
|
final int pending;
|
||||||
|
|
||||||
|
// 행성 가운데(사본 좌표).
|
||||||
|
static const _cx = 187.5;
|
||||||
|
static const _cy = 420.0;
|
||||||
|
|
||||||
|
/// 운석 하나 — 사본 자리에서 행성 쪽으로 최대 60% 다가온다.
|
||||||
|
Widget _meteor(double x, double y, double w, double h, Widget image) {
|
||||||
|
final t = (1 - remaining) * 0.6;
|
||||||
|
final dx = (_cx - (x + w / 2)) * t;
|
||||||
|
final dy = (_cy - (y + h / 2)) * t;
|
||||||
|
return _at(x + dx, y + dy, w, h, image);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Center(
|
Widget build(BuildContext context) => Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@@ -533,62 +693,89 @@ class _DefendPlanet extends StatelessWidget {
|
|||||||
duration: const Duration(milliseconds: 140),
|
duration: const Duration(milliseconds: 140),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: shaking
|
children: [
|
||||||
? [
|
_at(
|
||||||
_shadow(156, 348, 192, blur: 2),
|
48,
|
||||||
_shadow(27, 347, 192, blur: 2),
|
280,
|
||||||
_shadow(47, 320, 220),
|
280,
|
||||||
_shadow(108, 320, 220),
|
280,
|
||||||
_at(67.5, 300, 240, 240, const Image(image: _planet)),
|
const DecoratedBox(
|
||||||
]
|
decoration: BoxDecoration(
|
||||||
: [
|
shape: BoxShape.circle,
|
||||||
_at(
|
color: Color(0x4D000000),
|
||||||
48,
|
),
|
||||||
280,
|
),
|
||||||
280,
|
),
|
||||||
280,
|
_at(
|
||||||
const DecoratedBox(
|
48,
|
||||||
decoration: BoxDecoration(
|
280,
|
||||||
shape: BoxShape.circle,
|
280,
|
||||||
color: Color(0x4D000000),
|
280,
|
||||||
),
|
CustomPaint(painter: _RemainingRing(remaining)),
|
||||||
),
|
),
|
||||||
),
|
_at(67.5, 300, 240, 240, const Image(image: _planet)),
|
||||||
_at(67.5, 300, 240, 240, const Image(image: _planet)),
|
// 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다.
|
||||||
// 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다.
|
_meteor(
|
||||||
_at(
|
38,
|
||||||
38,
|
246,
|
||||||
246,
|
49,
|
||||||
49,
|
56.5,
|
||||||
56.5,
|
const RotatedBox(
|
||||||
const RotatedBox(
|
quarterTurns: 2,
|
||||||
quarterTurns: 2,
|
child: Image(image: AssetImage('assets/figma/meteor-b.png')),
|
||||||
child: Image(
|
),
|
||||||
image: AssetImage('assets/figma/meteor-b.png'),
|
),
|
||||||
),
|
_meteor(
|
||||||
),
|
328,
|
||||||
),
|
287,
|
||||||
_at(
|
34.1,
|
||||||
328,
|
29.5,
|
||||||
287,
|
const Image(image: AssetImage('assets/figma/meteor-a.png')),
|
||||||
34.1,
|
),
|
||||||
29.5,
|
_meteor(
|
||||||
const Image(image: AssetImage('assets/figma/meteor-a.png')),
|
230,
|
||||||
),
|
580,
|
||||||
_at(
|
29.5,
|
||||||
230,
|
34.1,
|
||||||
580,
|
const Image(image: AssetImage('assets/figma/meteor-b.png')),
|
||||||
29.5,
|
),
|
||||||
34.1,
|
],
|
||||||
const Image(image: AssetImage('assets/figma/meteor-b.png')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 잔여 시간 원호 — 12시에서 시계 방향, 분홍(#FF7DDD) → 보라(#6866F3).
|
||||||
|
class _RemainingRing extends CustomPainter {
|
||||||
|
const _RemainingRing(this.remaining);
|
||||||
|
|
||||||
|
final double remaining;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (remaining <= 0) return;
|
||||||
|
const stroke = 6.0;
|
||||||
|
final rect = (Offset.zero & size).deflate(stroke / 2);
|
||||||
|
canvas.drawArc(
|
||||||
|
rect,
|
||||||
|
-math.pi / 2,
|
||||||
|
2 * math.pi * remaining,
|
||||||
|
false,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = stroke
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..shader = const LinearGradient(
|
||||||
|
colors: [AppColors.cosmicAccent, AppColors.neonViolet],
|
||||||
|
).createShader(rect),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_RemainingRing old) => old.remaining != remaining;
|
||||||
|
}
|
||||||
|
|
||||||
/// 결과 — 사본 `홈/이클립스 성공`(분홍 원 + 체크) · 실패 변형(회색 원).
|
/// 결과 — 사본 `홈/이클립스 성공`(분홍 원 + 체크) · 실패 변형(회색 원).
|
||||||
///
|
///
|
||||||
/// 원 200 · 안쪽 그림자(흰 30% ↘ · 검정 30% ↖ · 3.3) · 아래 「확인」(343×56 · r8 · 분홍).
|
/// 원 200 · 안쪽 그림자(흰 30% ↘ · 검정 30% ↖ · 3.3) · 아래 「확인」(343×56 · r8 · 분홍).
|
||||||
|
|||||||
@@ -40,10 +40,16 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) =>
|
Stream<EclipseState?> watch({
|
||||||
(_watchers['${fandomId ?? ''}#$includeResults'] ??= SharedStream<EclipseState?>(
|
String? fandomId,
|
||||||
() => _openWatch(fandomId, includeResults),
|
bool includeResults = false,
|
||||||
)).stream;
|
bool includeScheduled = false,
|
||||||
|
}) =>
|
||||||
|
(_watchers['${fandomId ?? ''}#$includeResults#$includeScheduled'] ??=
|
||||||
|
SharedStream<EclipseState?>(
|
||||||
|
() => _openWatch(fandomId, includeResults, includeScheduled),
|
||||||
|
))
|
||||||
|
.stream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
|
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
|
||||||
@@ -54,6 +60,7 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
|||||||
Stream<EclipseState?> _openWatch(
|
Stream<EclipseState?> _openWatch(
|
||||||
String? fandomId,
|
String? fandomId,
|
||||||
bool includeResults,
|
bool includeResults,
|
||||||
|
bool includeScheduled,
|
||||||
) => Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) {
|
) => Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) {
|
||||||
if (id.isEmpty) return Stream<EclipseState?>.value(null);
|
if (id.isEmpty) return Stream<EclipseState?>.value(null);
|
||||||
return Stream<EclipseState?>.multi((out) {
|
return Stream<EclipseState?>.multi((out) {
|
||||||
@@ -62,7 +69,11 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
|||||||
var generation = 0;
|
var generation = 0;
|
||||||
final eventsSub = _backend.db
|
final eventsSub = _backend.db
|
||||||
.collection('fandoms/$id/eclipseRounds')
|
.collection('fandoms/$id/eclipseRounds')
|
||||||
.where('status', whereIn: includeResults ? ['active', 'ended', 'cancelled'] : ['active'])
|
.where('status', whereIn: [
|
||||||
|
if (includeScheduled) 'scheduled',
|
||||||
|
'active',
|
||||||
|
if (includeResults) ...['ended', 'cancelled'],
|
||||||
|
])
|
||||||
.orderBy('startAt', descending: true)
|
.orderBy('startAt', descending: true)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.snapshots(includeMetadataChanges: true)
|
.snapshots(includeMetadataChanges: true)
|
||||||
@@ -169,6 +180,7 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
|||||||
return (acc.$1 + d.intOr('qualifiedParticipants'), acc.$2 + d.intOr('defenseCount'));
|
return (acc.$1 + d.intOr('qualifiedParticipants'), acc.$2 + d.intOr('defenseCount'));
|
||||||
});
|
});
|
||||||
final endsAt = data['endsAt'];
|
final endsAt = data['endsAt'];
|
||||||
|
final startAt = data['startAt'];
|
||||||
return EclipseState(
|
return EclipseState(
|
||||||
fandomId: fandomId,
|
fandomId: fandomId,
|
||||||
eclipseId: eclipseId,
|
eclipseId: eclipseId,
|
||||||
@@ -186,6 +198,8 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
|||||||
'cancelled_error' => EclipseResult.cancelled,
|
'cancelled_error' => EclipseResult.cancelled,
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
startAt: startAt is Timestamp ? startAt.toDate() : null,
|
||||||
|
scheduled: data['status'] == 'scheduled',
|
||||||
endsAt: endsAt is Timestamp ? endsAt.toDate() : null,
|
endsAt: endsAt is Timestamp ? endsAt.toDate() : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../../core/theme/tokens.dart';
|
|||||||
import '../../../core/ui/live_stream.dart';
|
import '../../../core/ui/live_stream.dart';
|
||||||
import '../../home/home_screen.dart' show formatPop;
|
import '../../home/home_screen.dart' show formatPop;
|
||||||
import '../eclipse_data.dart';
|
import '../eclipse_data.dart';
|
||||||
|
import '../eclipse_defend_screen.dart';
|
||||||
import '../../../core/i18n/app_locale.dart';
|
import '../../../core/i18n/app_locale.dart';
|
||||||
|
|
||||||
/// 이클립스 인앱 경보 배너 — HOME · GALAXY 상단.
|
/// 이클립스 인앱 경보 배너 — HOME · GALAXY 상단.
|
||||||
@@ -52,7 +53,16 @@ class EclipseBanner extends StatelessWidget {
|
|||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: margin,
|
padding: margin,
|
||||||
child: EclipseBannerCard(state: state, onTap: onTap),
|
// 넘겨받은 동작이 없으면 「흔들어 방어」 화면을 연다 — HOME·행성 상세·GALAXY 홈
|
||||||
|
// 모두 이 기본 동작을 쓴다(배너를 눌러도 아무 일이 없던 결함).
|
||||||
|
child: EclipseBannerCard(
|
||||||
|
state: state,
|
||||||
|
onTap: onTap ?? () => Navigator.of(context, rootNavigator: true).push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => EclipseDefendScreen(
|
||||||
|
repository: repository, fandomId: state.fandomId,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/i18n/app_locale.dart';
|
||||||
|
import '../../../core/theme/tokens.dart';
|
||||||
|
import '../../../core/ui/live_stream.dart';
|
||||||
|
import '../eclipse_data.dart';
|
||||||
|
import '../eclipse_defend_screen.dart';
|
||||||
|
|
||||||
|
/// HOME 이클립스 이벤트 버튼 — 사본 「홈」(`373:443167`)의 `Group 563`.
|
||||||
|
///
|
||||||
|
/// 명세 `516:88125` *데이터: 이클립스 발생 잔여 시간 · 규칙: 성운(행성) 아래 중앙 정렬,
|
||||||
|
/// 어드민에서 설정된 이클립스 이벤트 발생 시간의 N분 전 노출 · 동작: 클릭 시 이클립스 상세로
|
||||||
|
/// 이동*. N분은 서버 회차의 `warnAt`(원장 C-22 발생 3분 전)이다 — 회차 문서가 그때부터
|
||||||
|
/// `scheduled` 로 있으므로 그 문서가 보이는 동안 띄운다. 발생 뒤 방어 창(60초) 동안은
|
||||||
|
/// 남은 초로 바뀌고, 결과가 나거나 창이 닫히면 접는다.
|
||||||
|
///
|
||||||
|
/// 모양: 위를 가리키는 꼭지 + 검정 80% 알약(229×46 · r 끝까지) · 42dot Medium 12 흰색 두 줄.
|
||||||
|
class EclipseEventButton extends StatelessWidget {
|
||||||
|
const EclipseEventButton({
|
||||||
|
super.key,
|
||||||
|
required this.repository,
|
||||||
|
required this.fandomId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EclipseRepository repository;
|
||||||
|
final String fandomId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => LiveStreamBuilder<EclipseState?>(
|
||||||
|
key: ValueKey('eclipse-button-$fandomId'),
|
||||||
|
create: () => repository.watch(fandomId: fandomId, includeScheduled: true),
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state == null) return const SizedBox.shrink();
|
||||||
|
return EclipseTicker(
|
||||||
|
builder: (context, now) {
|
||||||
|
if (state.result != null || state.defended || state.isExpired(now)) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final text = state.hasStarted(now)
|
||||||
|
? context.l10n.eclipseButtonActive(
|
||||||
|
'${((state.remaining(now)?.inMilliseconds ?? 0) / 1000).ceil()}',
|
||||||
|
)
|
||||||
|
: context.l10n.eclipseButtonSoon(
|
||||||
|
eclipseLeftLabel(state.startAt!.difference(now)),
|
||||||
|
);
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
liveRegion: true,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
// 명세 `516:88164` 「닫기 — 하단 move out」 — 아래에서 올라오는 전체 화면.
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (_) => EclipseDefendScreen(
|
||||||
|
repository: repository,
|
||||||
|
fandomId: state.fandomId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CustomPaint(
|
||||||
|
size: const Size(16, 7),
|
||||||
|
painter: _Tip(),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 229, minHeight: 46),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpace.s4,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xCC000000),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: AppFonts.body,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 17 / 12,
|
||||||
|
color: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 말풍선 꼭지 — 행성을 가리킨다.
|
||||||
|
class _Tip extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) => canvas.drawPath(
|
||||||
|
Path()
|
||||||
|
..moveTo(size.width / 2, 0)
|
||||||
|
..lineTo(size.width, size.height)
|
||||||
|
..lineTo(0, size.height)
|
||||||
|
..close(),
|
||||||
|
Paint()..color = const Color(0xCC000000),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_Tip old) => false;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import '../../core/ui/app_icons.dart';
|
|||||||
import '../../core/data/planet_visual_state.dart';
|
import '../../core/data/planet_visual_state.dart';
|
||||||
import '../../core/ui/live_stream.dart';
|
import '../../core/ui/live_stream.dart';
|
||||||
import '../eclipse/eclipse_data.dart';
|
import '../eclipse/eclipse_data.dart';
|
||||||
import '../eclipse/widgets/eclipse_banner.dart';
|
import '../eclipse/widgets/eclipse_event_button.dart';
|
||||||
import '../eclipse/widgets/live_proof.dart';
|
import '../eclipse/widgets/live_proof.dart';
|
||||||
import 'home_data.dart';
|
import 'home_data.dart';
|
||||||
import 'nebula_create_screen.dart';
|
import 'nebula_create_screen.dart';
|
||||||
@@ -221,19 +221,36 @@ class _LiveTickerSlot extends StatelessWidget {
|
|||||||
final String? fallback;
|
final String? fallback;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => LiveStreamBuilder<List<LiveEvent>>(
|
Widget build(BuildContext context) => LiveStreamBuilder<EclipseState?>(
|
||||||
key: ValueKey('live-$fandomId'),
|
key: ValueKey('live-eclipse-$fandomId'),
|
||||||
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
|
// 명세 `516:87664` 이벤트2 「이클립스 이벤트 — 행성/성운명」(사본 `373:443187`
|
||||||
|
// 「BTS 행성에 이클립스가 발생했어요!」). 발생한 회차만 본다.
|
||||||
create: () => fandomId.isEmpty
|
create: () => fandomId.isEmpty
|
||||||
? Stream.value(const <LiveEvent>[])
|
? Stream<EclipseState?>.value(null)
|
||||||
: eclipse.liveEvents(fandomId: fandomId, limit: 5),
|
: eclipse.watch(fandomId: fandomId),
|
||||||
builder: (context, events) {
|
builder: (context, eclipseState) => LiveStreamBuilder<List<LiveEvent>>(
|
||||||
final latest = (events == null || events.isEmpty)
|
key: ValueKey('live-$fandomId'),
|
||||||
? fallback
|
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
|
||||||
: liveEventMessage(events.first);
|
create: () => fandomId.isEmpty
|
||||||
if (latest == null) return const SizedBox.shrink();
|
? Stream.value(const <LiveEvent>[])
|
||||||
return LiveTicker(text: latest);
|
: 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);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +557,12 @@ class _HomeBody extends StatelessWidget {
|
|||||||
GoRouter.maybeOf(context)?.go('/galaxy'),
|
GoRouter.maybeOf(context)?.go('/galaxy'),
|
||||||
zapButtonKey: zapKey,
|
zapButtonKey: zapKey,
|
||||||
onHeroParticipate: _heroParticipate(context),
|
onHeroParticipate: _heroParticipate(context),
|
||||||
|
eclipseButton: data.fandomId.isEmpty
|
||||||
|
? null
|
||||||
|
: EclipseEventButton(
|
||||||
|
repository: eclipse,
|
||||||
|
fandomId: data.fandomId,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -564,20 +587,8 @@ class _HomeBody extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// 이클립스 인앱 경보 — 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱
|
// 이클립스 인앱 경보(별첨 1 ① 「푸시 미수신 단말 — 인앱 워닝 배너 병행」)는
|
||||||
// 워닝 배너 병행」. 활성 이클립스가 없으면 자리도 차지하지 않는다.
|
// Figma 대로 히어로 행성 아래의 이클립스 이벤트 버튼(`516:88124`)이 맡는다.
|
||||||
if (data.fandomId.isNotEmpty)
|
|
||||||
EclipseBanner(
|
|
||||||
repository: eclipse,
|
|
||||||
fandomId: data.fandomId,
|
|
||||||
// 히어로 아래 24 = 히어로 상자 12 + 여기 12. 구획 사이는 36(`Frame 4241`).
|
|
||||||
margin: const EdgeInsets.fromLTRB(
|
|
||||||
AppSpace.s4,
|
|
||||||
HomeSectionHeader.slack,
|
|
||||||
AppSpace.s4,
|
|
||||||
_sectionGap,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와
|
// 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와
|
||||||
// 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE ·
|
// 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE ·
|
||||||
// 인기 K-INSIDE.
|
// 인기 K-INSIDE.
|
||||||
|
|||||||
@@ -48,8 +48,12 @@ class HomeHero extends StatefulWidget {
|
|||||||
this.onItem,
|
this.onItem,
|
||||||
this.miniMap,
|
this.miniMap,
|
||||||
this.zapButtonKey,
|
this.zapButtonKey,
|
||||||
|
this.eclipseButton,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 이클립스 이벤트 버튼(`516:88124`). 경보 단계부터 행성 아래 가운데에 뜬다.
|
||||||
|
final Widget? eclipseButton;
|
||||||
|
|
||||||
/// 화면 하단에 떠 있는 ZAP 버튼([HomeZap])의 키 — 기여 연출이 이 자리에서 행성으로 날아간다.
|
/// 화면 하단에 떠 있는 ZAP 버튼([HomeZap])의 키 — 기여 연출이 이 자리에서 행성으로 날아간다.
|
||||||
final GlobalKey? zapButtonKey;
|
final GlobalKey? zapButtonKey;
|
||||||
|
|
||||||
@@ -181,35 +185,57 @@ class _HomeHeroState extends State<HomeHero> {
|
|||||||
builder: (context, box) => SizedBox(
|
builder: (context, box) => SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: math.min(planet, box.maxHeight),
|
height: math.min(planet, box.maxHeight),
|
||||||
child: widget.unjoined
|
child: Stack(
|
||||||
? _EmptyPlanet(
|
clipBehavior: Clip.none,
|
||||||
onCreateNebula: widget.onCreateNebula,
|
children: [
|
||||||
onJoin: widget.onJoin,
|
Positioned.fill(
|
||||||
)
|
child: widget.unjoined
|
||||||
: growth.isNebula
|
? _EmptyPlanet(
|
||||||
? _Nebula(
|
onCreateNebula: widget.onCreateNebula,
|
||||||
key: _planetKey,
|
onJoin: widget.onJoin,
|
||||||
// 탄생은 창립자만(Q-PN-01 A안) — 다른 멤버에게는 안내 · 탭이 없다.
|
)
|
||||||
ready: growth.nebulaReady && growth.isFounder,
|
: growth.isNebula
|
||||||
onBirth: widget.onBirth,
|
? _Nebula(
|
||||||
)
|
|
||||||
: Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: LivePlanet25D(
|
|
||||||
onOpenHistory: widget.onOpenHistory,
|
|
||||||
key: _planetKey,
|
key: _planetKey,
|
||||||
fandomId: fandomId,
|
// 탄생은 창립자만(Q-PN-01 A안) — 다른 멤버에게는 안내 · 탭이 없다.
|
||||||
watch: watchPlanet,
|
ready:
|
||||||
stage: growth.stage,
|
growth.nebulaReady &&
|
||||||
image: 'assets/figma/planet-home.png',
|
growth.isFounder,
|
||||||
size: math.min(planet, box.maxHeight),
|
onBirth: widget.onBirth,
|
||||||
glow: AppColors.neonViolet,
|
)
|
||||||
|
: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: LivePlanet25D(
|
||||||
|
onOpenHistory: widget.onOpenHistory,
|
||||||
|
key: _planetKey,
|
||||||
|
fandomId: fandomId,
|
||||||
|
watch: watchPlanet,
|
||||||
|
stage: growth.stage,
|
||||||
|
image:
|
||||||
|
'assets/figma/planet-home.png',
|
||||||
|
size: math.min(
|
||||||
|
planet,
|
||||||
|
box.maxHeight,
|
||||||
|
),
|
||||||
|
glow: AppColors.neonViolet,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
// 이클립스 이벤트 버튼 — 명세 `516:88125` 「성운(행성) 아래 중앙 정렬」.
|
||||||
|
// 사본 `Group 563` 은 행성 상자(212) 안 아래쪽에 겹쳐 놓인다.
|
||||||
|
if (widget.eclipseButton case final button?)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Center(child: button),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,27 +7,45 @@ import '../../../core/theme/tokens.dart';
|
|||||||
/// 사본 `Frame 1984081132`(「홈」 `2097:16428`) — 헤더 바로 아래 전폭 띠(높이 22 · 패딩 4/0 ·
|
/// 사본 `Frame 1984081132`(「홈」 `2097:16428`) — 헤더 바로 아래 전폭 띠(높이 22 · 패딩 4/0 ·
|
||||||
/// 바탕 없음), 가운데 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의
|
/// 바탕 없음), 가운데 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의
|
||||||
/// 표면이다.
|
/// 표면이다.
|
||||||
|
///
|
||||||
|
/// 명세 `516:87664` *동작: 이벤트 발생 시 텍스트 우측에서 좌측으로 1회 슬라이드* — 문구가
|
||||||
|
/// 바뀔 때마다 오른쪽 밖에서 한 번 미끄러져 들어와 멈춘다.
|
||||||
class LiveTicker extends StatelessWidget {
|
class LiveTicker extends StatelessWidget {
|
||||||
const LiveTicker({super.key, required this.text});
|
const LiveTicker({super.key, required this.text});
|
||||||
|
|
||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => ClipRect(
|
||||||
height: 22,
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
height: 22,
|
||||||
alignment: Alignment.center,
|
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||||
child: Text(
|
alignment: Alignment.center,
|
||||||
text,
|
child: TweenAnimationBuilder<double>(
|
||||||
maxLines: 1,
|
key: ValueKey(text),
|
||||||
overflow: TextOverflow.ellipsis,
|
tween: Tween(begin: 1, end: 0),
|
||||||
style: const TextStyle(
|
duration: const Duration(milliseconds: 600),
|
||||||
fontFamily: AppFonts.body,
|
curve: Curves.easeOutCubic,
|
||||||
fontSize: 12,
|
// 띠 너비만큼 오른쪽 밖에서 출발한다.
|
||||||
fontWeight: FontWeight.w500,
|
builder: (context, dx, child) => LayoutBuilder(
|
||||||
height: 14 / 12,
|
builder: (context, box) => Transform.translate(
|
||||||
letterSpacing: -0.3,
|
offset: Offset(dx * box.maxWidth, 0),
|
||||||
color: AppColors.onCosmic,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:sensors_plus/sensors_plus.dart';
|
import 'package:sensors_plus/sensors_plus.dart';
|
||||||
@@ -8,6 +7,7 @@ import '../../core/data/backend.dart';
|
|||||||
import '../../core/ui/live_stream.dart';
|
import '../../core/ui/live_stream.dart';
|
||||||
import 'planet_data.dart';
|
import 'planet_data.dart';
|
||||||
import '../../core/i18n/app_locale.dart';
|
import '../../core/i18n/app_locale.dart';
|
||||||
|
import '../quest/quest_evidence.dart' show ShakeDetector;
|
||||||
|
|
||||||
/// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다.
|
/// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다.
|
||||||
class HeroParticipationSheet extends StatefulWidget {
|
class HeroParticipationSheet extends StatefulWidget {
|
||||||
@@ -38,24 +38,16 @@ class _HeroParticipationSheetState extends State<HeroParticipationSheet>
|
|||||||
bool _sending = false;
|
bool _sending = false;
|
||||||
bool _failed = false;
|
bool _failed = false;
|
||||||
bool _foreground = true;
|
bool _foreground = true;
|
||||||
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_sensor = (widget.accelerometer ?? accelerometerEventStream()).listen((
|
final shakes = ShakeDetector();
|
||||||
event,
|
_sensor = (widget.accelerometer ??
|
||||||
) {
|
accelerometerEventStream(samplingPeriod: ShakeDetector.sampling))
|
||||||
final magnitude = math.sqrt(
|
.listen((event) {
|
||||||
event.x * event.x + event.y * event.y + event.z * event.z,
|
if (!shakes.add(event.x, event.y, event.z, DateTime.now())) return;
|
||||||
);
|
|
||||||
final now = DateTime.now();
|
|
||||||
if (!magnitude.isFinite ||
|
|
||||||
magnitude < 18 ||
|
|
||||||
now.difference(_lastShake).inMilliseconds < 220)
|
|
||||||
return;
|
|
||||||
_lastShake = now;
|
|
||||||
if (!_failed) _claim();
|
if (!_failed) _claim();
|
||||||
}, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다.
|
}, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,8 +112,13 @@ enum QuestType {
|
|||||||
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
|
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
|
||||||
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
|
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
|
||||||
if (this == QuestType.streaming) return 0;
|
if (this == QuestType.streaming) return 0;
|
||||||
|
// Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24). 서버(`fn-quest-verify-music-shake`)가
|
||||||
|
// 퀘스트의 `rewardPop` 을, 없으면 `config/policy.stepsPop` 을 기본액으로 쓰고 판정 등급
|
||||||
|
// 배수를 곱한다. 화면은 기본액(C 등급)을 적는다 — 운영값이 둘 다 없을 때만 중립 문구.
|
||||||
final raw = this == QuestType.attend
|
final raw = this == QuestType.attend
|
||||||
? policy['attendPop']
|
? policy['attendPop']
|
||||||
|
: this == QuestType.musicShake
|
||||||
|
? quest['rewardPop'] ?? policy['stepsPop']
|
||||||
: quest['rewardPop'];
|
: quest['rewardPop'];
|
||||||
return raw is num ? raw.round() : null;
|
return raw is num ? raw.round() : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,22 +48,62 @@ typedef PlaceProbe = Future<PlaceFix> Function();
|
|||||||
/// 흔들기 한 번마다 그 시각을 내는 스트림.
|
/// 흔들기 한 번마다 그 시각을 내는 스트림.
|
||||||
typedef ShakeProbe = Stream<DateTime> Function();
|
typedef ShakeProbe = Stream<DateTime> Function();
|
||||||
|
|
||||||
/// 흔들기 감지 — 가속도 크기가 중력(9.8)을 크게 넘는 순간. 이클립스 방어 화면과 같은 문턱 ·
|
/// 흔들기 한 번을 세는 규칙 — Music Shake · 이클립스 방어 · HERO TIME 이 함께 쓴다.
|
||||||
/// 간격이다(같은 동작을 같은 기준으로 센다).
|
///
|
||||||
Stream<DateTime> deviceShakes() async* {
|
/// **단위는 두 플랫폼 모두 m/s²(중력 포함)다.** sensors_plus 가 iOS 의 g 값에 9.81 을
|
||||||
var last = DateTime.fromMillisecondsSinceEpoch(0);
|
/// 곱해 넘긴다. 플랫폼 차이는 **표본 주기**였다(2026-10-05 실기기 — 같은 시간 흔들어
|
||||||
await for (final e in accelerometerEventStream()) {
|
/// iPhone 16 · Galaxy 63). 기본 주기(200ms)를 iOS 는 그대로 지켜 초당 5 표본만 오고,
|
||||||
final magnitude = math.sqrt(e.x * e.x + e.y * e.y + e.z * e.z);
|
/// Android 는 요청을 힌트로만 받아 훨씬 촘촘히 보낸다. 흔들기의 가속도 정점은 수십 ms
|
||||||
final now = DateTime.now();
|
/// 라 5Hz 표본은 대부분을 놓친다. 그래서 [sampling] 을 명시하고(약 50Hz),
|
||||||
if (!magnitude.isFinite || magnitude < 18 ||
|
/// 정점 하나를 한 번만 세도록 [rearm] 아래로 내려와야 다시 센다(히스테리시스).
|
||||||
now.difference(last) < const Duration(milliseconds: 220)) {
|
class ShakeDetector {
|
||||||
continue;
|
ShakeDetector({
|
||||||
|
this.threshold = 18,
|
||||||
|
this.rearm = 13,
|
||||||
|
this.gap = const Duration(milliseconds: 220),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 흔들기로 보는 가속도 크기(m/s²). 정지 상태는 중력 9.8 이다.
|
||||||
|
final double threshold;
|
||||||
|
|
||||||
|
/// 이 아래로 내려와야 다음 흔들기를 센다. 한 정점이 표본 여럿에 걸쳐도 한 번이다.
|
||||||
|
final double rearm;
|
||||||
|
|
||||||
|
/// 두 흔들기 사이 최소 간격. 이보다 촘촘한 값은 같은 동작의 진동이다.
|
||||||
|
final Duration gap;
|
||||||
|
|
||||||
|
/// 표본 주기. 두 플랫폼이 같은 주기로 보도록 기본값(200ms)을 쓰지 않는다.
|
||||||
|
static const sampling = SensorInterval.gameInterval;
|
||||||
|
|
||||||
|
bool _armed = true;
|
||||||
|
DateTime _last = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
|
/// 표본 하나를 넣는다. 흔들기 한 번으로 셀 표본이면 true.
|
||||||
|
bool add(double x, double y, double z, DateTime at) {
|
||||||
|
final magnitude = math.sqrt(x * x + y * y + z * z);
|
||||||
|
if (!magnitude.isFinite) return false;
|
||||||
|
if (magnitude < rearm) {
|
||||||
|
_armed = true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
last = now;
|
if (!_armed || magnitude < threshold || at.difference(_last) < gap) {
|
||||||
yield now;
|
return false;
|
||||||
|
}
|
||||||
|
_armed = false;
|
||||||
|
_last = at;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 단말 가속도계에서 흔들기 한 번마다 그 시각을 낸다.
|
||||||
|
Stream<DateTime> deviceShakes() {
|
||||||
|
final detector = ShakeDetector();
|
||||||
|
return accelerometerEventStream(samplingPeriod: ShakeDetector.sampling)
|
||||||
|
.map((e) => (e, DateTime.now()))
|
||||||
|
.where((s) => detector.add(s.$1.x, s.$1.y, s.$1.z, s.$2))
|
||||||
|
.map((s) => s.$2);
|
||||||
|
}
|
||||||
|
|
||||||
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
|
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
|
||||||
///
|
///
|
||||||
/// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것,
|
/// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것,
|
||||||
|
|||||||
@@ -799,5 +799,12 @@
|
|||||||
"homeIncubator": "Fandom Planet Incubator",
|
"homeIncubator": "Fandom Planet Incubator",
|
||||||
"homeGrowTogether": "Grow together",
|
"homeGrowTogether": "Grow together",
|
||||||
"homeArtistNews": "Artist News",
|
"homeArtistNews": "Artist News",
|
||||||
"growthConditionsTitle": "Growth conditions"
|
"growthConditionsTitle": "Growth conditions",
|
||||||
|
"eclipseButtonSoon": "Eclipse in {time}!\nTap the planet to stop it!",
|
||||||
|
"eclipseButtonActive": "The eclipse has started!\nShake your phone within {seconds}s to stop it!",
|
||||||
|
"eclipseSec": "{seconds}s",
|
||||||
|
"eclipseMinSec": "{minutes}m {seconds}s",
|
||||||
|
"eclipseOverlayTitle": "Meteor impact\nis starting!",
|
||||||
|
"eclipseLiveOccurred": "An eclipse has hit planet {name}!",
|
||||||
|
"eclipseMyShakes": "My valid shakes {count} / {total}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -799,5 +799,12 @@
|
|||||||
"homeIncubator": "Incubadora de planetas fandom",
|
"homeIncubator": "Incubadora de planetas fandom",
|
||||||
"homeGrowTogether": "Crecer juntos",
|
"homeGrowTogether": "Crecer juntos",
|
||||||
"homeArtistNews": "Noticias del artista",
|
"homeArtistNews": "Noticias del artista",
|
||||||
"growthConditionsTitle": "Condiciones de crecimiento"
|
"growthConditionsTitle": "Condiciones de crecimiento",
|
||||||
|
"eclipseButtonSoon": "¡Eclipse en {time}!\n¡Toca el planeta para detenerlo!",
|
||||||
|
"eclipseButtonActive": "¡El eclipse ha comenzado!\n¡Agita el teléfono en {seconds} s para detenerlo!",
|
||||||
|
"eclipseSec": "{seconds} s",
|
||||||
|
"eclipseMinSec": "{minutes} min {seconds} s",
|
||||||
|
"eclipseOverlayTitle": "¡El impacto de meteoritos\nva a comenzar!",
|
||||||
|
"eclipseLiveOccurred": "¡Un eclipse ha llegado al planeta {name}!",
|
||||||
|
"eclipseMyShakes": "Mis sacudidas válidas {count} / {total}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -799,5 +799,12 @@
|
|||||||
"homeIncubator": "ファンダム惑星インキュベーター",
|
"homeIncubator": "ファンダム惑星インキュベーター",
|
||||||
"homeGrowTogether": "一緒に成長する",
|
"homeGrowTogether": "一緒に成長する",
|
||||||
"homeArtistNews": "アーティストニュース",
|
"homeArtistNews": "アーティストニュース",
|
||||||
"growthConditionsTitle": "成長条件"
|
"growthConditionsTitle": "成長条件",
|
||||||
|
"eclipseButtonSoon": "{time}後にイクリプスが発生します!\n惑星をタップしてイクリプスを防ぎましょう!",
|
||||||
|
"eclipseButtonActive": "イクリプスが発生しました!\n{seconds}秒以内にスマホを振って防ぎましょう!",
|
||||||
|
"eclipseSec": "{seconds}秒",
|
||||||
|
"eclipseMinSec": "{minutes}分{seconds}秒",
|
||||||
|
"eclipseOverlayTitle": "惑星への隕石衝突が\n始まります!",
|
||||||
|
"eclipseLiveOccurred": "{name}惑星にイクリプスが発生しました!",
|
||||||
|
"eclipseMyShakes": "自分の有効Shake {count} / {total}回"
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-1
@@ -1653,5 +1653,60 @@
|
|||||||
"homeIncubator": "팬덤행성 인큐베이터",
|
"homeIncubator": "팬덤행성 인큐베이터",
|
||||||
"homeGrowTogether": "함께 성장하기",
|
"homeGrowTogether": "함께 성장하기",
|
||||||
"homeArtistNews": "아티스트 소식",
|
"homeArtistNews": "아티스트 소식",
|
||||||
"growthConditionsTitle": "성장 조건"
|
"growthConditionsTitle": "성장 조건",
|
||||||
|
"eclipseButtonSoon": "{time} 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!",
|
||||||
|
"@eclipseButtonSoon": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eclipseButtonActive": "이클립스가 발생했어요!\n{seconds}초 안에 휴대폰을 흔들어 막으세요!",
|
||||||
|
"@eclipseButtonActive": {
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eclipseSec": "{seconds}초",
|
||||||
|
"@eclipseSec": {
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eclipseMinSec": "{minutes}분 {seconds}초",
|
||||||
|
"@eclipseMinSec": {
|
||||||
|
"placeholders": {
|
||||||
|
"minutes": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"seconds": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eclipseOverlayTitle": "행성 운석 충돌이\n시작됩니다!",
|
||||||
|
"eclipseLiveOccurred": "{name} 행성에 이클립스가 발생했어요!",
|
||||||
|
"@eclipseLiveOccurred": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eclipseMyShakes": "내 유효 Shake {count} / {total}회",
|
||||||
|
"@eclipseMyShakes": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4915,6 +4915,48 @@ abstract class AppLocalizations {
|
|||||||
/// In ko, this message translates to:
|
/// In ko, this message translates to:
|
||||||
/// **'성장 조건'**
|
/// **'성장 조건'**
|
||||||
String get growthConditionsTitle;
|
String get growthConditionsTitle;
|
||||||
|
|
||||||
|
/// No description provided for @eclipseButtonSoon.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'{time} 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!'**
|
||||||
|
String eclipseButtonSoon(String time);
|
||||||
|
|
||||||
|
/// No description provided for @eclipseButtonActive.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'이클립스가 발생했어요!\n{seconds}초 안에 휴대폰을 흔들어 막으세요!'**
|
||||||
|
String eclipseButtonActive(String seconds);
|
||||||
|
|
||||||
|
/// No description provided for @eclipseSec.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'{seconds}초'**
|
||||||
|
String eclipseSec(String seconds);
|
||||||
|
|
||||||
|
/// No description provided for @eclipseMinSec.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'{minutes}분 {seconds}초'**
|
||||||
|
String eclipseMinSec(String minutes, String seconds);
|
||||||
|
|
||||||
|
/// No description provided for @eclipseOverlayTitle.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'행성 운석 충돌이\n시작됩니다!'**
|
||||||
|
String get eclipseOverlayTitle;
|
||||||
|
|
||||||
|
/// No description provided for @eclipseLiveOccurred.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'{name} 행성에 이클립스가 발생했어요!'**
|
||||||
|
String eclipseLiveOccurred(String name);
|
||||||
|
|
||||||
|
/// No description provided for @eclipseMyShakes.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'내 유효 Shake {count} / {total}회'**
|
||||||
|
String eclipseMyShakes(String count, String total);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -2788,4 +2788,37 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthConditionsTitle => 'Growth conditions';
|
String get growthConditionsTitle => 'Growth conditions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonSoon(String time) {
|
||||||
|
return 'Eclipse in $time!\nTap the planet to stop it!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonActive(String seconds) {
|
||||||
|
return 'The eclipse has started!\nShake your phone within ${seconds}s to stop it!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseSec(String seconds) {
|
||||||
|
return '${seconds}s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMinSec(String minutes, String seconds) {
|
||||||
|
return '${minutes}m ${seconds}s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get eclipseOverlayTitle => 'Meteor impact\nis starting!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseLiveOccurred(String name) {
|
||||||
|
return 'An eclipse has hit planet $name!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMyShakes(String count, String total) {
|
||||||
|
return 'My valid shakes $count / $total';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2800,4 +2800,37 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthConditionsTitle => 'Condiciones de crecimiento';
|
String get growthConditionsTitle => 'Condiciones de crecimiento';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonSoon(String time) {
|
||||||
|
return '¡Eclipse en $time!\n¡Toca el planeta para detenerlo!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonActive(String seconds) {
|
||||||
|
return '¡El eclipse ha comenzado!\n¡Agita el teléfono en $seconds s para detenerlo!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseSec(String seconds) {
|
||||||
|
return '$seconds s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMinSec(String minutes, String seconds) {
|
||||||
|
return '$minutes min $seconds s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get eclipseOverlayTitle => '¡El impacto de meteoritos\nva a comenzar!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseLiveOccurred(String name) {
|
||||||
|
return '¡Un eclipse ha llegado al planeta $name!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMyShakes(String count, String total) {
|
||||||
|
return 'Mis sacudidas válidas $count / $total';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2695,4 +2695,37 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthConditionsTitle => '成長条件';
|
String get growthConditionsTitle => '成長条件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonSoon(String time) {
|
||||||
|
return '$time後にイクリプスが発生します!\n惑星をタップしてイクリプスを防ぎましょう!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonActive(String seconds) {
|
||||||
|
return 'イクリプスが発生しました!\n$seconds秒以内にスマホを振って防ぎましょう!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseSec(String seconds) {
|
||||||
|
return '$seconds秒';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMinSec(String minutes, String seconds) {
|
||||||
|
return '$minutes分$seconds秒';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get eclipseOverlayTitle => '惑星への隕石衝突が\n始まります!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseLiveOccurred(String name) {
|
||||||
|
return '$name惑星にイクリプスが発生しました!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMyShakes(String count, String total) {
|
||||||
|
return '自分の有効Shake $count / $total回';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2697,4 +2697,37 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthConditionsTitle => '성장 조건';
|
String get growthConditionsTitle => '성장 조건';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonSoon(String time) {
|
||||||
|
return '$time 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonActive(String seconds) {
|
||||||
|
return '이클립스가 발생했어요!\n$seconds초 안에 휴대폰을 흔들어 막으세요!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseSec(String seconds) {
|
||||||
|
return '$seconds초';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMinSec(String minutes, String seconds) {
|
||||||
|
return '$minutes분 $seconds초';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get eclipseOverlayTitle => '행성 운석 충돌이\n시작됩니다!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseLiveOccurred(String name) {
|
||||||
|
return '$name 행성에 이클립스가 발생했어요!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMyShakes(String count, String total) {
|
||||||
|
return '내 유효 Shake $count / $total회';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2692,4 +2692,37 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get growthConditionsTitle => '成长条件';
|
String get growthConditionsTitle => '成长条件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonSoon(String time) {
|
||||||
|
return '$time后将发生日食!\n点击行星来阻止日食!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonActive(String seconds) {
|
||||||
|
return '日食已发生!\n请在$seconds秒内摇动手机来阻止!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseSec(String seconds) {
|
||||||
|
return '$seconds秒';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMinSec(String minutes, String seconds) {
|
||||||
|
return '$minutes分$seconds秒';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get eclipseOverlayTitle => '陨石即将\n撞击行星!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseLiveOccurred(String name) {
|
||||||
|
return '$name行星发生了日食!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMyShakes(String count, String total) {
|
||||||
|
return '我的有效摇动 $count / $total次';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -799,5 +799,12 @@
|
|||||||
"homeIncubator": "粉丝星球孵化器",
|
"homeIncubator": "粉丝星球孵化器",
|
||||||
"homeGrowTogether": "一起成长",
|
"homeGrowTogether": "一起成长",
|
||||||
"homeArtistNews": "艺人动态",
|
"homeArtistNews": "艺人动态",
|
||||||
"growthConditionsTitle": "成长条件"
|
"growthConditionsTitle": "成长条件",
|
||||||
|
"eclipseButtonSoon": "{time}后将发生日食!\n点击行星来阻止日食!",
|
||||||
|
"eclipseButtonActive": "日食已发生!\n请在{seconds}秒内摇动手机来阻止!",
|
||||||
|
"eclipseSec": "{seconds}秒",
|
||||||
|
"eclipseMinSec": "{minutes}分{seconds}秒",
|
||||||
|
"eclipseOverlayTitle": "陨石即将\n撞击行星!",
|
||||||
|
"eclipseLiveOccurred": "{name}行星发生了日食!",
|
||||||
|
"eclipseMyShakes": "我的有效摇动 {count} / {total}次"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -562,6 +562,12 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String eclipseBannerDefense(String count, String target) => _fill('eclipseBannerDefense', {'count': count, 'target': target}) ?? _base.eclipseBannerDefense(count, target);
|
String eclipseBannerDefense(String count, String target) => _fill('eclipseBannerDefense', {'count': count, 'target': target}) ?? _base.eclipseBannerDefense(count, target);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonActive(String seconds) => _fill('eclipseButtonActive', {'seconds': seconds}) ?? _base.eclipseButtonActive(seconds);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseButtonSoon(String time) => _fill('eclipseButtonSoon', {'time': time}) ?? _base.eclipseButtonSoon(time);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get eclipseCancelBody => _remote['eclipseCancelBody'] ?? _base.eclipseCancelBody;
|
String get eclipseCancelBody => _remote['eclipseCancelBody'] ?? _base.eclipseCancelBody;
|
||||||
|
|
||||||
@@ -583,15 +589,27 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get eclipseFailTitle => _remote['eclipseFailTitle'] ?? _base.eclipseFailTitle;
|
String get eclipseFailTitle => _remote['eclipseFailTitle'] ?? _base.eclipseFailTitle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseLiveOccurred(String name) => _fill('eclipseLiveOccurred', {'name': name}) ?? _base.eclipseLiveOccurred(name);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMinSec(String minutes, String seconds) => _fill('eclipseMinSec', {'minutes': minutes, 'seconds': seconds}) ?? _base.eclipseMinSec(minutes, seconds);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String eclipseMyCount(String count) => _fill('eclipseMyCount', {'count': count}) ?? _base.eclipseMyCount(count);
|
String eclipseMyCount(String count) => _fill('eclipseMyCount', {'count': count}) ?? _base.eclipseMyCount(count);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseMyShakes(String count, String total) => _fill('eclipseMyShakes', {'count': count, 'total': total}) ?? _base.eclipseMyShakes(count, total);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String eclipseNoPopNote(String count) => _fill('eclipseNoPopNote', {'count': count}) ?? _base.eclipseNoPopNote(count);
|
String eclipseNoPopNote(String count) => _fill('eclipseNoPopNote', {'count': count}) ?? _base.eclipseNoPopNote(count);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get eclipseNone => _remote['eclipseNone'] ?? _base.eclipseNone;
|
String get eclipseNone => _remote['eclipseNone'] ?? _base.eclipseNone;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get eclipseOverlayTitle => _remote['eclipseOverlayTitle'] ?? _base.eclipseOverlayTitle;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get eclipsePendingBody => _remote['eclipsePendingBody'] ?? _base.eclipsePendingBody;
|
String get eclipsePendingBody => _remote['eclipsePendingBody'] ?? _base.eclipsePendingBody;
|
||||||
|
|
||||||
@@ -610,6 +628,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get eclipseRetrySame => _remote['eclipseRetrySame'] ?? _base.eclipseRetrySame;
|
String get eclipseRetrySame => _remote['eclipseRetrySame'] ?? _base.eclipseRetrySame;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String eclipseSec(String seconds) => _fill('eclipseSec', {'seconds': seconds}) ?? _base.eclipseSec(seconds);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String eclipseSeconds(String seconds) => _fill('eclipseSeconds', {'seconds': seconds}) ?? _base.eclipseSeconds(seconds);
|
String eclipseSeconds(String seconds) => _fill('eclipseSeconds', {'seconds': seconds}) ?? _base.eclipseSeconds(seconds);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sensors_plus/sensors_plus.dart' show SensorInterval;
|
||||||
|
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
|
||||||
|
import 'package:fancake/features/eclipse/widgets/eclipse_event_button.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
import 'package:fancake/features/home/home_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_evidence.dart';
|
||||||
|
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
|
||||||
|
/// 이클립스 Figma 흐름 — HOME 이벤트 버튼(`516:88124`) → 상세(`373:444865`) →
|
||||||
|
/// 발생 직전 오버레이(`373:444976`) → 방어(`373:445267`) → 결과, 그리고 흔들기 판정.
|
||||||
|
Future<void> _pump(WidgetTester tester, Widget home) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: home));
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('흔들기 판정 (iOS 16 · Galaxy 63 실기기 편차)', () {
|
||||||
|
test('표본 주기를 명시한다 — 기본 200ms(iOS 5Hz)를 쓰지 않는다', () {
|
||||||
|
expect(ShakeDetector.sampling, SensorInterval.gameInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 흔들기 [count] 번을 50Hz 로 흉내 낸다. 정점마다 표본 여러 개가 문턱을 넘는다.
|
||||||
|
int countAt50Hz(int count, {Duration period = const Duration(milliseconds: 300)}) {
|
||||||
|
final d = ShakeDetector();
|
||||||
|
var at = DateTime(2026, 10, 5);
|
||||||
|
var hits = 0;
|
||||||
|
for (var i = 0; i < count; i++) {
|
||||||
|
// 정점 3 표본(60ms) 연속 문턱 위 → 나머지는 중력 근처.
|
||||||
|
for (final m in [20.0, 24.0, 21.0]) {
|
||||||
|
if (d.add(m, 0, 0, at)) hits++;
|
||||||
|
at = at.add(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
for (var t = 60; t < period.inMilliseconds; t += 20) {
|
||||||
|
if (d.add(9.8, 0, 0, at)) hits++;
|
||||||
|
at = at.add(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('정점 하나는 표본이 여럿이어도 한 번이다', () {
|
||||||
|
expect(countAt50Hz(20), 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('문턱 아래로 내려오지 않으면 다시 세지 않는다', () {
|
||||||
|
final d = ShakeDetector();
|
||||||
|
final t0 = DateTime(2026, 10, 5);
|
||||||
|
expect(d.add(25, 0, 0, t0), isTrue);
|
||||||
|
// 1초 내내 문턱 위 — 같은 동작이다.
|
||||||
|
for (var i = 1; i <= 50; i++) {
|
||||||
|
expect(d.add(25, 0, 0, t0.add(Duration(milliseconds: 20 * i))), isFalse);
|
||||||
|
}
|
||||||
|
expect(d.add(9.8, 0, 0, t0.add(const Duration(seconds: 2))), isFalse);
|
||||||
|
expect(d.add(25, 0, 0, t0.add(const Duration(milliseconds: 2020))), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('간격보다 촘촘한 정점은 같은 동작의 진동이다', () {
|
||||||
|
expect(countAt50Hz(10, period: const Duration(milliseconds: 120)), lessThan(10));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('HOME 이클립스 이벤트 버튼 (516:88125)', () {
|
||||||
|
testWidgets('경보(scheduled) 단계부터 잔여 시간을 띄우고 누르면 이클립스 상세로 간다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(
|
||||||
|
state: eclipseFixture(
|
||||||
|
scheduled: true,
|
||||||
|
startAt: DateTime.now().add(const Duration(minutes: 2, seconds: 30)),
|
||||||
|
endsAt: DateTime.now().add(const Duration(minutes: 3, seconds: 30)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
Scaffold(body: Center(child: EclipseEventButton(repository: repo, fandomId: 'bts'))),
|
||||||
|
);
|
||||||
|
expect(find.textContaining('후 이클립스가 발생해요!'), findsOneWidget);
|
||||||
|
expect(find.textContaining('2분'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(EclipseEventButton));
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
expect(find.byType(EclipseDefendScreen), findsOneWidget);
|
||||||
|
expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('발생 뒤에는 남은 방어 초를, 결과가 나면 접는다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(
|
||||||
|
state: eclipseFixture(
|
||||||
|
startAt: DateTime.now().subtract(const Duration(seconds: 10)),
|
||||||
|
endsAt: DateTime.now().add(const Duration(seconds: 50)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
Scaffold(body: Center(child: EclipseEventButton(repository: repo, fandomId: 'bts'))),
|
||||||
|
);
|
||||||
|
expect(find.textContaining('이클립스가 발생했어요!'), findsOneWidget);
|
||||||
|
|
||||||
|
repo.push(eclipseFixture(defended: true));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
expect(find.textContaining('이클립스'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('HOME 라이브 띠가 이클립스 발생을 알리고 행성 아래 버튼이 뜬다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(
|
||||||
|
state: eclipseFixture(
|
||||||
|
startAt: DateTime.now().subtract(const Duration(seconds: 5)),
|
||||||
|
endsAt: DateTime.now().add(const Duration(seconds: 55)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
HomeScreen(
|
||||||
|
repository: const SampleHomeRepository(),
|
||||||
|
questRepository: const SampleQuestRepository(),
|
||||||
|
eclipse: repo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('BTS 행성에 이클립스가 발생했어요!'), findsOneWidget);
|
||||||
|
expect(find.byType(EclipseEventButton), findsOneWidget);
|
||||||
|
// Figma 에 없는 카드형 배너는 HOME 에서 빠졌다.
|
||||||
|
expect(find.textContaining('이클립스 발동 —'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('이클립스 상세 → 오버레이 → 방어 (373:444865 · 373:444976 · 373:445267)', () {
|
||||||
|
testWidgets('발생 전은 상세 — 아래에 발생 남은 시간', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(
|
||||||
|
state: eclipseFixture(
|
||||||
|
scheduled: true,
|
||||||
|
startAt: DateTime.now().add(const Duration(seconds: 40)),
|
||||||
|
endsAt: DateTime.now().add(const Duration(seconds: 100)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
|
||||||
|
expect(find.text('휴대폰을 흔들어\n행성 운석 충돌을 막으세요!'), findsOneWidget);
|
||||||
|
expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget);
|
||||||
|
expect(find.text('흔들기 어려우면 눌러서 참여'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('발생 3초 전부터 오버레이 카운트다운', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(
|
||||||
|
state: eclipseFixture(
|
||||||
|
scheduled: true,
|
||||||
|
startAt: DateTime.now().add(const Duration(milliseconds: 2500)),
|
||||||
|
endsAt: DateTime.now().add(const Duration(seconds: 62)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
|
||||||
|
expect(find.text('행성 운석 충돌이\n시작됩니다!'), findsOneWidget);
|
||||||
|
expect(find.text('3'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('방어 중 진행 표시 — 내 유효 Shake / 인정 횟수 · 팬덤 참여자 / 목표', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(
|
||||||
|
state: eclipseFixture(
|
||||||
|
defense: 3,
|
||||||
|
targetDefense: 10,
|
||||||
|
startAt: DateTime.now().subtract(const Duration(seconds: 1)),
|
||||||
|
endsAt: DateTime.now().add(const Duration(seconds: 59)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
|
||||||
|
expect(find.textContaining('초간 휴대폰을 흔들어'), findsOneWidget);
|
||||||
|
expect(find.text('내 유효 Shake 0 / 60회'), findsOneWidget);
|
||||||
|
expect(find.text('유효 참여 3 / 10명'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
expect(find.text('내 유효 Shake 1 / 60회'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('발생 직후 서버가 아직 scheduled 면 모았다가 active 가 되면 보낸다', (tester) async {
|
||||||
|
final startAt = DateTime.now().subtract(const Duration(seconds: 1));
|
||||||
|
final endsAt = DateTime.now().add(const Duration(seconds: 59));
|
||||||
|
final repo = FakeEclipseRepository(
|
||||||
|
state: eclipseFixture(scheduled: true, startAt: startAt, endsAt: endsAt),
|
||||||
|
);
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
|
||||||
|
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
expect(repo.calls, isEmpty);
|
||||||
|
|
||||||
|
repo.push(eclipseFixture(startAt: startAt, endsAt: endsAt));
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
expect(repo.calls, hasLength(1));
|
||||||
|
expect(repo.calls.single.$3, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Music Shake 보상 표시 (원장 B-24)', () {
|
||||||
|
test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 stepsPop 을 기본액으로 적는다', () {
|
||||||
|
expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), 50);
|
||||||
|
expect(
|
||||||
|
QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, const {'stepsPop': 50}),
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
expect(QuestType.musicShake.rewardFrom(const {}, const {}), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ class NoEclipseRepository implements EclipseRepository {
|
|||||||
const NoEclipseRepository();
|
const NoEclipseRepository();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) => Stream.value(null);
|
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) => Stream.value(null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
|
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
|
||||||
@@ -69,7 +69,7 @@ class FakeEclipseRepository implements EclipseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) async* {
|
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) async* {
|
||||||
yield _last;
|
yield _last;
|
||||||
yield* _eclipse.stream;
|
yield* _eclipse.stream;
|
||||||
}
|
}
|
||||||
@@ -110,6 +110,8 @@ EclipseState eclipseFixture({
|
|||||||
int defenseCount = 12,
|
int defenseCount = 12,
|
||||||
bool defended = false,
|
bool defended = false,
|
||||||
EclipseResult? result,
|
EclipseResult? result,
|
||||||
|
DateTime? startAt,
|
||||||
|
bool scheduled = false,
|
||||||
DateTime? endsAt,
|
DateTime? endsAt,
|
||||||
}) => EclipseState(
|
}) => EclipseState(
|
||||||
fandomId: fandomId,
|
fandomId: fandomId,
|
||||||
@@ -121,6 +123,8 @@ EclipseState eclipseFixture({
|
|||||||
defenseCount: defenseCount,
|
defenseCount: defenseCount,
|
||||||
defended: defended,
|
defended: defended,
|
||||||
result: result,
|
result: result,
|
||||||
|
startAt: startAt,
|
||||||
|
scheduled: scheduled,
|
||||||
// 마감이 없으면 화면이 「지났는가」를 물을 수 없다. 기본을 넉넉히 둔다.
|
// 마감이 없으면 화면이 「지났는가」를 물을 수 없다. 기본을 넉넉히 둔다.
|
||||||
endsAt: endsAt ?? DateTime.now().add(const Duration(minutes: 20)),
|
endsAt: endsAt ?? DateTime.now().add(const Duration(minutes: 20)),
|
||||||
);
|
);
|
||||||
@@ -142,7 +146,7 @@ class CountingEclipseRepository implements EclipseRepository {
|
|||||||
int active = 0;
|
int active = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) {
|
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) {
|
||||||
late StreamController<EclipseState?> controller;
|
late StreamController<EclipseState?> controller;
|
||||||
controller = StreamController<EclipseState?>(
|
controller = StreamController<EclipseState?>(
|
||||||
onListen: () {
|
onListen: () {
|
||||||
|
|||||||
Reference in New Issue
Block a user