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