1053 lines
35 KiB
Dart
1053 lines
35 KiB
Dart
import 'dart:async';
|
||
import 'dart:math' as math;
|
||
|
||
import 'dart:ui' show ImageFilter;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../../core/data/backend.dart';
|
||
import '../../core/theme/tokens.dart';
|
||
import '../../core/ui/app_icons.dart';
|
||
import '../../core/ui/live_stream.dart';
|
||
import '../home/home_screen.dart' show formatPop;
|
||
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 참여 화면.
|
||
///
|
||
/// 근거 — 정책 원장 **B-55** *하루 2 회 **공동 Shake 방어 이벤트*** ·
|
||
/// Firestore 설계서 v1.1 §9-#5 *방어는 Shake 참여로 이루어지며 **1 회당 POP 소모를
|
||
/// 두지 않는다***. 그래서 이 화면에는 잔액도 차감 표기도 없다.
|
||
///
|
||
/// **흔들기 판정을 화면이 최종적으로 하지 않는다.** 단말이 센 횟수는 서버가 상한과
|
||
/// 속도로 다시 판정하며(별첨 1 ② 서버검증 원칙), 화면은 서버가 받아들인 값
|
||
/// ([DefendResult.accepted])만 자기 기여로 표시한다. 단말 값을 그대로 믿고 그리면
|
||
/// 서버가 깎은 뒤에 숫자가 줄어드는 것으로 보인다.
|
||
///
|
||
/// `quest/verify.ts` 의 `gradeShake` 는 재사용하지 않았다. 그쪽은 **구간별 박자
|
||
/// 정확도**(perfect·good·miss)를 매기는 Music Shake 판정이고, 이클립스 방어는
|
||
/// 박자가 없는 자유 흔들기 누적이라 판정 모델이 다르다.
|
||
class EclipseDefendScreen extends StatefulWidget {
|
||
const EclipseDefendScreen({
|
||
super.key,
|
||
required this.repository,
|
||
required this.fandomId,
|
||
this.shakes = deviceShakes,
|
||
this.nebula = false,
|
||
});
|
||
|
||
final EclipseRepository repository;
|
||
|
||
/// 대상 팬덤이 아직 성운인가 — 명세 `516:88158` *온보딩 행성/성운 - 이미지* · `516:88185`
|
||
/// *행성/성운 - 사용자 행성/성운 이미지*. 성운이면 성운 그림으로 그린다.
|
||
final bool nebula;
|
||
|
||
/// 흔들기 입력. 테스트는 가짜 스트림을 넣는다 — 방어 수단은 **Shake 하나**다(FX §5).
|
||
final ShakeProbe shakes;
|
||
|
||
/// 어느 팬덤의 이클립스인가. 화면은 구독으로 현재 상태를 따라간다.
|
||
final String fandomId;
|
||
|
||
@override
|
||
State<EclipseDefendScreen> createState() => _EclipseDefendScreenState();
|
||
}
|
||
|
||
class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||
with WidgetsBindingObserver {
|
||
bool _foreground = true;
|
||
String? _roundId;
|
||
StreamSubscription<DateTime>? _accel;
|
||
|
||
/// 아직 서버에 보내지 않은 흔들기 수.
|
||
int _pending = 0;
|
||
|
||
/// 서버가 받아들인 내 누적 참여량.
|
||
int _mine = 0;
|
||
|
||
/// 받아들인 흔들기 일련번호 — 바뀔 때마다 Shield Pulse 한 번(v1.5 §5 「유효 Shake 마다 Shield Pulse / 원형 파동」).
|
||
int _pulse = 0;
|
||
|
||
/// 화면에 보이는 내 횟수 — 서버 확정값에 아직 보내지 않았거나 보내는 중인 흔들기를 더한다.
|
||
/// 흔드는 동안 서버 왕복(약 0.7초 묶음)을 기다리면 숫자가 늦게 올라 「얼마나 잘 흔들고
|
||
/// 있는지」 알 수 없었다. 확정값이 오면 그것으로 맞춰진다.
|
||
int get _shown => _mine + _pending + (_sending ? (_batch?.shakes ?? 0) : 0);
|
||
|
||
/// 상한에 걸려 깎인 적이 있는가. 한 번 알리고 다시 알리지 않는다.
|
||
bool _cappedNotified = false;
|
||
|
||
Timer? _burst;
|
||
bool _sending = false;
|
||
|
||
/// 가속도 센서가 없는 단말. 탭 대체 참여는 두지 않는다(FX §5 「수동 방어 Shake only」).
|
||
bool _noSensor = false;
|
||
({String fandomId, String eclipseId, String seq, int shakes})? _batch;
|
||
|
||
/// 묶어 보내는 시간창. 흔들 때마다 부르면 호출이 초당 여러 번 나간다.
|
||
static const _burstWindow = Duration(milliseconds: 700);
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
// 흔들기 판정 규칙(문턱 · 표본 주기)은 Music Shake 와 같다 — [ShakeDetector].
|
||
_accel = widget.shakes().listen(
|
||
(_) => _shake(),
|
||
onError: (_) {
|
||
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 방어는 Shake only(FX §5)라
|
||
// 대체 입력을 두지 않고 지원하지 않는다고만 알린다.
|
||
if (mounted) setState(() => _noSensor = true);
|
||
},
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
_burst?.cancel();
|
||
_burst = null;
|
||
_accel?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
_foreground = state == AppLifecycleState.resumed;
|
||
if (!_foreground) {
|
||
_burst?.cancel();
|
||
_burst = null;
|
||
_pending = 0;
|
||
}
|
||
}
|
||
|
||
void _shake() {
|
||
if (!_foreground ||
|
||
ModalRoute.of(context)?.isCurrent == false ||
|
||
_current?.canShake(DateTime.now()) != true ||
|
||
(_batch != null && !_sending))
|
||
return;
|
||
final qualify = _current?.qualifyShakes ?? 0;
|
||
final before = _shown;
|
||
setState(() {
|
||
_pending += 1;
|
||
_pulse += 1;
|
||
});
|
||
// 화면을 보지 않고 흔들어도 알 수 있게 — 한 번 셀 때마다 짧게, 인정 기준을 넘는 순간 길게.
|
||
if (qualify > 0 && before < qualify && _shown >= qualify) {
|
||
fxHaptic(FxIntensity.large);
|
||
} else {
|
||
HapticFeedback.selectionClick();
|
||
}
|
||
_scheduleFlush();
|
||
}
|
||
|
||
void _scheduleFlush() {
|
||
_burst ??= Timer(_burstWindow, () {
|
||
_burst = null;
|
||
_flush();
|
||
});
|
||
}
|
||
|
||
Future<void> _flush({bool retry = false}) async {
|
||
if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent == false)
|
||
return;
|
||
if (_batch != null && !retry) return;
|
||
if (_batch == null) {
|
||
final state = _current;
|
||
if (_pending == 0 || state == null || !state.canDefend) return;
|
||
_batch = (
|
||
fandomId: state.fandomId,
|
||
eclipseId: state.eclipseId,
|
||
seq: 'shake${DateTime.now().microsecondsSinceEpoch}',
|
||
shakes: _pending,
|
||
);
|
||
_pending = 0;
|
||
}
|
||
final batch = _batch!;
|
||
setState(() => _sending = true);
|
||
try {
|
||
final result = await widget.repository.defend(
|
||
fandomId: batch.fandomId,
|
||
eclipseId: batch.eclipseId,
|
||
clientSeq: batch.seq,
|
||
shakes: batch.shakes,
|
||
);
|
||
if (!mounted || _batch != batch) return;
|
||
_batch = null;
|
||
if (_current?.eclipseId == batch.eclipseId) {
|
||
setState(() => _mine = math.max(_mine, result.myTotal));
|
||
if (result.clamped > 0 && !_cappedNotified) {
|
||
_cappedNotified = true;
|
||
_notify(context.l10n.eclipseCapped);
|
||
}
|
||
}
|
||
} on BackendException catch (error) {
|
||
if (!mounted || _batch != batch) return;
|
||
if (['E-ECL-01', 'E-AUTH-03', 'E-PERM-01'].contains(error.code))
|
||
_batch = null;
|
||
_notify(_failure(error));
|
||
} catch (_) {
|
||
if (!mounted || _batch != batch) return;
|
||
_notify(context.l10n.eclipseResultUnknown);
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _sending = false);
|
||
if (_batch == null &&
|
||
_pending > 0 &&
|
||
_foreground &&
|
||
_current?.canDefend == true)
|
||
_scheduleFlush();
|
||
}
|
||
}
|
||
}
|
||
|
||
Widget _retryButton() => TextButton(
|
||
onPressed: _sending ? null : () => _flush(retry: true),
|
||
child: Text(
|
||
_sending ? context.l10n.eclipseChecking : context.l10n.eclipseRetrySame,
|
||
),
|
||
);
|
||
|
||
/// 서버 오류 코드를 문구로 옮긴다. 코드로 분기하고 메시지로 분기하지 않는다.
|
||
String _failure(BackendException error) => switch (error.code) {
|
||
'E-ECL-01' => context.l10n.eclipseEnded,
|
||
'E-QST-02' => context.l10n.eclipseTooFast,
|
||
'E-AUTH-03' => context.l10n.authLoginRequired,
|
||
_ => context.l10n.eclipseResultUnknown,
|
||
};
|
||
|
||
void _notify(String message) {
|
||
ScaffoldMessenger.of(context)
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(
|
||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||
);
|
||
}
|
||
|
||
/// 마지막으로 받은 이클립스 상태. 방어 호출이 대상 ID 를 여기서 얻는다.
|
||
EclipseState? _current;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Scaffold(
|
||
body: SafeArea(
|
||
child: LiveStreamBuilder<EclipseState?>(
|
||
key: ValueKey('defend-${widget.fandomId}'),
|
||
create: () => widget.repository.watch(
|
||
fandomId: widget.fandomId,
|
||
includeResults: true,
|
||
// 경보 단계부터 들어온다 — HOME 이클립스 이벤트 버튼(`516:88125`) → 이클립스 상세.
|
||
includeScheduled: true,
|
||
),
|
||
builder: (context, state) {
|
||
_current = state;
|
||
if (state != null && _roundId != state.eclipseId) {
|
||
_roundId = state.eclipseId;
|
||
_mine = 0;
|
||
_batch = null;
|
||
_pending = 0;
|
||
_cappedNotified = false;
|
||
_burst?.cancel();
|
||
_burst = null;
|
||
}
|
||
if (state == null ||
|
||
(!state.canShake(DateTime.now()) && state.hasStarted(DateTime.now()))) {
|
||
_pending = 0;
|
||
_burst?.cancel();
|
||
_burst = null;
|
||
}
|
||
// 발생 직후 서버가 회차를 `active` 로 바꾸기 전에 모인 흔들기를 이제 보낸다.
|
||
if (state != null && state.canDefend && _pending > 0 && _batch == null) {
|
||
_scheduleFlush();
|
||
}
|
||
if (state == null) {
|
||
return Column(
|
||
children: [
|
||
const _CloseBar(),
|
||
Expanded(child: Center(child: Text(context.l10n.eclipseNone))),
|
||
],
|
||
);
|
||
}
|
||
// 단계는 시각으로 갈린다 — 상세(발생 전) → 오버레이(발생 직전) → 방어 → 결과.
|
||
return EclipseTicker(
|
||
builder: (context, now) => _phase(context, state, now),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
|
||
/// 발생 직전 오버레이 길이. 사본 `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 (_shown >= 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 (_shown >= 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),
|
||
Flexible(
|
||
child: SizedBox(height: 400, child: _ErodingPlanet(image: _imageOf(widget.nebula))),
|
||
),
|
||
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);
|
||
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,
|
||
),
|
||
),
|
||
// 사본 `373:445267` 「…행성 운석 충돌을 막으세요!」와 다르게 「이클립스를 막으세요!」다 —
|
||
// 발주사 상세정의 v1.6.1 3장 · 1주차 공식 검토 회신서 17항 「운석 충돌은 Figma 에 남은 임시
|
||
// 카피, Eclipse 와 별도 이벤트로 구현하지 않고 Eclipse 문구로 일원화」(해석확인 영역A N-A05).
|
||
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,
|
||
pulse: _pulse,
|
||
shield: state.progress,
|
||
image: _imageOf(widget.nebula),
|
||
),
|
||
),
|
||
),
|
||
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(_shown), '${state.qualifyShakes}')}'
|
||
'${_pending > 0
|
||
? context.l10n.eclipsePendingSuffix('$_pending')
|
||
: _sending
|
||
? context.l10n.eclipseSendingSuffix
|
||
: ''}'
|
||
'${_shown >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}',
|
||
value: state.qualifyShakes <= 0
|
||
? 0
|
||
: (_shown / 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(),
|
||
if (_noSensor) _Note(context.l10n.eclipseNoSensor),
|
||
const SizedBox(height: AppSpace.s4),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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% · 흰색 · 가운데.
|
||
const _titleStyle = TextStyle(
|
||
fontFamily: AppFonts.display,
|
||
fontSize: 24,
|
||
fontWeight: FontWeight.w400,
|
||
height: 1.4,
|
||
color: AppColors.onCosmic,
|
||
);
|
||
|
||
/// 사본 상단 `wrapper`(56) — 왼쪽 16 에 닫기 X(24).
|
||
class _CloseBar extends StatelessWidget {
|
||
const _CloseBar();
|
||
|
||
@override
|
||
Widget build(BuildContext context) => SizedBox(
|
||
height: 56,
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: IconButton(
|
||
constraints: const BoxConstraints.tightFor(width: 56, height: 56),
|
||
icon: const ImageIcon(AppIcons.x, size: 24, color: AppColors.onCosmic),
|
||
tooltip: context.l10n.commonClose,
|
||
onPressed: () => Navigator.of(context).maybePop(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 사본에 없는 정책 안내 한 단락 — 12 · #767676 · 가운데.
|
||
class _Note extends StatelessWidget {
|
||
const _Note(this.text);
|
||
|
||
final String text;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||
child: Text(
|
||
text,
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.4,
|
||
color: AppColors.onCosmicMuted,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 진행 한 줄 — 글(14 흰색) 위, 6 높이 막대 아래.
|
||
class _Meter extends StatelessWidget {
|
||
const _Meter({
|
||
super.key,
|
||
required this.label,
|
||
required this.value,
|
||
required this.color,
|
||
});
|
||
|
||
final String label;
|
||
final double value;
|
||
final Color color;
|
||
|
||
@override
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 화면을 짧은 주기로 다시 그린다. 시각은 서버 값(`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;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_timer = Timer.periodic(
|
||
const Duration(milliseconds: 250),
|
||
(_) => setState(() {}),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_timer.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => widget.builder(context, DateTime.now());
|
||
}
|
||
|
||
const _planet = AssetImage('assets/figma/planet-home.png');
|
||
const _nebula = AssetImage('assets/figma/nebula-home.png');
|
||
|
||
AssetImage _imageOf(bool nebula) => nebula ? _nebula : _planet;
|
||
|
||
/// 이클립스 상세로 가는 길 — 아래에서 올라와 아래로 닫힌다(명세 `516:88158` *하단 move out*).
|
||
/// HOME 버튼 · 배너(HOME · 행성 상세 · GALAXY)가 모두 이 경로를 쓴다.
|
||
Route<void> eclipseDefendRoute({
|
||
required EclipseRepository repository,
|
||
required String fandomId,
|
||
bool nebula = false,
|
||
}) => SheetRoute<void>(
|
||
builder: (_) => EclipseDefendScreen(
|
||
repository: repository,
|
||
fandomId: fandomId,
|
||
nebula: nebula,
|
||
),
|
||
);
|
||
|
||
// 사본 좌표(프레임 원점 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`.
|
||
///
|
||
/// 행성(240) 양옆에 흐린 사본(`image 9~12`, 흐림 4)을 겹쳐 그림자가 번지는 모습이다.
|
||
class _ErodingPlanet extends StatelessWidget {
|
||
const _ErodingPlanet({required this.image});
|
||
|
||
final AssetImage image;
|
||
|
||
Widget _shadow(double x, double y, double size, {double blur = 0}) =>
|
||
_at(
|
||
x,
|
||
y,
|
||
size,
|
||
size,
|
||
ImageFiltered(
|
||
imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||
child: ColorFiltered(
|
||
colorFilter: const ColorFilter.mode(Color(0xB3141414), BlendMode.srcATop),
|
||
child: Image(image: image),
|
||
),
|
||
),
|
||
);
|
||
|
||
@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, Image(image: image)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 방어 중 행성 — 사본 `홈/이클립스 방어`(`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,
|
||
required this.image,
|
||
this.pulse = 0,
|
||
this.shield = 0,
|
||
});
|
||
|
||
/// 고유 유효 참여자 / 목표(서버 값 [EclipseState.progress]) — Shield 조각이 이어지는 정도.
|
||
final double shield;
|
||
|
||
/// 흔들기 일련번호. 0 이면 아직 파동이 없다.
|
||
final int pulse;
|
||
|
||
final AssetImage image;
|
||
|
||
/// 남은 시간 비율 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
|
||
Widget build(BuildContext context) => Center(
|
||
child: SizedBox(
|
||
width: 355,
|
||
height: 400,
|
||
child: AnimatedScale(
|
||
scale: pending > 0 ? 1.04 : 1,
|
||
duration: const Duration(milliseconds: 140),
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
_at(
|
||
48,
|
||
280,
|
||
280,
|
||
280,
|
||
const DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Color(0x4D000000),
|
||
),
|
||
),
|
||
),
|
||
_at(
|
||
48,
|
||
280,
|
||
280,
|
||
280,
|
||
CustomPaint(painter: _RemainingRing(remaining)),
|
||
),
|
||
_at(67.5, 300, 240, 240, Image(image: image)),
|
||
// Shield — 끊어진 Ring 이 참여자 비율만큼 이어지고 목표 달성 시 완전한 원(v1.5 §5.1).
|
||
// 값이 바뀌면 Shield Pulse 와 같은 길이로 이어 붙인다. 동작 줄이기면 바로 바뀐다.
|
||
_at(
|
||
48,
|
||
280,
|
||
280,
|
||
280,
|
||
TweenAnimationBuilder<double>(
|
||
key: const ValueKey('shield-ring'),
|
||
tween: Tween(end: shield.clamp(0.0, 1.0)),
|
||
duration: MediaQuery.disableAnimationsOf(context)
|
||
? Duration.zero
|
||
: shieldPulseDuration,
|
||
builder: (context, p, _) =>
|
||
CustomPaint(painter: ShieldRingPainter(p)),
|
||
),
|
||
),
|
||
// 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,
|
||
246,
|
||
49,
|
||
56.5,
|
||
const RotatedBox(
|
||
quarterTurns: 2,
|
||
child: Image(image: AssetImage('assets/figma/meteor-b.png')),
|
||
),
|
||
),
|
||
_meteor(
|
||
328,
|
||
287,
|
||
34.1,
|
||
29.5,
|
||
const Image(image: AssetImage('assets/figma/meteor-a.png')),
|
||
),
|
||
_meteor(
|
||
230,
|
||
580,
|
||
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 · 분홍).
|
||
class _Outcome extends StatelessWidget {
|
||
const _Outcome({
|
||
required this.title,
|
||
required this.success,
|
||
this.confirm = true,
|
||
this.extra = const [],
|
||
});
|
||
|
||
final String title;
|
||
final bool success;
|
||
final bool confirm;
|
||
final List<Widget> extra;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Column(
|
||
children: [
|
||
const SizedBox(height: 56),
|
||
const SizedBox(height: 24),
|
||
SizedBox(
|
||
height: 68,
|
||
child: Text(title, textAlign: TextAlign.center, style: _titleStyle),
|
||
),
|
||
const SizedBox(height: 104),
|
||
CustomPaint(
|
||
size: const Size.square(200),
|
||
painter: _OutcomeDisc(
|
||
color: success ? AppColors.cosmicAccent : const Color(0xFFBDBDBD),
|
||
check: success,
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpace.s5),
|
||
...extra,
|
||
const Spacer(),
|
||
if (confirm)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
AppSpace.s4,
|
||
0,
|
||
AppSpace.s4,
|
||
AppSpace.s4,
|
||
),
|
||
child: FilledButton(
|
||
onPressed: () => Navigator.of(context).maybePop(),
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size.fromHeight(56),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
),
|
||
),
|
||
child: Text(context.l10n.commonConfirm),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 사본 `Group 567` — 200 원 · 안쪽 그림자 두 겹(흰 30% +3.3 · 검정 30% -3.3, 흐림 3.3)
|
||
/// 과 `Vector` 체크(50×33.3 · 선 16.7 #F8F8F8 · 둥근 끝).
|
||
class _OutcomeDisc extends CustomPainter {
|
||
const _OutcomeDisc({required this.color, required this.check});
|
||
|
||
final Color color;
|
||
final bool check;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final circle = Offset.zero & size;
|
||
canvas.drawOval(circle, Paint()..color = color);
|
||
canvas.save();
|
||
canvas.clipPath(Path()..addOval(circle));
|
||
for (final (shadow, d) in [(0x4DFFFFFF, 3.3), (0x4D000000, -3.3)]) {
|
||
// 원 바깥을 칠한 뒤 d 만큼 밀어 흐리면 안쪽 가장자리에만 그림자가 남는다.
|
||
final ring = Path()
|
||
..fillType = PathFillType.evenOdd
|
||
..addRect(circle.inflate(20))
|
||
..addOval(circle.shift(Offset(d, d)));
|
||
canvas.drawPath(
|
||
ring,
|
||
Paint()
|
||
..color = Color(shadow)
|
||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3.3 / 2),
|
||
);
|
||
}
|
||
canvas.restore();
|
||
if (!check) return;
|
||
canvas.drawPath(
|
||
Path()
|
||
..moveTo(74.1, 99.2)
|
||
..lineTo(90.8, 115.8)
|
||
..lineTo(124.1, 82.5),
|
||
Paint()
|
||
..color = const Color(0xFFF8F8F8)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 16.7
|
||
..strokeCap = StrokeCap.round
|
||
..strokeJoin = StrokeJoin.round,
|
||
);
|
||
}
|
||
|
||
@override
|
||
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;
|
||
}
|
||
|
||
/// Shield Ring 조각 수. 원문(v1.5 §5.1)에 수치가 없는 임시값(해석 확인 항목).
|
||
const shieldSegments = 12;
|
||
|
||
/// 진행 [p] 0~1 → Ring 조각들의 (시작각, 길이). 처음엔 조각 사이 틈이 절반이고, 참여자가 늘수록
|
||
/// 틈이 좁아져 이어지며, 목표 달성(1)이면 틈 없는 원 하나다.
|
||
List<(double, double)> shieldArcs(double p) {
|
||
if (p >= 1) return const [(-math.pi / 2, 2 * math.pi)];
|
||
const slot = 2 * math.pi / shieldSegments;
|
||
final sweep = slot * (0.5 + 0.5 * p.clamp(0.0, 1.0));
|
||
return [
|
||
for (var i = 0; i < shieldSegments; i++)
|
||
(-math.pi / 2 + i * slot + (slot - sweep) / 2, sweep),
|
||
];
|
||
}
|
||
|
||
/// 행성(240)과 잔여 시간 원호(280) 사이의 Shield Ring. 완성되면 바깥 번짐을 한 겹 더한다.
|
||
class ShieldRingPainter extends CustomPainter {
|
||
const ShieldRingPainter(this.progress);
|
||
|
||
final double progress;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final r = size.shortestSide / 2 * (260 / 280);
|
||
final rect = Rect.fromCircle(center: size.center(Offset.zero), radius: r);
|
||
final full = progress >= 1;
|
||
final paint = Paint()
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = full ? 5 : 3
|
||
..strokeCap = StrokeCap.round
|
||
..color = AppColors.neonEmerald.withValues(alpha: 0.35 + 0.55 * progress.clamp(0.0, 1.0));
|
||
if (full) {
|
||
canvas.drawCircle(
|
||
rect.center,
|
||
r,
|
||
Paint()
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 12
|
||
..color = AppColors.neonEmerald.withValues(alpha: 0.25),
|
||
);
|
||
}
|
||
for (final (start, sweep) in shieldArcs(progress)) {
|
||
canvas.drawArc(rect, start, sweep, false, paint);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(ShieldRingPainter old) => old.progress != progress;
|
||
}
|