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 createState() => _EclipseDefendScreenState(); } class _EclipseDefendScreenState extends State with WidgetsBindingObserver { bool _foreground = true; String? _roundId; StreamSubscription? _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 _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( 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 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; }