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 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; /// 묶어 보내는 시간창. 흔들 때마다 부르면 호출이 초당 여러 번 나간다. 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 _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, // 경보 단계부터 들어온다 — 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 createState() => _EclipseTickerState(); } class _EclipseTickerState extends State { 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 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; }