HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -0,0 +1,209 @@
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,
});
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
: result?.contributedPop == null
? context.l10n.zapDone
: 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;
}