import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../core/theme/tokens.dart'; import '../../../core/ui/planet_fx_scope.dart'; import '../../../core/format.dart'; import '../galaxy_data.dart'; import '../../../core/i18n/app_locale.dart'; /// v1.5 §3: 입력 에너지는 즉시, 흡수·기여 결과는 서버 거래 확정 후에만 표시한다. class ZapFeedback extends StatefulWidget { const ZapFeedback({ super.key, required this.touchSerial, required this.pending, required this.result, required this.source, required this.target, required this.radius, this.fandomName, this.quest = false, this.messageAboveTarget = false, }); /// 안내 띠를 [target] 행성 상자 위쪽에 둔다(HOME). 기본 자리(아래 128)는 HOME 에서 행성 이름 · /// 성장 카드 말풍선을 덮는다(10/8 r11). 행성 상자 반 변 = [radius] / 0.4(HOME 이 넘기는 값). final bool messageAboveTarget; /// 퀘스트 보상 확정의 행성 반응인가 — 결과 문구를 퀘스트 반영으로 바꾼다. final bool quest; /// 기여가 반영된 행성 이름 — 9/21 회신 6절 「N POP 이 [팬덤] 성장에 반영되었습니다」. final String? fandomName; final int touchSerial; final bool pending; final ZapResult? result; final Offset source; final Offset target; final double radius; @override State createState() => _ZapFeedbackState(); } class _ZapFeedbackState extends State with TickerProviderStateMixin { late final _travel = AnimationController( vsync: this, duration: const Duration(milliseconds: 380), ); late final _absorb = AnimationController( vsync: this, duration: const Duration(milliseconds: 1800), ); final _seen = {}; bool _showResult = false; @override void didUpdateWidget(ZapFeedback old) { super.didUpdateWidget(old); if (widget.touchSerial != old.touchSerial) { _showResult = false; _travel.forward(from: 0); } final result = widget.result; if (result != null && result.txId.isNotEmpty && _seen.add(result.txId)) { _showResult = true; _absorb.forward(from: 0); } } @override void dispose() { _travel.dispose(); _absorb.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final reduced = MediaQuery.disableAnimationsOf(context); final strongFx = PlanetFxScope.of(context)?.busy ?? false; return AnimatedBuilder( animation: Listenable.merge([_travel, _absorb]), builder: (context, _) { final result = widget.result; final showResult = _showResult && !widget.pending && _absorb.value < 1; return Stack( children: [ if (!reduced && !strongFx) Positioned.fill( child: CustomPaint( painter: _ZapPainter( source: widget.source, target: widget.target, radius: widget.radius, travel: _travel.value, travelling: _travel.isAnimating, absorption: showResult ? _absorb.value : null, ), ), ), if (widget.pending || showResult) Positioned( left: 16, right: 16, top: widget.messageAboveTarget && widget.target != Offset.zero ? math.max(0, widget.target.dy - widget.radius / 0.4 / 2) : null, bottom: widget.messageAboveTarget && widget.target != Offset.zero ? null : 128, child: Center( child: Semantics( liveRegion: true, child: DecoratedBox( decoration: BoxDecoration( color: AppColors.cosmicSurface, borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), child: Text( widget.pending ? context.l10n.zapApplying : widget.quest ? (widget.fandomName ?? '').isNotEmpty ? context.l10n.questReflected( widget.fandomName!, ) : context.l10n.zapDone : result?.contributedPop == null ? context.l10n.zapDone : (widget.fandomName ?? '').isNotEmpty ? context.l10n.zapReflected( formatPop(result!.contributedPop!), widget.fandomName!, ) : context.l10n.zapDonePop( formatPop(result!.contributedPop!), ), style: Theme.of(context).textTheme.labelLarge, ), ), ), ), ), ), ], ); }, ); } } class _ZapPainter extends CustomPainter { const _ZapPainter({ required this.source, required this.target, required this.radius, required this.travel, required this.travelling, required this.absorption, }); final Offset source; final Offset target; final double radius; final double travel; final bool travelling; final double? absorption; @override void paint(Canvas canvas, Size size) { const color = AppColors.cosmicAccent; if (travelling) { final t = Curves.easeOutCubic.transform(travel); // 짧은 직선 이동, 잔상은 끝점 근처 3개로 제한한다. for (var i = 2; i >= 0; i--) { final p = Offset.lerp(source, target, (t - i * 0.04).clamp(0.0, 1.0))!; canvas.drawCircle( p, 5.0 - i, Paint() ..color = color.withValues( alpha: (1 - i * 0.25) * (1 - travel * 0.5), ), ); } } final a = absorption; if (a == null || a > 0.5) return; final t = a * 2; canvas.drawCircle( target, radius * (1 + t * 0.5), Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 * (1 - t) + 0.5 ..color = color.withValues(alpha: 0.75 * (1 - t)), ); canvas.drawCircle( target, radius * 1.5, Paint() ..shader = RadialGradient( colors: [ color.withValues(alpha: 0.3 * (1 - t)), color.withValues(alpha: 0), ], ).createShader(Rect.fromCircle(center: target, radius: radius * 1.5)), ); for (var i = 0; i < 8; i++) { final angle = i * math.pi / 4; final p = target + Offset(math.cos(angle), math.sin(angle)) * radius * (1.5 - t * 0.5); canvas.drawCircle( p, 2 * (1 - t), Paint()..color = color.withValues(alpha: 1 - t), ); } } @override bool shouldRepaint(_ZapPainter old) => old.source != source || old.target != target || old.radius != radius || old.travel != travel || old.travelling != travelling || old.absorption != absorption; }