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 createState() => _HomeHeroState(); } class _HomeHeroState extends State { 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), _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: _TapShake( onTap: widget.onTapPlanet, child: 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, ), ), ), ], ), ), // 이클립스 이벤트 버튼 — 명세 `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, 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; @override Widget build(BuildContext context) => 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), 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, ), ), ], ); } /// 성장 카드 — 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, ); /// 말풍선 가운데의 최소 x — 「행성 성장률 100%」 줄(약 103)과 겹치지 않게 한다. static const _bubbleMinCenter = 196.0; String _value(num v) => v == v.roundToDouble() ? formatPop(v.toInt()) : v.toStringAsFixed(1); void _details(BuildContext context) => showModalBottomSheet( 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()}'; 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( growth.isNebula ? context.l10n.heroNebulaGrowthRate(percent) : context.l10n.heroGrowthRate(percent), 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)을 따르되 왼쪽 성장률 글자와 겹치지 않게 민다. // 성운 카드(`344:213809`)에는 말풍선이 없다. if (!growth.isNebula) Positioned( top: 31.5, left: (box.maxWidth * (ratio ?? 0)).clamp( math.min(_bubbleMinCenter, box.maxWidth - 84), box.maxWidth - 84, ), child: FractionalTranslation( translation: const Offset(-0.5, 0), 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); @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, // Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고, // 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다. child: _TapShake( onTap: ready && onBirth != null ? onBirth : () {}, 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), ), ), ), ], ), ), ); } /// 「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( 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.pending * data.zapPop, // Figma `516:88102` Zap_버프(아이템 배수 · 지속시간)는 별첨 B B-22 「배수 기능 제거」 · // 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 — // 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다. isHeroTime: hero, blockedReason: zap.sending ? context.l10n.zapApplying : zap.needsRetry ? context.l10n.zapNeedsCheck : null, onZap: () => hero ? onHeroParticipate!() : zap.tap(fandomId), ), ], ); }, ); } /// 행성 터치 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · UX·FX v1.5 §3 Light Haptic. /// 흔들림이 끝나면 [onTap](커뮤니티 이동)을 부른다. [onTap] 이 없으면 아무 반응도 하지 않는다. class _TapShake extends StatefulWidget { const _TapShake({required this.child, this.onTap}); final Widget child; final VoidCallback? onTap; @override State<_TapShake> createState() => _TapShakeState(); } class _TapShakeState extends State<_TapShake> with SingleTickerProviderStateMixin { late final _shake = AnimationController(vsync: this, duration: const Duration(milliseconds: 320)); @override void dispose() { _shake.dispose(); super.dispose(); } Future _tap() async { final onTap = widget.onTap; if (onTap == null || _shake.isAnimating) return; fxHaptic(FxIntensity.micro); if (!MediaQuery.disableAnimationsOf(context)) await _shake.forward(from: 0); if (mounted) onTap(); } @override Widget build(BuildContext context) => GestureDetector( behavior: HitTestBehavior.translucent, onTap: widget.onTap == null ? null : _tap, child: AnimatedBuilder( animation: _shake, // 한 번 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠. builder: (context, child) => Transform.translate( offset: Offset(math.sin(_shake.value * math.pi * 4) * 6 * (1 - _shake.value), 0), child: child, ), child: widget.child, ), ); }