HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE

This commit is contained in:
Sn-Kinos
2026-10-05 16:29:41 +09:00
parent f84d696c21
commit 54d0b23260
45 changed files with 3448 additions and 1111 deletions
+286 -118
View File
@@ -47,8 +47,12 @@ class HomeHero extends StatefulWidget {
this.unjoined = false,
this.onItem,
this.miniMap,
this.zapButtonKey,
});
/// 화면 하단에 떠 있는 ZAP 버튼([HomeZap])의 키 — 기여 연출이 이 자리에서 행성으로 날아간다.
final GlobalKey? zapButtonKey;
final VoidCallback? onOpenHistory;
/// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다.
@@ -96,8 +100,9 @@ class HomeHero extends StatefulWidget {
/// 사본 기준 배경 높이(상태 표시줄 48 포함).
static const double designHeight = 680;
/// 배경 아래로 ZAP 원이 걸쳐 내려오는 몫. 다음 구획의 위 여백(24)과 같다.
static const double zapOverhang = 24;
/// 히어로 아래 여백 — 사본 `Frame 4241` 위 패딩 24 에서 첫 구획 머리의 터치 여유 12 를 뺀 값.
/// (ZAP 은 화면 하단에 떠 있어 더는 히어로에 걸치지 않는다.)
static const double bottomGap = 12;
@override
State<HomeHero> createState() => _HomeHeroState();
@@ -106,17 +111,18 @@ class HomeHero extends StatefulWidget {
class _HomeHeroState extends State<HomeHero> {
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();
/// [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
? box.localToGlobal(box.size.center(Offset.zero), ancestor: root)
return box is RenderBox && box.hasSize && root is RenderBox && root.hasSize
? box.localToGlobal(box.size.center(Offset.zero)) -
root.localToGlobal(Offset.zero)
: Offset.zero;
}
@@ -133,7 +139,7 @@ class _HomeHeroState extends State<HomeHero> {
return PlanetFxScope(
child: SizedBox(
height: background + HomeHero.zapOverhang,
height: background + HomeHero.bottomGap,
child: Stack(
key: _fxKey,
children: [
@@ -202,7 +208,6 @@ class _HomeHeroState extends State<HomeHero> {
glow: AppColors.neonViolet,
),
),
],
),
),
@@ -218,7 +223,6 @@ class _HomeHeroState extends State<HomeHero> {
_GrowthOverlayCard(
growth: growth,
onOpen: widget.onOpenPlanet,
zapClearance: hasZap,
),
],
],
@@ -226,67 +230,11 @@ class _HomeHeroState extends State<HomeHero> {
),
if (widget.miniMap case final mini?)
Positioned(
top: top + HomeHero.miniMapTop,
right: 0,
child: 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<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,
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(
@@ -294,7 +242,7 @@ class _HomeHeroState extends State<HomeHero> {
touchSerial: zap.touchSerial,
pending: zap.pending > 0 || zap.sending,
result: zap.result,
source: _center(_buttonKey),
source: _center(widget.zapButtonKey),
target: _center(_planetKey),
radius:
(_planetKey.currentContext?.findRenderObject()
@@ -344,7 +292,8 @@ class _Contribution extends StatelessWidget {
alignment: Alignment.center,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.cosmicSurfaceRaised,
// 사본 `Ellipse 3` #9BF8A8(프로필 사진이 없을 때의 바탕).
color: Color(0xFF9BF8A8),
boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)],
),
child: Text(
@@ -353,7 +302,7 @@ class _Contribution extends StatelessWidget {
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.onCosmic,
color: Colors.black,
),
),
),
@@ -391,7 +340,9 @@ class _Contribution extends StatelessWidget {
: growth.isNebula
? context.l10n.heroNebulaPrompt
: growth.hasRecentContribution
? context.l10n.heroRecentContribution(formatPop(growth.contributedPop))
? context.l10n.heroRecentContribution(
formatPop(growth.contributedPop),
)
: context.l10n.heroGrowingTogether,
style: const TextStyle(
fontFamily: AppFonts.body,
@@ -406,26 +357,19 @@ class _Contribution extends StatelessWidget {
);
}
/// 성장 카드 — 사본 기본 상태의 「BTS › / 행성 성장률 67% / 진행 바 / 6,270 / 10,000」
/// (`Frame 1984081073` 343 폭 · `#040304` 40% · r12 · 패딩 12/16 · 간격 12 · 유리 흐림 4).
/// 성장 카드 — 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)
/// 은 바 채움 끝 위에 뜬다.
///
/// 둘째 줄 「POP 진행률」과 바 아래 두 조건 줄은 정책값이다 — growthPercent 는 POP 진행률
/// 이고(전체 충족률 아님) 승급은 POP·sFPI 동시 충족이다(정책 설계서 §P-08 · UX·FX v1.5
/// §2.1). 말풍선 꼬리는 바 채움 끝을 가리킨다(사본 `Group 563` 가운데 = 채움 끝 x238).
/// 성장률 · 수치는 두 조건 중 병목 축([PlanetGrowth.bottleneck])이다. 말풍선을 누르면 두
/// 조건 상세([GrowthConditions])를 연다.
class _GrowthOverlayCard extends StatelessWidget {
const _GrowthOverlayCard({
required this.growth,
this.onOpen,
this.zapClearance = false,
});
const _GrowthOverlayCard({required this.growth, this.onOpen});
final PlanetGrowth growth;
final VoidCallback? onOpen;
/// 원형 ZAP(52)이 카드 아래 변에 걸친다. 둘째 조건 줄이 가려지지 않게 아래 여백을
/// ZAP 윗변(아래 변 − 28) 위로 올린다. ZAP 좌표는 사본 그대로 둔다.
final bool zapClearance;
static const _white14 = TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
@@ -434,26 +378,58 @@ class _GrowthOverlayCard extends StatelessWidget {
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<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 ratio = growth.popRatio;
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: EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s3,
AppSpace.s4,
zapClearance ? 32 : AppSpace.s3,
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s4,
vertical: 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(
@@ -485,44 +461,57 @@ class _GrowthOverlayCard extends StatelessWidget {
const ImageIcon(
AppIcons.chevronRight,
size: 24,
color: AppColors.onCosmicVariant,
color: Color(0xFF777777),
),
],
),
),
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),
// 성장률 · 바 · 수치를 누르면 두 조건 상세 — 말풍선이 없는 성운 카드의 입구.
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,
),
],
),
),
],
),
// 말풍선 — 꼬리 끝이 바 위 9.5 에 닿는다(사본 581.5+40 → 바 631).
// 말풍선 — 사본 y566.5(카드 안 31.5) · 꼬리 끝이 바 위에 닿는다. 가운데는 채움 끝
// (사본 x238 = 채움 206)을 따르되 왼쪽 성장률 글자와 겹치지 않게 민다.
// 성운 카드(`344:213809`)에는 말풍선이 없다.
if (!growth.isNebula)
Positioned(
top: 45 + 24 + AppSpace.s3 - 49.5,
left: (box.maxWidth * (ratio ?? 0.5)).clamp(
84.0,
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: _SpeechBubble(text: growth.growthStatus),
child: Semantics(
button: true,
child: GestureDetector(
onTap: () => _details(context),
child: _SpeechBubble(text: growth.growthBubble),
),
),
),
),
],
@@ -820,7 +809,9 @@ class _StartSheet extends StatelessWidget {
onTap: () => Navigator.of(context).pop(),
child: SizedBox(
height: 47,
child: Center(child: Text(context.l10n.commonCancel, style: _item)),
child: Center(
child: Text(context.l10n.commonCancel, style: _item),
),
),
),
],
@@ -828,3 +819,180 @@ class _StartSheet extends StatelessWidget {
),
);
}
/// 「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.pending * data.zapPop,
isHeroTime: hero,
blockedReason: zap.sending
? context.l10n.zapApplying
: zap.needsRetry
? context.l10n.zapNeedsCheck
: null,
onZap: () => hero ? onHeroParticipate!() : zap.tap(fandomId),
),
],
);
},
);
}