310 lines
9.9 KiB
Dart
310 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,
|
|
),
|
|
),
|
|
);
|
|
}
|