Files
fanuzu/app/lib/features/galaxy/widgets/zap_feedback.dart
T

239 lines
7.6 KiB
Dart

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<ZapFeedback> createState() => _ZapFeedbackState();
}
class _ZapFeedbackState extends State<ZapFeedback>
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 = <String>{};
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;
}