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 createState() => _ZapButtonState(); } class _ZapButtonState extends State { 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(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(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, ), ), ); }