229 lines
7.0 KiB
Dart
229 lines
7.0 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,
|
|
});
|
|
|
|
/// 퀘스트 보상 확정의 행성 반응인가 — 결과 문구를 퀘스트 반영으로 바꾼다.
|
|
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,
|
|
bottom: 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;
|
|
}
|