1290 lines
47 KiB
Dart
1290 lines
47 KiB
Dart
import 'dart:async';
|
||
import '../../../core/ui/planet_fx_scope.dart';
|
||
import '../../galaxy/galaxy_data.dart';
|
||
import '../../galaxy/zap_controller.dart';
|
||
import '../../galaxy/widgets/zap_button.dart';
|
||
import '../../galaxy/widgets/zap_feedback.dart';
|
||
import '../../../core/ui/live_stream.dart';
|
||
import '../../../core/ui/hero_time_status.dart';
|
||
import 'growth_conditions.dart';
|
||
|
||
import 'dart:math' as math;
|
||
import 'dart:ui' show ImageFilter;
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../../core/theme/tokens.dart';
|
||
import '../../../core/ui/app_icons.dart';
|
||
import '../../../core/ui/live_planet.dart';
|
||
import '../../../core/ui/planet_25d.dart' show planetFallbackImage;
|
||
import '../../../core/data/planet_visual_state.dart';
|
||
import '../home_data.dart';
|
||
import '../home_screen.dart' show formatPop;
|
||
import '../../../core/i18n/app_locale.dart';
|
||
import '../../../core/ui/fx_intensity.dart';
|
||
|
||
/// 홈 상단 히어로 — Figma 사본 `252:159354` 홈의 `Frame 1984081117`.
|
||
///
|
||
/// 배치는 사본의 기본 상태 변형(`343:205364` 등 ZAP 이 닫힌 홈)을 따른다. 배경은 상태
|
||
/// 표시줄 포함 680 높이, 그 아래(124~680)에 「아바타·문구 → 48 → 행성 212 → 48 → 성장
|
||
/// 카드」를 세로 가운데로 쌓는다(`Frame 1984081116` · gap 48 · CENTER). 원형 ZAP 은 카드
|
||
/// 아래 변에 중심을 걸치므로 히어로 상자를 배경보다 24 길게 잡아 누를 자리를 넣는다.
|
||
///
|
||
/// 문구 규칙(Figma 명세) — *「{닉네임} 님의」 작게 · 「{POP} POP 기여로 / 꾸준하게
|
||
/// 성장하고 있어요.」 크게 두 줄*. 텍스트는 왼쪽 정렬, 행성은 가운데 정렬이다.
|
||
class HomeHero extends StatefulWidget {
|
||
const HomeHero({
|
||
super.key,
|
||
required this.growth,
|
||
this.fandomId = '',
|
||
this.watchPlanet = noPlanetWatch,
|
||
this.onHeroParticipate,
|
||
this.onOpenHistory,
|
||
this.onOpenPlanet,
|
||
this.onTapPlanet,
|
||
this.zap,
|
||
this.zapData,
|
||
this.ticker,
|
||
this.onBirth,
|
||
this.onCreateNebula,
|
||
this.onJoin,
|
||
this.unjoined = false,
|
||
this.onItem,
|
||
this.miniMap,
|
||
this.zapButtonKey,
|
||
this.eclipseButton,
|
||
});
|
||
|
||
/// 이클립스 이벤트 버튼(`516:88124`). 경보 단계부터 행성 아래 가운데에 뜬다.
|
||
final Widget? eclipseButton;
|
||
|
||
/// 화면 하단에 떠 있는 ZAP 버튼([HomeZap])의 키 — 기여 연출이 이 자리에서 행성으로 날아간다.
|
||
final GlobalKey? zapButtonKey;
|
||
|
||
final VoidCallback? onOpenHistory;
|
||
|
||
/// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다.
|
||
final VoidCallback? onOpenPlanet;
|
||
|
||
/// 행성 본체 터치 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번, 커뮤니티 이동」.
|
||
final VoidCallback? onTapPlanet;
|
||
final String fandomId;
|
||
final PlanetWatch watchPlanet;
|
||
final VoidCallback? onHeroParticipate;
|
||
|
||
final PlanetGrowth growth;
|
||
final ZapController? zap;
|
||
final GalaxySnapshot? zapData;
|
||
|
||
/// 헤더 바로 아래(y102 · 높이 22) 라이브 띠. 없으면 자리도 비운다.
|
||
final Widget? ticker;
|
||
|
||
/// 성운 상태에서 성운을 누를 때 — 행성 생성(F1-04)으로 간다. 전환 조건(B-10)을 둘 다
|
||
/// 채웠을 때만 불린다.
|
||
final VoidCallback? onBirth;
|
||
|
||
/// 소속이 없는 첫 사용자 홈(F1-05)의 「+」 시트 — 행성 생성(성운) · 행성 가입.
|
||
final VoidCallback? onCreateNebula;
|
||
final VoidCallback? onJoin;
|
||
|
||
/// 소속 팬덤이 없는 첫 사용자 상태(`343:212939`) — [HomeSnapshot.fandomId] 가 빈 경우.
|
||
final bool unjoined;
|
||
|
||
/// ZAP 옵션 「아이템」 — 사본 「아이템 선택」 시트(`343:204868`).
|
||
final VoidCallback? onItem;
|
||
|
||
/// GALAXY 미니맵 — 히어로 우측 상단 [miniMapTop] · 오른쪽 끝(여백 0).
|
||
final Widget? miniMap;
|
||
|
||
/// 미니맵 자리(C-113 「최신 확정 HOME Figma 위치」). Figma `89rQgL1JEFlA8yxl9L77bS`
|
||
/// 「홈」(`2097:16428`) 375pt 실측 — 헤더(y0~124) 바로 아래 히어로 본문 시작점, 인사 문구
|
||
/// (「1,230 POP 기여로…」) 줄과 같은 높이, 오른쪽 끝에 붙음(여백 0).
|
||
static const Alignment miniMapAlignment = Alignment.topRight;
|
||
|
||
/// 미니맵 위 변 — 상태 표시줄 아래 기준 사본 y(히어로 본문 `Frame` y124).
|
||
static const double miniMapTop = 124;
|
||
|
||
/// 미니맵 한 변 — Figma 「홈」(`2097:16428`) 실측 약 93×90pt. C-113(위치·크기 = Figma)을
|
||
/// C-105 「약 140pt」보다 우선한다(2026-10-05 대표 결정).
|
||
static const double miniMapSize = 93;
|
||
|
||
/// 사본 기준 배경 높이(상태 표시줄 48 포함).
|
||
static const double designHeight = 680;
|
||
|
||
/// 히어로 아래 여백 — 사본 `Frame 4241` 위 패딩 24 에서 첫 구획 머리의 터치 여유 12 를 뺀 값.
|
||
/// (ZAP 은 화면 하단에 떠 있어 더는 히어로에 걸치지 않는다.)
|
||
static const double bottomGap = 12;
|
||
|
||
@override
|
||
State<HomeHero> createState() => _HomeHeroState();
|
||
}
|
||
|
||
class _HomeHeroState extends State<HomeHero> {
|
||
final _fxKey = GlobalKey();
|
||
final _planetKey = GlobalKey();
|
||
PlanetGrowth get growth => widget.growth;
|
||
String get fandomId => widget.fandomId;
|
||
PlanetWatch get watchPlanet => widget.watchPlanet;
|
||
VoidCallback? get onHeroParticipate => widget.onHeroParticipate;
|
||
|
||
/// [key] 의 가운데를 히어로 좌표로. ZAP 버튼은 히어로 밖(화면 하단 고정)이라 전역 좌표로 잰다.
|
||
Offset _center(GlobalKey? key) {
|
||
final box = key?.currentContext?.findRenderObject();
|
||
final root = _fxKey.currentContext?.findRenderObject();
|
||
return box is RenderBox && box.hasSize && root is RenderBox && root.hasSize
|
||
? box.localToGlobal(box.size.center(Offset.zero)) -
|
||
root.localToGlobal(Offset.zero)
|
||
: Offset.zero;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 사본 좌표는 상태 표시줄 48 을 포함한다. 기기 상단 여백으로 바꿔 쓴다.
|
||
final top = MediaQuery.paddingOf(context).top - 48;
|
||
final background = top + HomeHero.designHeight;
|
||
const planet = 212.0;
|
||
final hasZap =
|
||
widget.zap != null &&
|
||
widget.zapData?.joined == true &&
|
||
widget.zapData?.mine?.id == fandomId;
|
||
|
||
return PlanetFxScope(
|
||
child: SizedBox(
|
||
height: background + HomeHero.bottomGap,
|
||
child: Stack(
|
||
key: _fxKey,
|
||
children: [
|
||
// 1. 우주 배경 — 시안 `252:159356`. 아래로 갈수록 화면 배경색으로 잠긴다.
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: background,
|
||
child: LivePlanetLighting(
|
||
fandomId: fandomId,
|
||
watch: watchPlanet,
|
||
child: Image.asset(
|
||
'assets/figma/bg-space-home.png',
|
||
fit: BoxFit.cover,
|
||
alignment: Alignment.topCenter,
|
||
),
|
||
),
|
||
),
|
||
|
||
// 2. 문구 · 행성 · 성장 카드 — `Frame 1984081116`(y124~680, gap 48).
|
||
// 사본은 가운데 정렬이지만 카드가 사본(151)보다 한 줄 길어 가운데로 두면
|
||
// 문구가 위로 밀린다. 사본 좌표(문구 y130 · 행성 y263)에 고정해 위에서 쌓는다.
|
||
Positioned(
|
||
top: top + 124,
|
||
left: AppSpace.s4,
|
||
right: AppSpace.s4,
|
||
height: HomeHero.designHeight - 124,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 첫 사용자 홈(`343:212939`)은 아바타가 y124, 나머지 상태는 y130.
|
||
if (!widget.unjoined) const SizedBox(height: 6),
|
||
// 미니맵(오른쪽 위 [HomeHero.miniMapSize])과 같은 높이라 그 폭만큼 비운다 — 긴 언어(ja ·
|
||
// es)의 제목이 미니맵 아래로 들어가 잘리지 않고 줄바꿈된다(10/8 r11).
|
||
Padding(
|
||
padding: EdgeInsets.only(
|
||
right: widget.miniMap == null
|
||
? 0
|
||
: HomeHero.miniMapSize - AppSpace.s4 + AppSpace.s2,
|
||
),
|
||
child: _Contribution(
|
||
growth: growth,
|
||
joined: !widget.unjoined,
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpace.s7),
|
||
// HERO TIME 안내·큰 글자처럼 아래가 길어지면 행성이 먼저 줄어든다.
|
||
Flexible(
|
||
child: LayoutBuilder(
|
||
builder: (context, box) => SizedBox(
|
||
width: double.infinity,
|
||
height: math.min(planet, box.maxHeight),
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Positioned.fill(
|
||
child: widget.unjoined
|
||
? _EmptyPlanet(
|
||
onCreateNebula: widget.onCreateNebula,
|
||
onJoin: widget.onJoin,
|
||
)
|
||
: growth.isNebula
|
||
? _Nebula(
|
||
key: _planetKey,
|
||
// 탄생은 창립자만(Q-PN-01 A안) — 다른 멤버에게는 안내 · 탭이 없다.
|
||
ready:
|
||
growth.nebulaReady &&
|
||
growth.isFounder,
|
||
onBirth: widget.onBirth,
|
||
)
|
||
: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Positioned.fill(
|
||
child: _PlanetReaction(
|
||
onTap: widget.onTapPlanet,
|
||
zap: hasZap ? widget.zap : null,
|
||
planetSize: math.min(
|
||
planet,
|
||
box.maxHeight,
|
||
),
|
||
builder: (spinBoost) => LivePlanet25D(
|
||
onOpenHistory:
|
||
widget.onOpenHistory,
|
||
key: _planetKey,
|
||
fandomId: fandomId,
|
||
watch: watchPlanet,
|
||
stage: growth.stage,
|
||
// 형태 없는 옛 행성은 다른 화면과 같은 그림(10/6 C-5).
|
||
image: planetFallbackImage(
|
||
fandomId,
|
||
),
|
||
size: math.min(
|
||
planet,
|
||
box.maxHeight,
|
||
),
|
||
glow: AppColors.neonViolet,
|
||
spinBoost: spinBoost,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 이클립스 이벤트 버튼 — 명세 `516:88125` 「성운(행성) 아래 중앙 정렬」.
|
||
// 사본 `Group 563` 은 행성 상자(212) 안 아래쪽에 겹쳐 놓인다.
|
||
if (widget.eclipseButton case final button?)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: Center(child: button),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (!widget.unjoined) ...[
|
||
const SizedBox(height: AppSpace.s7),
|
||
HeroTimeStatus(
|
||
fandomId: fandomId,
|
||
watch: watchPlanet,
|
||
onParticipate: hasZap ? null : onHeroParticipate,
|
||
),
|
||
_GrowthOverlayCard(
|
||
growth: growth,
|
||
onOpen: widget.onOpenPlanet,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
|
||
if (widget.miniMap case final mini?)
|
||
Positioned(top: top + HomeHero.miniMapTop, right: 0, child: mini),
|
||
|
||
if (widget.ticker case final ticker?)
|
||
Positioned(top: top + 102, left: 0, right: 0, child: ticker),
|
||
|
||
if (widget.zap case final zap?)
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: ZapFeedback(
|
||
fandomName: widget.growth.fandomName,
|
||
quest: zap.questReaction,
|
||
messageAboveTarget: true,
|
||
touchSerial: zap.touchSerial,
|
||
pending: zap.pending > 0 || zap.sending,
|
||
result: zap.result,
|
||
source: _center(widget.zapButtonKey),
|
||
target: _center(_planetKey),
|
||
radius:
|
||
(_planetKey.currentContext?.findRenderObject()
|
||
is RenderBox
|
||
? (_planetKey.currentContext!.findRenderObject()
|
||
as RenderBox)
|
||
.size
|
||
.shortestSide
|
||
: planet) *
|
||
0.4,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 「BTS_Hong 님의 / 1,230 POP 기여로 꾸준하게 성장하고 있어요.」 — `Frame 1984081113`.
|
||
///
|
||
/// 아바타 26 원 + 9 + 닉네임(ABeeZee 12 흰색) 2 「님의」(42dot 12 #A3A2A0), 3 아래 문구
|
||
/// (42dot Medium 20 · 행간 140%). 아바타 줄에는 검정 70% 4 그림자.
|
||
class _Contribution extends StatelessWidget {
|
||
const _Contribution({required this.growth, required this.joined});
|
||
|
||
final PlanetGrowth growth;
|
||
|
||
/// 소속 팬덤이 있는가. 없으면 첫 사용자 문구(`343:212939`)를 쓴다.
|
||
final bool joined;
|
||
|
||
static const _ownerAffix = TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.3,
|
||
letterSpacing: -0.3,
|
||
color: AppColors.onCosmicVariant,
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final owner = context.l10n.heroOwner('\u0000').split('\u0000');
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 「{닉네임} 님의」는 로그인 사용자의 인사 줄이다(설명 프레임 `2097:16391`).
|
||
if (growth.contributorName.trim().isNotEmpty) ...[
|
||
Row(
|
||
children: [
|
||
// 로그인 사용자의 프로필 사진(`2097:16391` 「프로필 - 이미지」). 없으면 이니셜.
|
||
Container(
|
||
width: 26,
|
||
height: 26,
|
||
alignment: Alignment.center,
|
||
clipBehavior: Clip.antiAlias,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
// 사본 `Ellipse 3` #9BF8A8(프로필 사진이 없을 때의 바탕).
|
||
color: const Color(0xFF9BF8A8),
|
||
boxShadow: const [
|
||
BoxShadow(color: Color(0xB3000000), blurRadius: 4),
|
||
],
|
||
image: growth.photoUrl == null
|
||
? null
|
||
: DecorationImage(
|
||
image: NetworkImage(growth.photoUrl!),
|
||
fit: BoxFit.cover,
|
||
onError: (_, _) {},
|
||
),
|
||
),
|
||
child: growth.photoUrl != null
|
||
? null
|
||
: Text(
|
||
growth.contributorName.characters.firstOrNull ?? '—',
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: Colors.black,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 9),
|
||
// 「님의」는 언어마다 이름 앞(es 「De」) 또는 뒤(ko 「님의」 · ja 「さんの」)에 붙는다 — 문구
|
||
// 하나({name} 자리표시)를 이름 자리에서 갈라 앞뒤를 흐린 색으로 그린다(10/8 r11 es 오역).
|
||
if (owner.first.isNotEmpty) Text(owner.first, style: _ownerAffix),
|
||
Flexible(
|
||
child: Text(
|
||
growth.contributorName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.display,
|
||
fontSize: 12,
|
||
height: 1.3,
|
||
letterSpacing: -0.3,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
),
|
||
if (owner.last.isNotEmpty) Text(owner.last, style: _ownerAffix),
|
||
],
|
||
),
|
||
const SizedBox(height: 3),
|
||
],
|
||
Text(
|
||
// 사본 문구의 「팬케이크」는 사용자 노출 브랜드라 「팬우주」로 바꾼다(공통 기준 4항).
|
||
!joined
|
||
? context.l10n.heroStartPrompt
|
||
: growth.isNebula
|
||
? context.l10n.heroNebulaPrompt
|
||
: growth.hasRecentContribution
|
||
? context.l10n.heroRecentContribution(
|
||
formatPop(growth.contributedPop),
|
||
)
|
||
: context.l10n.heroGrowingTogether,
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.4,
|
||
letterSpacing: -0.5,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 성장 카드 — Figma 「홈」 `Frame 1984081073`(343×151 · `#040304` 40% · r12 · 패딩 12/16 ·
|
||
/// 간격 12 · 유리 흐림 4): 「BTS ›」(ABeeZee 32 · chevron 24 #777777) / 「행성 성장률 67%」
|
||
/// (14/500 · 행간 24) / 진행 바 10 / 「6,270 / 10,000」(14/500). 말풍선(`Group 563` 168×40)
|
||
/// 은 바 채움 끝 위에 뜬다.
|
||
///
|
||
/// 성장률 · 수치는 두 조건 중 병목 축([PlanetGrowth.bottleneck])이다. 말풍선을 누르면 두
|
||
/// 조건 상세([GrowthConditions])를 연다.
|
||
class _GrowthOverlayCard extends StatelessWidget {
|
||
const _GrowthOverlayCard({required this.growth, this.onOpen});
|
||
|
||
final PlanetGrowth growth;
|
||
final VoidCallback? onOpen;
|
||
|
||
static const _white14 = TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
height: 24 / 14,
|
||
color: AppColors.onCosmic,
|
||
);
|
||
|
||
/// 말풍선과 성장률 글자 사이 최소 간격.
|
||
static const _bubbleGap = 8.0;
|
||
|
||
static double _textWidth(
|
||
BuildContext context,
|
||
String text,
|
||
TextStyle style,
|
||
) =>
|
||
(TextPainter(
|
||
text: TextSpan(text: text, style: style),
|
||
textDirection: Directionality.of(context),
|
||
textScaler: MediaQuery.textScalerOf(context),
|
||
maxLines: 1,
|
||
)..layout()).width.ceilToDouble() +
|
||
1;
|
||
|
||
String _value(num v) =>
|
||
v == v.roundToDouble() ? formatPop(v.toInt()) : v.toStringAsFixed(1);
|
||
|
||
void _details(BuildContext context) => showModalBottomSheet<void>(
|
||
context: context,
|
||
useRootNavigator: true,
|
||
backgroundColor: AppColors.cosmicSheet,
|
||
builder: (context) => SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
AppSpace.s4,
|
||
AppSpace.s5,
|
||
AppSpace.s4,
|
||
AppSpace.s5,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
context.l10n.growthConditionsTitle,
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
const SizedBox(height: AppSpace.s3),
|
||
GrowthConditions(growth: growth, style: _white14),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final low = growth.bottleneck;
|
||
final ratio = low?.ratio;
|
||
final percent = ratio == null ? '—' : '${(ratio * 100).round()}';
|
||
final rate = growth.isNebula
|
||
? context.l10n.heroNebulaGrowthRate(percent)
|
||
: context.l10n.heroGrowthRate(percent);
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpace.s4,
|
||
vertical: AppSpace.s3,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0x66040304),
|
||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||
),
|
||
child: LayoutBuilder(
|
||
builder: (context, box) => Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onOpen,
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
growth.fandomName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.display,
|
||
fontSize: 32,
|
||
height: 1.4,
|
||
letterSpacing: -0.8,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
),
|
||
if (onOpen != null)
|
||
const ImageIcon(
|
||
AppIcons.chevronRight,
|
||
size: 24,
|
||
color: Color(0xFF777777),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 성장률 · 바 · 수치를 누르면 두 조건 상세 — 말풍선이 없는 성운 카드의 입구.
|
||
GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => _details(context),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(rate, style: _white14),
|
||
const SizedBox(height: AppSpace.s3),
|
||
_Bar(ratio: ratio ?? 0),
|
||
const SizedBox(height: AppSpace.s3),
|
||
Text(
|
||
low == null
|
||
? '— / —'
|
||
: '${_value(low.current)} / ${_value(low.required)}',
|
||
style: _white14,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
// 말풍선 — 사본 y566.5(카드 안 31.5) · 꼬리 끝이 바 위에 닿는다. 가운데는 채움 끝
|
||
// (사본 x238 = 채움 206)을 따르되 왼쪽 성장률 글자와 겹치지 않게 민다. 두 글자 폭은
|
||
// 언어마다 달라 재서 정한다 — 남는 폭이 모자라면 말풍선 글자를 줄인다(10/8 r11 es).
|
||
// 성운 카드(`344:213809`)에는 말풍선이 없다.
|
||
if (!growth.isNebula)
|
||
Builder(
|
||
builder: (context) {
|
||
final left =
|
||
_textWidth(context, rate, _white14) + _bubbleGap;
|
||
final width = math.min(
|
||
_textWidth(
|
||
context,
|
||
growth.growthBubble,
|
||
_SpeechBubble.style,
|
||
) +
|
||
_SpeechBubble.padding * 2,
|
||
math.max(0.0, box.maxWidth - left),
|
||
);
|
||
return Positioned(
|
||
top: 31.5,
|
||
left: (box.maxWidth * (ratio ?? 0) - width / 2).clamp(
|
||
math.min(left, box.maxWidth - width),
|
||
box.maxWidth - width,
|
||
),
|
||
width: width,
|
||
child: Semantics(
|
||
button: true,
|
||
child: GestureDetector(
|
||
onTap: () => _details(context),
|
||
child: _SpeechBubble(text: growth.growthBubble),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 진행 바 — 10 높이 · 바탕 검정 30% · 채움 `#D496C4 → #FF7DDD`.
|
||
class _Bar extends StatelessWidget {
|
||
const _Bar({required this.ratio});
|
||
|
||
final double ratio;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
height: 10,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0x4D000000),
|
||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||
),
|
||
alignment: Alignment.centerLeft,
|
||
child: FractionallySizedBox(
|
||
widthFactor: ratio,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||
gradient: const LinearGradient(
|
||
colors: [Color(0xFFD496C4), Color(0xFFFF7DDD)],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 말풍선 — 사본 `Union`(검정 80% · 본체 30 + 아래 꼬리 10) · 42dot Medium 12 흰색.
|
||
class _SpeechBubble extends StatelessWidget {
|
||
const _SpeechBubble({required this.text});
|
||
|
||
final String text;
|
||
|
||
static const _fill = Color(0xCC000000);
|
||
|
||
static const padding = 10.0;
|
||
|
||
static const style = TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1,
|
||
color: AppColors.onCosmic,
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
height: 30,
|
||
padding: const EdgeInsets.symmetric(horizontal: padding),
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: _fill,
|
||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||
),
|
||
child: Text(
|
||
text,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: style,
|
||
),
|
||
),
|
||
CustomPaint(
|
||
size: const Size(10, 10),
|
||
painter: _TailPainter(color: _fill),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
class _TailPainter extends CustomPainter {
|
||
const _TailPainter({required this.color, this.up = false});
|
||
final Color color;
|
||
|
||
/// 위를 가리키는 꼬리(말풍선이 대상 아래에 있을 때).
|
||
final bool up;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final path = up
|
||
? (Path()
|
||
..moveTo(0, size.height)
|
||
..lineTo(size.width, size.height)
|
||
..lineTo(size.width / 2, 0)
|
||
..close())
|
||
: (Path()
|
||
..moveTo(0, 0)
|
||
..lineTo(size.width, 0)
|
||
..lineTo(size.width / 2, size.height)
|
||
..close());
|
||
canvas.drawPath(path, Paint()..color = color);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_TailPainter old) => old.color != color || old.up != up;
|
||
}
|
||
|
||
/// 성운 — 사본 `홈_성장 완료`(`344:213808` 212). 두 전환 조건을 채우면 아래 말풍선
|
||
/// 「성운을 클릭해서 / 행성으로 탄생 시켜보세요!」(`Group 563` 181×53 · 꼬리 위)가 뜨고
|
||
/// 누르면 행성 생성으로 간다. 채우기 전에는 눌러도 아무 일이 없다(B-10 동시 충족).
|
||
class _Nebula extends StatelessWidget {
|
||
const _Nebula({super.key, required this.ready, this.onBirth});
|
||
|
||
final bool ready;
|
||
final VoidCallback? onBirth;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Center(
|
||
child: SizedBox(
|
||
width: 212,
|
||
height: 212,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
alignment: Alignment.topCenter,
|
||
children: [
|
||
// 사본 `Ellipse 40~45`(74×71 #D9D9D9) — 성운 뒤 왼쪽에 걸친 흰 원반.
|
||
Positioned(
|
||
left: -1,
|
||
top: 69.5,
|
||
child: Container(
|
||
width: 74,
|
||
height: 71,
|
||
decoration: const ShapeDecoration(
|
||
shape: OvalBorder(),
|
||
color: Color(0xFFD9D9D9),
|
||
),
|
||
),
|
||
),
|
||
Semantics(
|
||
button: ready && onBirth != null,
|
||
label: context.l10n.heroNebula,
|
||
// Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고,
|
||
// 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다.
|
||
child: _PlanetReaction(
|
||
onTap: ready && onBirth != null ? onBirth : () {},
|
||
planetSize: 212,
|
||
builder: (_) => Image.asset(
|
||
'assets/figma/nebula-home.png',
|
||
width: 212,
|
||
height: 212,
|
||
),
|
||
),
|
||
),
|
||
if (ready)
|
||
Positioned(
|
||
top: 177,
|
||
child: IgnorePointer(
|
||
child: Column(
|
||
children: [
|
||
CustomPaint(
|
||
size: const Size(12, 9),
|
||
painter: _TailPainter(
|
||
color: const Color(0xCC000000),
|
||
up: true,
|
||
),
|
||
),
|
||
Container(
|
||
width: 181,
|
||
height: 44,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xCC000000),
|
||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||
),
|
||
child: Text(
|
||
context.l10n.heroNebulaTap,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.4,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 소속이 없는 첫 사용자 — 사본 `홈_초기사용자`(`Frame 1984081310`): 검정 원 190 안에
|
||
/// 검정 40 상자(r8 · 분홍 빛 4) + 「+」. 누르면 「행성 생성 (성운) / 행성 가입 / 취소」 시트.
|
||
class _EmptyPlanet extends StatelessWidget {
|
||
const _EmptyPlanet({this.onCreateNebula, this.onJoin});
|
||
|
||
final VoidCallback? onCreateNebula;
|
||
final VoidCallback? onJoin;
|
||
|
||
Future<void> _open(BuildContext context) async {
|
||
final pick = await showModalBottomSheet<VoidCallback>(
|
||
context: context,
|
||
useRootNavigator: true,
|
||
backgroundColor: Colors.transparent,
|
||
barrierColor: const Color(0xB3000000),
|
||
builder: (context) => _StartSheet(
|
||
options: [
|
||
(context.l10n.heroCreateNebula, onCreateNebula),
|
||
(context.l10n.heroJoinPlanet, onJoin),
|
||
],
|
||
),
|
||
);
|
||
pick?.call();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Center(
|
||
child: Container(
|
||
width: 190,
|
||
height: 190,
|
||
alignment: Alignment.center,
|
||
decoration: const BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Color(0xB3000000), // `Ellipse 20` 검정 · 투명도 0.7
|
||
),
|
||
child: Semantics(
|
||
button: true,
|
||
label: context.l10n.heroCreateOrJoin,
|
||
child: GestureDetector(
|
||
onTap: () => _open(context),
|
||
// `Frame 1984081024` 에셋(40 상자 + 분홍 빛 4 여백 + 「+」).
|
||
child: Image.asset(
|
||
'assets/figma/home-plus-box.png',
|
||
width: 48,
|
||
height: 48,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 사본 `Frame 1984081055` — 좌우·아래 10 띄운 #2C2C2E r16 시트, 손잡이 36×4, 줄 높이 47.
|
||
class _StartSheet extends StatelessWidget {
|
||
const _StartSheet({required this.options});
|
||
|
||
final List<(String, VoidCallback?)> options;
|
||
|
||
static const _item = TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.3,
|
||
color: AppColors.onCosmic,
|
||
);
|
||
|
||
@override
|
||
// 사본은 홈 인디케이터 위가 아니라 화면 아래에서 10 띄운다.
|
||
Widget build(BuildContext context) => SafeArea(
|
||
bottom: false,
|
||
child: Container(
|
||
margin: const EdgeInsets.fromLTRB(10, 0, 10, 10),
|
||
padding: const EdgeInsets.fromLTRB(AppSpace.s4, 12, AppSpace.s4, 12),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cosmicSheet,
|
||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Center(
|
||
child: Container(
|
||
width: 36,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF4F4F4F),
|
||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
for (final (label, action) in options)
|
||
InkWell(
|
||
onTap: () => Navigator.of(context).pop(action),
|
||
child: SizedBox(
|
||
height: 47,
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(label, style: _item),
|
||
),
|
||
),
|
||
),
|
||
InkWell(
|
||
onTap: () => Navigator.of(context).pop(),
|
||
child: SizedBox(
|
||
height: 47,
|
||
child: Center(
|
||
child: Text(context.l10n.commonCancel, style: _item),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 「GALAXY 맵 확장」 히어로 — Figma 「홈_초기사용자」 `343:211127`.
|
||
///
|
||
/// 히어로 680(상태 표시줄 48 포함)은 그대로 두고, 그 안 y124~680 을 갤럭시 맵(프로토타입 v6.2
|
||
/// 장면 — `GalaxyWebView` 확장 모드)이 채운다. 프레임 순서대로 라이브 띠(y48 · 22)가 헤더(y70)
|
||
/// 위에 오고, 히어로 아래에 375×54 「GALAXY」 바(ABeeZee 20 · 오른쪽 돋보기 42)가 붙는다.
|
||
/// 돋보기 = 갤럭시 검색(`2008:14560` 「돋보기 - 검색페이지 이동」).
|
||
///
|
||
/// 닫기 컨트롤은 프레임에 없다. 「GALAXY」 글자 탭과 시스템 뒤로가기로 기본 히어로에 돌아간다.
|
||
class HomeGalaxyHero extends StatelessWidget {
|
||
const HomeGalaxyHero({
|
||
super.key,
|
||
required this.map,
|
||
required this.onClose,
|
||
required this.onSearch,
|
||
this.ticker,
|
||
});
|
||
|
||
/// 갤럭시 맵 — HOME 이 미니맵과 같은 `GalaxyWebView` 를 넘긴다(확장 모드).
|
||
final Widget map;
|
||
final VoidCallback onClose;
|
||
final VoidCallback onSearch;
|
||
final Widget? ticker;
|
||
|
||
/// 사본 「GALAXY」 바 높이.
|
||
static const barHeight = 54.0;
|
||
|
||
/// 헤더가 라이브 띠 아래로 내려가는 몫(띠 22).
|
||
static const headerShift = 22.0;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final top = MediaQuery.paddingOf(context).top - 48;
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
SizedBox(
|
||
height: top + HomeHero.designHeight,
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: Image.asset(
|
||
'assets/figma/bg-space-home.png',
|
||
fit: BoxFit.cover,
|
||
alignment: Alignment.topCenter,
|
||
),
|
||
),
|
||
Positioned(
|
||
top: top + 124,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: map,
|
||
),
|
||
if (ticker case final t?)
|
||
Positioned(top: top + 48, left: 0, right: 0, child: t),
|
||
],
|
||
),
|
||
),
|
||
Container(
|
||
height: barHeight,
|
||
color: const Color(0xFF141414),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpace.s4,
|
||
vertical: 6,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
GestureDetector(
|
||
key: const ValueKey('galaxy-bar-close'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onClose,
|
||
child: const Text(
|
||
'GALAXY',
|
||
style: TextStyle(
|
||
fontFamily: AppFonts.display,
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w400,
|
||
height: 1.4,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
),
|
||
const Spacer(),
|
||
IconButton(
|
||
constraints: const BoxConstraints.tightFor(
|
||
width: 42,
|
||
height: 42,
|
||
),
|
||
padding: EdgeInsets.zero,
|
||
tooltip: context.l10n.commonSearch,
|
||
onPressed: onSearch,
|
||
icon: const Icon(
|
||
Icons.search,
|
||
size: 20,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// HOME 의 ZAP — Figma 「홈」 `Frame 1984081111`(52×53 원 · `#4BC2AE → #4F98C8 → #5669DC` ·
|
||
/// `#547BD4` 빛 6 · 번개 하나). 프레임 루트 직속이라 스크롤과 무관하게 하단 탭 위
|
||
/// 가운데에 떠 있다(사본 x162 · 하단 탭 위 16). 명세 「Zap 버튼」 규칙 *화면 하단 위치 고정 ·*
|
||
/// *행성/성운 가입 또는 생성한 유저에게만 노출* — 소속 팬덤이 지도 데이터의 내 행성일 때만.
|
||
class HomeZap extends StatelessWidget {
|
||
const HomeZap({
|
||
super.key,
|
||
required this.buttonKey,
|
||
required this.zap,
|
||
required this.data,
|
||
required this.fandomId,
|
||
required this.watchPlanet,
|
||
this.onHeroParticipate,
|
||
this.onItem,
|
||
});
|
||
|
||
final GlobalKey buttonKey;
|
||
final ZapController zap;
|
||
final GalaxySnapshot data;
|
||
final String fandomId;
|
||
final PlanetWatch watchPlanet;
|
||
final VoidCallback? onHeroParticipate;
|
||
final VoidCallback? onItem;
|
||
|
||
/// 사본 하단 탭 윗변(y2806)과 버튼 아랫변(y2790) 사이.
|
||
static const bottomGap = 16.0;
|
||
|
||
static bool visible(GalaxySnapshot? data, String fandomId) =>
|
||
data != null && data.joined && data.mine?.id == fandomId;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
|
||
key: ValueKey(('home-zap', fandomId)),
|
||
create: () => watchPlanet(fandomId),
|
||
builder: (context, state) {
|
||
final hero =
|
||
state?.isHeroTime == true &&
|
||
state?.heroEndsAt?.isAfter(DateTime.now()) == true &&
|
||
onHeroParticipate != null &&
|
||
zap.pending == 0 &&
|
||
!zap.sending &&
|
||
!zap.needsRetry;
|
||
return Column(
|
||
key: buttonKey,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (zap.needsRetry)
|
||
TextButton(
|
||
onPressed: zap.retry,
|
||
child: Text(context.l10n.zapRetrySame),
|
||
),
|
||
ZapButton(
|
||
round: true,
|
||
// 사본 `2097:16428` 의 떠 있는 ZAP 은 번개만 있다(라벨 없음).
|
||
showLabel: false,
|
||
onItem: onItem,
|
||
zapPop: data.zapPop,
|
||
myPop: (zap.balance ?? data.myPop) - zap.queued * data.zapPop,
|
||
// Figma `516:88102` Zap_버프(아이템 배수 · 지속시간)는 별첨 B B-22 「배수 기능 제거」 ·
|
||
// 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 —
|
||
// 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다.
|
||
isHeroTime: hero,
|
||
// 보내는 중에도 누를 수 있다 — 연타는 다음 묶음으로 쌓인다(Figma `2033:15818` 10회 이상 연타).
|
||
blockedReason: zap.needsRetry ? context.l10n.zapNeedsCheck : null,
|
||
onZap: () => hero ? onHeroParticipate!() : zap.tap(fandomId),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 행성 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · `2033:15818` 행성 리액션 1.
|
||
///
|
||
/// - 행성 터치: Light Haptic · 흔들림 1번이 끝나면 [onTap](커뮤니티 이동).
|
||
/// - GIVE 입력 반응(즉시): 누를 때마다 흔들림 1번, 이어서 10회 이상이면 흔들림 x2 · 자전 1.5배
|
||
/// ([Planet25D.spinBoost]).
|
||
/// - GIVE 결과(서버 확정 뒤, v1.5 §1.2 · §3): 행성 우하단 [+1] 0.5초(연속 가능), 10회 이상 이어진
|
||
/// 연타의 확정이면 [+10].
|
||
class _PlanetReaction extends StatefulWidget {
|
||
const _PlanetReaction({
|
||
required this.builder,
|
||
required this.planetSize,
|
||
this.onTap,
|
||
this.zap,
|
||
});
|
||
|
||
final Widget Function(bool spinBoost) builder;
|
||
final double planetSize;
|
||
final VoidCallback? onTap;
|
||
final ZapController? zap;
|
||
|
||
/// 「10 회 이상 터치시」.
|
||
static const boostStreak = 10;
|
||
|
||
/// 흔들림 한 번 길이.
|
||
static const shakeOnce = Duration(milliseconds: 320);
|
||
|
||
@override
|
||
State<_PlanetReaction> createState() => _PlanetReactionState();
|
||
}
|
||
|
||
class _PlanetReactionState extends State<_PlanetReaction>
|
||
with SingleTickerProviderStateMixin {
|
||
late final _shake = AnimationController(
|
||
vsync: this,
|
||
duration: _PlanetReaction.shakeOnce,
|
||
);
|
||
int _shakes = 1;
|
||
int? _seenSerial;
|
||
int _streak = 0;
|
||
Timer? _countTimer;
|
||
String? _seenTx;
|
||
|
||
/// 확정 결과 표시 — 「+1」 · 「+10」. null 이면 숨김.
|
||
String? _count;
|
||
Timer? _resultTimer;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_seenSerial = widget.zap?.touchSerial;
|
||
_seenTx = widget.zap?.result?.txId;
|
||
widget.zap?.addListener(_onZap);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(_PlanetReaction old) {
|
||
super.didUpdateWidget(old);
|
||
if (old.zap != widget.zap) {
|
||
old.zap?.removeListener(_onZap);
|
||
_seenSerial = widget.zap?.touchSerial;
|
||
_seenTx = widget.zap?.result?.txId;
|
||
widget.zap?.addListener(_onZap);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
widget.zap?.removeListener(_onZap);
|
||
_countTimer?.cancel();
|
||
_resultTimer?.cancel();
|
||
_shake.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onZap() {
|
||
final zap = widget.zap!;
|
||
final tx = zap.result?.txId;
|
||
if (tx != null && tx.isNotEmpty && tx != _seenTx) {
|
||
_seenTx = tx;
|
||
if (!zap.questReaction) {
|
||
setState(() => _count =
|
||
zap.confirmedStreak >= _PlanetReaction.boostStreak ? '+10' : '+1');
|
||
_resultTimer?.cancel();
|
||
_resultTimer = Timer(ZapController.streakWindow, () {
|
||
if (mounted) setState(() => _count = null);
|
||
});
|
||
}
|
||
}
|
||
if (zap.touchSerial == _seenSerial) return;
|
||
_seenSerial = zap.touchSerial;
|
||
if (zap.questReaction) return;
|
||
setState(() => _streak = zap.streak);
|
||
_countTimer?.cancel();
|
||
_countTimer = Timer(ZapController.streakWindow, () {
|
||
if (mounted) setState(() => _streak = 0);
|
||
});
|
||
_play(_streak >= _PlanetReaction.boostStreak ? 2 : 1);
|
||
}
|
||
|
||
TickerFuture? _play(int shakes) {
|
||
// 동작 줄이기면 흔들지 않는다 — 숫자 표시와 이동은 그대로.
|
||
if (MediaQuery.disableAnimationsOf(context)) return null;
|
||
_shakes = shakes;
|
||
_shake.duration = _PlanetReaction.shakeOnce * shakes;
|
||
return _shake.forward(from: 0);
|
||
}
|
||
|
||
bool _opening = false;
|
||
|
||
/// 흔들림 한 번을 보여 준 뒤 이동한다. 그사이 GIVE 가 흔들림을 다시 시작해도 이동은 그대로 간다.
|
||
Future<void> _tap() async {
|
||
final onTap = widget.onTap;
|
||
if (onTap == null || _opening) return;
|
||
_opening = true;
|
||
fxHaptic(FxIntensity.micro);
|
||
if (_play(1) != null) await Future<void>.delayed(_PlanetReaction.shakeOnce);
|
||
_opening = false;
|
||
if (mounted) onTap();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final boost = _streak >= _PlanetReaction.boostStreak;
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.translucent,
|
||
onTap: widget.onTap == null ? null : _tap,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
alignment: Alignment.center,
|
||
children: [
|
||
AnimatedBuilder(
|
||
animation: _shake,
|
||
// 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠. 한 번에 [_shakes] 왕복.
|
||
builder: (context, child) => Transform.translate(
|
||
key: const ValueKey('planet-give-shake'),
|
||
offset: Offset(
|
||
math.sin(_shake.value * math.pi * 4 * _shakes) *
|
||
6 *
|
||
(1 - _shake.value),
|
||
0,
|
||
),
|
||
child: child,
|
||
),
|
||
child: widget.builder(boost),
|
||
),
|
||
if (_count case final count?)
|
||
// 행성 우측 하단 — 행성 그림 상자([planetSize]) 안 오른쪽 아래. x 0.6 은 360 폭에서도 [+10] 이
|
||
// 오른쪽 GALAXY 미니맵에 가려지지 않는 자리다(10/22 iPhone 캡처에서 0.85 는 끝자리가 가려졌다).
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: Center(
|
||
child: SizedBox.square(
|
||
dimension: widget.planetSize,
|
||
child: Align(
|
||
alignment: const Alignment(0.6, 0.85),
|
||
child: Text(
|
||
count,
|
||
key: const ValueKey('planet-give-count'),
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.body,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.onCosmic,
|
||
shadows: [
|
||
Shadow(color: AppColors.neonViolet, blurRadius: 8),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|