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

313 lines
9.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../../core/i18n/app_locale.dart';
const _bolt = AssetImage('assets/figma/icon-bolt.png');
const _item = AssetImage('assets/figma/icon-item.png');
const _zapGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF4BC2AE), Color(0xFF4F98C8), Color(0xFF5669DC)],
);
/// Zap — Figma 「Zap 버튼」 명세 그대로.
///
/// *요소 : Zap 기본 · Zap 옵션 · 기여 · 아이템 · 닫기*
///
/// **「자동(오토 도네이션)」은 두지 않는다.** 별첨 1 ① 시나리오 4 가 적었으나 별첨 B
/// 가 상위 기준이고 거기에 없는 기능이다(공통 기준 2항).
/// *규칙 : 화면 하단 위치 고정 · 디폴트 Zap 기본 노출 ·*
/// ***행성/성운 가입 또는 생성한 유저에게만 노출***
/// *동작 : 기본 클릭 시 어드민에서 설정한 기여 POP 만큼 내 POP 차감 후 행성 POP 에 추가 ·*
/// ***2초 롱프레스 시 옵션으로 전환***
///
/// 차감 수치가 **어드민 설정값**이라는 것이 명세에 박혀 있다. 화면에 상수로 박지
/// 않고 [zapPop] 으로 받는다 — 정책 설계서 §P-08 의 Admin 운영값이다.
class ZapButton extends StatefulWidget {
const ZapButton({
super.key,
required this.zapPop,
required this.myPop,
required this.onZap,
this.blockedReason,
this.isHeroTime = false,
this.onContribute,
this.onItem,
this.round = false,
this.showLabel = true,
});
/// 한 번에 기여할 POP. Admin 운영값.
final int zapPop;
/// 내 활동 POP 잔액. 모자라면 누를 수 없다.
final int myPop;
/// HERO 참여는 개인 POP 차감이 아니라 선물 수령 화면으로 진입한다.
final bool isHeroTime;
/// 전송 중·소속 없음 등 입력을 막는 사유. Eclipse 방어와는 무관하다.
final String? blockedReason;
final VoidCallback onZap;
final VoidCallback? onContribute;
final VoidCallback? onItem;
/// HOME 의 원형 Zap — Figma 사본 `252:159354` 「Frame 1984081111」(52 원 · 청록→파랑
/// 그라디언트 · 번개 + 「기여」). 옵션은 위에 기여, 아래 줄에 닫기·아이템(`Frame 1984081131`).
final bool round;
/// 원형 기본 상태의 「기여」 라벨. 성운 상태 사본(`344:213778`)은 번개만 있다.
final bool showLabel;
/// 명세의 「2초간 롱프레싱」.
static const longPress = Duration(seconds: 2);
@override
State<ZapButton> createState() => _ZapButtonState();
}
class _ZapButtonState extends State<ZapButton> {
bool _optionsOpen = false;
@override
void didUpdateWidget(ZapButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isHeroTime != widget.isHeroTime) _optionsOpen = false;
}
bool get _canZap =>
widget.blockedReason == null &&
(widget.isHeroTime || widget.myPop >= widget.zapPop);
@override
Widget build(BuildContext context) => AnimatedSize(
duration: const Duration(milliseconds: 180),
alignment: Alignment.bottomCenter,
child: widget.round
? (_optionsOpen ? _roundOptions(context) : _roundBasic(context))
: (_optionsOpen ? _options(context) : _basic(context)),
);
Widget _roundBasic(BuildContext context) => RawGestureDetector(
gestures: {
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(duration: ZapButton.longPress),
(recognizer) =>
recognizer.onLongPress = () =>
setState(() => _optionsOpen = true),
),
},
// Tooltip 은 쓰지 않는다 — 자체 길게 누르기가 2초 옵션 전환을 가로챈다.
child: Semantics(
label: _canZap
? (widget.isHeroTime
? 'ZAP+ · HERO TIME'
: 'ZAP ${widget.zapPop} POP')
: (widget.blockedReason ?? context.l10n.zapNotEnoughPop),
child: Opacity(
opacity: _canZap ? 1 : 0.4,
child: _RoundZap(
icon: const ImageIcon(_bolt, size: 20, color: AppColors.onCosmic),
label: widget.isHeroTime ? 'ZAP+' : context.l10n.commonContribute,
fill: _zapGradient,
glow: true,
onTap: _canZap ? widget.onZap : null,
showLabel: widget.showLabel,
),
),
),
);
Widget _roundOptions(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
_RoundZap(
icon: const ImageIcon(_bolt, size: 20, color: AppColors.onCosmic),
label: context.l10n.commonContribute,
fill: _zapGradient,
glow: true,
onTap: widget.onContribute,
),
const SizedBox(height: AppSpace.s5),
// 사본의 왼쪽 「자동」 자리는 비운다(클래스 주석 — 별첨 B 에 없는 기능).
Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 52 + AppSpace.s6),
_RoundZap(
icon: const ImageIcon(
AppIcons.x,
size: 24,
color: AppColors.onCosmic,
),
label: context.l10n.commonClose,
showLabel: false,
ring: true,
onTap: () => setState(() => _optionsOpen = false),
),
const SizedBox(width: AppSpace.s6),
_RoundZap(
icon: const ImageIcon(_item, size: 20, color: AppColors.onCosmic),
label: context.l10n.commonItem,
onTap: widget.onItem,
),
],
),
],
);
Widget _basic(BuildContext context) => RawGestureDetector(
gestures: {
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(duration: ZapButton.longPress),
(recognizer) =>
recognizer.onLongPress = () =>
setState(() => _optionsOpen = true),
),
},
child: FilledButton.icon(
onPressed: _canZap ? widget.onZap : null,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
),
icon: const Icon(Icons.bolt),
label: Text(
_canZap
? widget.isHeroTime
? 'ZAP+ · HERO TIME'
: 'ZAP ${widget.zapPop} POP'
: (widget.blockedReason ?? context.l10n.zapNotEnoughPop),
),
),
);
Widget _options(BuildContext context) => Container(
padding: const EdgeInsets.all(AppSpace.s2),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceRaised,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.cosmicOutline),
),
child: Row(
children: [
_Option(
icon: Icons.volunteer_activism_outlined,
label: context.l10n.commonContribute,
onTap: widget.onContribute,
),
_Option(
icon: Icons.card_giftcard_outlined,
label: context.l10n.commonItem,
onTap: widget.onItem,
),
_Option(
icon: Icons.close,
label: context.l10n.commonClose,
onTap: () => setState(() => _optionsOpen = false),
),
],
),
);
}
class _Option extends StatelessWidget {
const _Option({required this.icon, required this.label, this.onTap});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
constraints: const BoxConstraints(minHeight: AppSizing.touchMinApp),
padding: const EdgeInsets.symmetric(vertical: AppSpace.s2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: AppColors.onCosmic),
const SizedBox(height: 2),
Text(label, style: t.labelSmall),
],
),
),
),
);
}
}
/// 원형 Zap 한 칸 — 52 원 · 패딩 8 · 아이콘 20 위 10pt 라벨(42dot Medium).
class _RoundZap extends StatelessWidget {
const _RoundZap({
required this.icon,
required this.label,
this.onTap,
this.fill,
this.glow = false,
this.ring = false,
this.showLabel = true,
});
final Widget icon;
final String label;
final VoidCallback? onTap;
final Gradient? fill;
final bool glow;
final bool ring;
final bool showLabel;
@override
Widget build(BuildContext context) => Semantics(
button: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: fill,
color: fill == null && !ring ? const Color(0xFF050505) : null,
border: ring ? Border.all(color: const Color(0xFF4F98C8)) : null,
boxShadow: glow
? const [BoxShadow(color: Color(0xFF547BD4), blurRadius: 6)]
: null,
),
alignment: Alignment.center,
child: showLabel
? Column(
mainAxisSize: MainAxisSize.min,
children: [
icon,
Text(
label,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 10,
fontWeight: FontWeight.w500,
height: 1.6,
color: AppColors.onCosmic,
),
),
],
)
: icon,
),
),
);
}