HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -0,0 +1,697 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:sensors_plus/sensors_plus.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 'eclipse_data.dart';
import '../../core/i18n/app_locale.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,
});
final EclipseRepository repository;
/// 어느 팬덤의 이클립스인가. 화면은 구독으로 현재 상태를 따라간다.
final String fandomId;
@override
State<EclipseDefendScreen> createState() => _EclipseDefendScreenState();
}
class _EclipseDefendScreenState extends State<EclipseDefendScreen>
with WidgetsBindingObserver {
bool _foreground = true;
String? _roundId;
StreamSubscription<AccelerometerEvent>? _accel;
/// 아직 서버에 보내지 않은 흔들기 수.
int _pending = 0;
/// 서버가 받아들인 내 누적 참여량.
int _mine = 0;
/// 상한에 걸려 깎인 적이 있는가. 한 번 알리고 다시 알리지 않는다.
bool _cappedNotified = false;
Timer? _burst;
bool _sending = false;
({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);
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_accel = accelerometerEventStream().listen(
_onAccel,
onError: (_) {
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 화면은 그대로 두고
// 아래의 「직접 누르기」로 참여할 수 있게 한다.
},
);
}
@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 _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() {
if (!_foreground ||
ModalRoute.of(context)?.isCurrent == false ||
_current?.canDefend != true ||
(_batch != null && !_sending))
return;
setState(() => _pending += 1);
_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,
),
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?.canDefend != true) {
_pending = 0;
_burst?.cancel();
_burst = null;
}
if (state == null) {
return Column(
children: [
const _CloseBar(),
Expanded(child: Center(child: Text(context.l10n.eclipseNone))),
],
);
}
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(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,
),
),
),
),
],
),
);
},
),
),
);
}
/// 사본 제목 — 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,
),
),
);
}
/// 초 단위로 다시 그린다. 마감은 서버 값이라 재진입해도 처음부터 세지 않는다.
class _Countdown extends StatefulWidget {
const _Countdown({required this.endsAt, required this.builder});
final DateTime? endsAt;
final Widget Function(BuildContext context, int? seconds) builder;
@override
State<_Countdown> createState() => _CountdownState();
}
class _CountdownState extends State<_Countdown> {
late final Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
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).
///
/// 흔들기 전은 검정 30% 원(280) 안의 행성(240)과 운석 셋, 흔드는 중은 행성 양옆에
/// 흐린 사본을 겹친 「가려지는」 모습(`373:444966` 등, 흐림 4)이다.
class _DefendPlanet extends StatelessWidget {
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}) =>
_at(
x,
y,
size,
size,
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: const ColorFiltered(
colorFilter: ColorFilter.mode(Color(0xB3141414), BlendMode.srcATop),
child: Image(image: _planet),
),
),
);
@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: shaking
? [
_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)),
]
: [
_at(
48,
280,
280,
280,
const DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color(0x4D000000),
),
),
),
_at(67.5, 300, 240, 240, const Image(image: _planet)),
// 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다.
_at(
38,
246,
49,
56.5,
const RotatedBox(
quarterTurns: 2,
child: Image(
image: AssetImage('assets/figma/meteor-b.png'),
),
),
),
_at(
328,
287,
34.1,
29.5,
const Image(image: AssetImage('assets/figma/meteor-a.png')),
),
_at(
230,
580,
29.5,
34.1,
const Image(image: AssetImage('assets/figma/meteor-b.png')),
),
],
),
),
),
);
}
/// 결과 — 사본 `홈/이클립스 성공`(분홍 원 + 체크) · 실패 변형(회색 원).
///
/// 원 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;
}