814 lines
29 KiB
Dart
814 lines
29 KiB
Dart
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 미니맵 — 행성 영역 우측 상단(해석 확인 요청서 Q-DS-06 권고안 A · 회신 전 임시).
|
||
/// 사본 홈에는 없는 요소라 행성 칸 모서리에만 얹고 문구·카드와 겹치지 않게 한다.
|
||
final Widget? miniMap;
|
||
|
||
/// 사본 기준 배경 높이(상태 표시줄 48 포함).
|
||
static const double designHeight = 680;
|
||
|
||
/// 배경 아래로 ZAP 원이 걸쳐 내려오는 몫. 다음 구획의 위 여백(24)과 같다.
|
||
static const double zapOverhang = 24;
|
||
|
||
@override
|
||
State<HomeHero> createState() => _HomeHeroState();
|
||
}
|
||
|
||
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();
|
||
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.miniMap case final mini?)
|
||
Positioned(top: 0, right: 0, child: mini),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
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.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(
|
||
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<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)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|