Files
fanuzu/app/lib/features/eclipse/eclipse_defend_screen.dart
T

698 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}