HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user