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

885 lines
28 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 '../../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 deviceShakes;
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<DateTime>? _accel;
/// 아직 서버에 보내지 않은 흔들기 수.
int _pending = 0;
/// 서버가 받아들인 내 누적 참여량.
int _mine = 0;
/// 상한에 걸려 깎인 적이 있는가. 한 번 알리고 다시 알리지 않는다.
bool _cappedNotified = false;
Timer? _burst;
bool _sending = 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 = deviceShakes().listen(
(_) => _shake(),
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 _shake() {
if (!_foreground ||
ModalRoute.of(context)?.isCurrent == false ||
_current?.canShake(DateTime.now()) != 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,
// 경보 단계부터 들어온다 — 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 (_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% · 흰색 · 가운데.
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');
// 사본 좌표(프레임 원점 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();
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: 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
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, const Image(image: _planet)),
// 좌상단 운석(`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;
}