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/data/planet_visual_state.dart'; import '../home_data.dart'; import '../home_screen.dart' show formatPop; import '../../../core/i18n/app_locale.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.zap, this.zapData, this.ticker, this.onBirth, this.onCreateNebula, this.onJoin, this.unjoined = false, this.onItem, this.miniMap, }); final VoidCallback? onOpenHistory; /// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다. final VoidCallback? onOpenPlanet; 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; /// 배경 아래로 ZAP 원이 걸쳐 내려오는 몫. 다음 구획의 위 여백(24)과 같다. static const double zapOverhang = 24; @override State createState() => _HomeHeroState(); } class _HomeHeroState extends State { final _fxKey = GlobalKey(); final _planetKey = GlobalKey(); final _buttonKey = GlobalKey(); PlanetGrowth get growth => widget.growth; String get fandomId => widget.fandomId; PlanetWatch get watchPlanet => widget.watchPlanet; VoidCallback? get onHeroParticipate => widget.onHeroParticipate; Offset _center(GlobalKey key) { final box = key.currentContext?.findRenderObject(); final root = _fxKey.currentContext?.findRenderObject(); return box is RenderBox && box.hasSize && root is RenderBox ? box.localToGlobal(box.size.center(Offset.zero), ancestor: root) : 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.zapOverhang, 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), _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: 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: LivePlanet25D( onOpenHistory: widget.onOpenHistory, key: _planetKey, fandomId: fandomId, watch: watchPlanet, stage: growth.stage, image: 'assets/figma/planet-home.png', size: math.min(planet, box.maxHeight), glow: AppColors.neonViolet, ), ), ], ), ), ), ), if (!widget.unjoined) ...[ const SizedBox(height: AppSpace.s7), HeroTimeStatus( fandomId: fandomId, watch: watchPlanet, onParticipate: hasZap ? null : onHeroParticipate, ), _GrowthOverlayCard( growth: growth, onOpen: widget.onOpenPlanet, zapClearance: hasZap, ), ], ], ), ), 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), // 3. 원형 ZAP — 사본 `Frame 1984081111`. 중심이 카드 아래 변에 걸친다. if (widget.zapData case final data?) if (widget.zap case final zap?) if (data.joined && data.mine?.id == fandomId) Positioned( left: 0, right: 0, bottom: 0, child: LiveStreamBuilder( 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, showLabel: !growth.isNebula, onItem: widget.onItem, zapPop: data.zapPop, myPop: (zap.balance ?? data.myPop) - zap.pending * data.zapPop, isHeroTime: hero, blockedReason: zap.sending ? context.l10n.zapApplying : zap.needsRetry ? context.l10n.zapNeedsCheck : null, onZap: () => hero ? onHeroParticipate!() : zap.tap(fandomId), ), ], ); }, ), ), if (widget.zap case final zap?) Positioned.fill( child: IgnorePointer( child: ZapFeedback( touchSerial: zap.touchSerial, pending: zap.pending > 0 || zap.sending, result: zap.result, source: _center(_buttonKey), 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; @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 성운·첫 사용자 상태의 「{닉네임} 님의」는 기여 여부와 무관한 인사 줄이다. if (growth.hasRecentContribution || ((!joined || growth.isNebula) && growth.contributorName.trim().isNotEmpty)) ...[ Row( children: [ // 프로필 사진 자산이 데이터에 없어 이니셜로 그린다. Container( width: 26, height: 26, alignment: Alignment.center, decoration: const BoxDecoration( shape: BoxShape.circle, color: AppColors.cosmicSurfaceRaised, boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)], ), child: Text( growth.contributorName.characters.firstOrNull ?? '—', style: const TextStyle( fontFamily: AppFonts.body, fontSize: 12, fontWeight: FontWeight.w500, color: AppColors.onCosmic, ), ), ), const SizedBox(width: 9), Text( growth.contributorName, style: const TextStyle( fontFamily: AppFonts.display, fontSize: 12, height: 1.3, letterSpacing: -0.3, color: AppColors.onCosmic, ), ), const SizedBox(width: 2), Text( context.l10n.heroOwnerSuffix, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 12, fontWeight: FontWeight.w500, height: 1.3, letterSpacing: -0.3, color: AppColors.onCosmicVariant, ), ), ], ), 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, ), ), ], ); } /// 성장 카드 — 사본 기본 상태의 「BTS › / 행성 성장률 67% / 진행 바 / 6,270 / 10,000」 /// (`Frame 1984081073` 343 폭 · `#040304` 40% · r12 · 패딩 12/16 · 간격 12 · 유리 흐림 4). /// /// 둘째 줄 「POP 진행률」과 바 아래 두 조건 줄은 정책값이다 — growthPercent 는 POP 진행률 /// 이고(전체 충족률 아님) 승급은 POP·sFPI 동시 충족이다(정책 설계서 §P-08 · UX·FX v1.5 /// §2.1). 말풍선 꼬리는 바 채움 끝을 가리킨다(사본 `Group 563` 가운데 = 채움 끝 x238). class _GrowthOverlayCard extends StatelessWidget { const _GrowthOverlayCard({ required this.growth, this.onOpen, this.zapClearance = false, }); final PlanetGrowth growth; final VoidCallback? onOpen; /// 원형 ZAP(52)이 카드 아래 변에 걸친다. 둘째 조건 줄이 가려지지 않게 아래 여백을 /// ZAP 윗변(아래 변 − 28) 위로 올린다. ZAP 좌표는 사본 그대로 둔다. final bool zapClearance; static const _white14 = TextStyle( fontFamily: AppFonts.body, fontSize: 14, fontWeight: FontWeight.w500, height: 24 / 14, color: AppColors.onCosmic, ); @override Widget build(BuildContext context) { final ratio = growth.popRatio; return ClipRRect( borderRadius: BorderRadius.circular(AppRadius.md), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), child: Container( width: double.infinity, padding: EdgeInsets.fromLTRB( AppSpace.s4, AppSpace.s3, AppSpace.s4, zapClearance ? 32 : AppSpace.s3, ), decoration: BoxDecoration( color: const Color(0x66040304), borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all(color: AppFx.glassStroke), ), 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: AppColors.onCosmicVariant, ), ], ), ), Row( children: [ Text(context.l10n.heroPopProgress, style: _white14), const SizedBox(width: 4), Text( growth.growthPercent == null ? '—' : '${growth.growthPercent}%', style: _white14, ), ], ), const SizedBox(height: AppSpace.s3), _Bar(ratio: ratio ?? 0), const SizedBox(height: AppSpace.s3), GrowthConditions( growth: growth, style: _white14.copyWith(height: 16 / 14), ), ], ), // 말풍선 — 꼬리 끝이 바 위 9.5 에 닿는다(사본 581.5+40 → 바 631). // 성운 카드(`344:213809`)에는 말풍선이 없다. if (!growth.isNebula) Positioned( top: 45 + 24 + AppSpace.s3 - 49.5, left: (box.maxWidth * (ratio ?? 0.5)).clamp( 84.0, box.maxWidth - 84, ), child: FractionalTranslation( translation: const Offset(-0.5, 0), child: _SpeechBubble(text: growth.growthStatus), ), ), ], ), ), ), ), ); } } /// 진행 바 — 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); @override Widget build(BuildContext context) => Column( mainAxisSize: MainAxisSize.min, children: [ Container( height: 30, padding: const EdgeInsets.symmetric(horizontal: 10), alignment: Alignment.center, decoration: BoxDecoration( color: _fill, borderRadius: BorderRadius.circular(AppRadius.full), ), child: Text( text, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 12, fontWeight: FontWeight.w500, height: 1, color: AppColors.onCosmic, ), ), ), 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, child: GestureDetector( onTap: ready ? onBirth : null, child: 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 _open(BuildContext context) async { final pick = await showModalBottomSheet( 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)), ), ), ], ), ), ); }