HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -0,0 +1,813 @@
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user