Files
fanuzu/app/lib/features/home/widgets/home_hero.dart
T

1268 lines
46 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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번 · 행성 우하단 [+1] 0.5초(연속 가능). 이어서 10회 이상이면
/// 흔들림 x2 · 자전 1.5배([Planet25D.spinBoost]) · [+10]. 숫자는 누른 횟수이고 기여 결과는
/// 서버 확정 뒤 [ZapFeedback] 이 따로 띄운다(v1.5 §1.2).
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;
@override
void initState() {
super.initState();
_seenSerial = widget.zap?.touchSerial;
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;
widget.zap?.addListener(_onZap);
}
}
@override
void dispose() {
widget.zap?.removeListener(_onZap);
_countTimer?.cancel();
_shake.dispose();
super.dispose();
}
void _onZap() {
final zap = widget.zap!;
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 (_streak > 0)
// 행성 우측 하단 — 행성 그림 상자([planetSize]) 안 오른쪽 아래.
Positioned.fill(
child: IgnorePointer(
child: Center(
child: SizedBox.square(
dimension: widget.planetSize,
child: Align(
alignment: const Alignment(0.85, 0.85),
child: Text(
boost ? '+10' : '+1',
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),
],
),
),
),
),
),
),
),
],
),
);
}
}