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
@@ -5,7 +5,8 @@ import '../../../core/theme/tokens.dart';
import '../home_data.dart';
import '../../../core/i18n/app_locale.dart';
/// AI Daily Briefing — Figma 홈의 번호 매긴 헤드라인 줄.
/// 아티스트 소식(AI Daily Briefing) — Figma 「홈」 `Frame 1984081094`: `#1A1A1C` · r12 ·
/// 패딩 20/16 · 줄 간격 20. 줄은 번호(16/500 흰색) · 28 · 제목(16/500 · 행간 150% · #A3A2A0).
///
/// **구획을 News 와 SNS 로 나눈다** — 정책 원장 B-47 *News/SNS 각각 0 건이면 해당*
/// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만
@@ -27,7 +28,6 @@ class BriefingList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
// 구획 순서는 enum 선언 순서다(News 다음 SNS). 수신 순서로 정하면 날마다 바뀐다.
final sections = [
for (final section in BriefingSection.values)
@@ -38,10 +38,14 @@ class BriefingList extends StatelessWidget {
),
].where((g) => g.rows.isNotEmpty).toList();
// 구획 이름(News · SNS)은 사본에 없지만 B-47 의 구획 단위 숨김을 화면에서 가르는 표지라
// 남긴다 — 「더 보기」와 같은 12/600 #A3A2A0 으로 작게.
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s4,
vertical: AppSpace.s2,
padding: const EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s5,
AppSpace.s4,
AppSpace.s5,
),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
@@ -51,22 +55,19 @@ class BriefingList extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (groupIndex, group) in sections.indexed) ...[
if (groupIndex > 0) const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(
top: AppSpace.s3,
bottom: AppSpace.s1,
),
child: Text(
group.section.label,
style: t.labelSmall?.copyWith(
color: AppColors.cosmicAccent,
fontWeight: FontWeight.w600,
),
if (groupIndex > 0) const SizedBox(height: AppSpace.s5),
Text(
group.section.label,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmicVariant,
),
),
for (final (i, item) in group.rows.indexed) ...[
if (i > 0) const Divider(height: 1),
for (final item in group.rows) ...[
const SizedBox(height: AppSpace.s5),
_Row(item: item, onOpen: () => _open(context, item)),
],
],
@@ -89,10 +90,12 @@ class BriefingList extends StatelessWidget {
if (!opened && context.mounted) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(context.l10n.briefingOpenFailed),
behavior: SnackBarBehavior.floating,
));
..showSnackBar(
SnackBar(
content: Text(context.l10n.briefingOpenFailed),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
@@ -105,27 +108,39 @@ class _Row extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return InkWell(
onTap: onOpen,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 20,
child: Text(
'${item.rank}.',
style: t.bodySmall?.copyWith(
color: AppColors.cosmicAccent,
fontWeight: FontWeight.w600,
),
child: Row(
children: [
SizedBox(
width: 10,
child: Text(
'${item.rank}',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.4,
color: AppColors.onCosmic,
),
),
Expanded(child: Text(item.headline, style: t.bodySmall)),
],
),
),
const SizedBox(width: 28),
Expanded(
child: Text(
item.headline,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
color: AppColors.onCosmicVariant,
),
),
),
],
),
);
}
@@ -1,129 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../home_data.dart';
import '../../../core/i18n/app_locale.dart';
/// HOT ARCHIVE — 진행 중인 캠페인 한 건.
///
/// Figma 「다이브짱 / 92% 달성 / 강남역 초대형 LED 광고 / 48 · 24 · 82」.
/// 정책 설계서 §P-09 — 실패 시 100% 자동 환불이 이 기능의 검수 기준이다.
class CampaignCard extends StatelessWidget {
const CampaignCard({super.key, required this.campaign, this.onTap});
final CampaignHighlight campaign;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 시안 HOT ARCHIVE 카드(252:159447) — 위에 이미지 342×228.
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: AspectRatio(
aspectRatio: 342 / 228,
child: Image.asset('assets/figma/campaign-sample.png', fit: BoxFit.cover),
),
),
const SizedBox(height: AppSpace.s3),
Row(
children: [
Text(campaign.authorName, style: t.bodySmall),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s2,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.cosmicAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
context.l10n.campaignAchieved('${campaign.achievedPercent}'),
style: t.labelSmall?.copyWith(
color: AppColors.cosmicAccent,
),
),
),
],
),
const SizedBox(height: AppSpace.s3),
Text(campaign.title, style: t.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(
campaign.description,
style: t.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpace.s3),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.full),
child: LinearProgressIndicator(
value: (campaign.achievedPercent / 100).clamp(0.0, 1.0),
minHeight: 6,
backgroundColor: AppColors.cosmicSurfaceRaised,
valueColor: const AlwaysStoppedAnimation(
AppColors.cosmicAccent,
),
),
),
const SizedBox(height: AppSpace.s3),
Row(
children: [
_Stat(icon: Icons.people_outline, value: campaign.supporters),
const SizedBox(width: AppSpace.s4),
_Stat(
icon: Icons.chat_bubble_outline,
value: campaign.comments,
),
const SizedBox(width: AppSpace.s4),
_Stat(icon: Icons.favorite_border, value: campaign.likes),
const Spacer(),
Text(
context.l10n.campaignView,
style: t.bodySmall?.copyWith(color: AppColors.cosmicAccent),
),
const Icon(
Icons.chevron_right,
size: 16,
color: AppColors.cosmicAccent,
),
],
),
],
),
),
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({required this.icon, required this.value});
final IconData icon;
final int value;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppColors.onCosmicVariant),
const SizedBox(width: AppSpace.s1),
Text('$value', style: Theme.of(context).textTheme.labelSmall),
],
);
}
+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),
),
],
);
},
);
}
@@ -0,0 +1,551 @@
import 'package:flutter/material.dart';
import '../../../core/format.dart';
import '../../../core/i18n/app_locale.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../inside/inside_data.dart';
import '../../inside/inside_screen.dart' show InsideQuestionCard;
import '../home_data.dart';
/// Figma 「홈」(`2097:16428`) 구획 머리 — 제목 42dot 20/600(행간 100%) 과 오른쪽 「더 보기」
/// (12/600 #A3A2A0 + chevron 18). 오른쪽 자리에 다른 것(아티스트 소식 「공유」 알약)을 둘 수 있다.
class HomeSectionHeader extends StatelessWidget {
const HomeSectionHeader({
super.key,
required this.title,
this.onMore,
this.trailing,
});
final String title;
final VoidCallback? onMore;
final Widget? trailing;
static const _more = TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmicVariant,
);
/// 머리 줄 높이 — 사본 20 이지만 「더 보기」 터치 영역 44 를 위해 44 로 잡고, 위아래 남는
/// 12 씩을 구획 간격에서 뺀다(HOME 이 [slack] 을 쓴다). 눈에 보이는 위치는 사본과 같다.
static const height = 44.0;
static const slack = (height - 20) / 2;
@override
Widget build(BuildContext context) => ConstrainedBox(
constraints: const BoxConstraints(minHeight: height),
child: Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
),
?trailing,
if (trailing == null && onMore != null)
// 글자는 사본 크기대로 두고 누르는 자리만 44 로 넓힌다.
InkWell(
onTap: onMore,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: height),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.commonMore, style: _more),
const ImageIcon(
AppIcons.chevronRight,
size: 18,
color: AppColors.onCosmicVariant,
),
],
),
),
),
],
),
);
}
/// 팬덤행성 인큐베이터(성운 추천) — 사본 `Frame 1984081878`: 154×218 카드를 간격 8 로 가로
/// 스크롤. 카드 `#1A1A1C` · `#232324` 획 · r12 · 패딩 16 · 간격 20.
/// 명세 §2.114-4(`2097:16403`) — 기여율 높은 순 최대 6개 · 「함께 성장하기」 = 성운 상세.
class NebulaIncubator extends StatelessWidget {
const NebulaIncubator({
super.key,
required this.nebulas,
required this.onOpen,
});
final List<NebulaPick> nebulas;
final ValueChanged<NebulaPick> onOpen;
static const cardSize = Size(154, 218);
@override
Widget build(BuildContext context) => SizedBox(
// 글자 확대 때 카드 안이 넘치지 않게 글자 몫만 배율을 따른다.
height: cardSize.height - 70 + MediaQuery.textScalerOf(context).scale(70),
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
itemCount: nebulas.length,
separatorBuilder: (_, _) => const SizedBox(width: AppSpace.s2),
itemBuilder: (context, i) =>
_NebulaCard(nebula: nebulas[i], onOpen: () => onOpen(nebulas[i])),
),
);
}
class _NebulaCard extends StatelessWidget {
const _NebulaCard({required this.nebula, required this.onOpen});
final NebulaPick nebula;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) => Container(
width: NebulaIncubator.cardSize.width,
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF232324)),
),
child: Column(
children: [
Text(
nebula.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Image(
image: AssetImage('assets/figma/icon-person.png'),
width: 14,
height: 14,
color: Color(0xFF45464A),
),
const SizedBox(width: 3),
Text(
formatPop(nebula.members),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1,
letterSpacing: -0.3,
color: Color(0xFF45464A),
),
),
],
),
const SizedBox(height: 12),
const Image(
image: AssetImage('assets/figma/nebula-card.png'),
width: 48,
height: 48,
),
const SizedBox(height: 12),
Text(
'${nebula.percent}%',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 8),
// 사본 `Frame 1984081872` — 122×4 #424242 위에 #E49BD1 → #FF7DDD 채움.
Container(
height: 4,
decoration: BoxDecoration(
color: const Color(0xFF424242),
borderRadius: BorderRadius.circular(AppRadius.full),
),
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: (nebula.percent / 100).clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
gradient: const LinearGradient(
colors: [Color(0xFFE49BD1), Color(0xFFFF7DDD)],
),
),
),
),
),
const Spacer(),
Material(
color: const Color(0xFFFF7DDD),
shape: const StadiumBorder(),
child: InkWell(
onTap: onOpen,
customBorder: const StadiumBorder(),
child: SizedBox(
height: 26,
width: double.infinity,
child: Center(
child: Text(
context.l10n.homeGrowTogether,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1,
letterSpacing: -0.35,
color: Colors.black,
),
),
),
),
),
),
],
),
);
}
/// 아티스트 소식 머리의 「공유」 알약 — 사본 `Frame 1984081277`(55×26 · #1A1A1C · 패딩 6/8 ·
/// 12/600 #A3A2A0 + share-2 14).
class SharePill extends StatelessWidget {
const SharePill({super.key, required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Material(
color: AppColors.cosmicSurfaceContainer,
shape: const StadiumBorder(),
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.commonShare, style: HomeSectionHeader._more),
const SizedBox(width: 4),
const ImageIcon(
AppIcons.share,
size: 14,
color: AppColors.onCosmicVariant,
),
],
),
),
),
);
}
/// HOT ARCHIVE 캐러셀 — 사본 `Frame 1984081106` 카드(343×481) + 아래 16 · 점 8(간격 8 ·
/// 현재 #FF7DDD · 나머지 #474747). 명세 §2.114-6 「좌/우 스와이프」.
class CampaignCarousel extends StatefulWidget {
const CampaignCarousel({
super.key,
required this.campaigns,
required this.onOpen,
});
final List<CampaignHighlight> campaigns;
final ValueChanged<CampaignHighlight> onOpen;
@override
State<CampaignCarousel> createState() => _CampaignCarouselState();
}
class _CampaignCarouselState extends State<CampaignCarousel> {
int _page = 0;
@override
Widget build(BuildContext context) {
// 이미지 228 + 본문 253. 본문 글자 몫(약 120)만 배율을 따른다.
final height = 481 - 120 + MediaQuery.textScalerOf(context).scale(120);
return Column(
children: [
SizedBox(
height: height,
child: PageView.builder(
itemCount: widget.campaigns.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (context, i) => Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: HomeCampaignCard(
campaign: widget.campaigns[i],
onOpen: () => widget.onOpen(widget.campaigns[i]),
),
),
),
),
// 사본은 한 장이어도 점 줄을 둔다(현재 위치 표지).
if (widget.campaigns.isNotEmpty) ...[
const SizedBox(height: AppSpace.s4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < widget.campaigns.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _page
? const Color(0xFFFF7DDD)
: const Color(0xFF474747),
),
),
],
],
),
],
],
);
}
}
/// 캠페인 카드 — 사본 `Frame 1984081106`: 이미지 342×228 / 본문 `#181818` · `#1E1E1E` 획 ·
/// 패딩 14 · 간격 21 — 작성자(원 26 #ED7E7E · ABeeZee 12) + 「92% 달성」(그라디언트 r4) /
/// 제목 ABeeZee 14 / 설명 14/500 #C2C2C2 2줄 / 선 #4F4F4F / 좋아요 · 댓글 / 「캠페인 보기」
/// (흰 r12 · 38).
///
/// 사본의 조회수 · 공유수 칸은 캠페인 문서에 없는 값이라 그리지 않는다.
class HomeCampaignCard extends StatelessWidget {
const HomeCampaignCard({
super.key,
required this.campaign,
required this.onOpen,
});
final CampaignHighlight campaign;
final VoidCallback onOpen;
static const _stat = TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1,
letterSpacing: -0.3,
color: Color(0xFF767676),
);
@override
Widget build(BuildContext context) {
Widget stat(ImageProvider icon, int n) => Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageIcon(icon, size: 14, color: const Color(0xFF767676)),
const SizedBox(width: 4),
Text(formatPop(n), style: _stat),
],
);
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
// 사본 342×228 — 높이를 고정해 넓은 화면에서도 본문 자리가 줄지 않게 한다.
const SizedBox(
height: 228,
width: double.infinity,
child: Image(
image: AssetImage('assets/figma/campaign-sample.png'),
fit: BoxFit.cover,
),
),
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: const BoxDecoration(
color: Color(0xFF181818),
border: Border(
left: BorderSide(color: Color(0xFF1E1E1E)),
right: BorderSide(color: Color(0xFF1E1E1E)),
bottom: BorderSide(color: Color(0xFF1E1E1E)),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 26,
height: 26,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFED7E7E),
),
),
const SizedBox(width: 9),
Expanded(
child: Text(
campaign.authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmic,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: const LinearGradient(
colors: [
Color(0xFF4BC2AE),
Color(0xFF4F98C8),
Color(0xFF5669DC),
],
),
),
child: Text(
context.l10n.campaignAchieved(
'${campaign.achievedPercent}',
),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmic,
),
),
),
],
),
const SizedBox(height: 16),
Text(
campaign.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 14,
height: 1.3,
letterSpacing: -0.35,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 7),
Text(
campaign.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.4,
letterSpacing: -0.35,
color: Color(0xFFC2C2C2),
),
),
const Spacer(),
const Divider(
height: 1,
thickness: 1,
color: Color(0xFF4F4F4F),
),
const SizedBox(height: 21),
Row(
children: [
stat(AppIcons.heart, campaign.likes),
const SizedBox(width: 12),
stat(AppIcons.messageCircle, campaign.comments),
],
),
const SizedBox(height: 21),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 38,
width: double.infinity,
child: Center(
child: Text(
context.l10n.campaignView,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: -0.35,
color: Colors.black,
),
),
),
),
),
),
],
),
),
),
],
),
);
}
}
/// 인기 K-INSIDE 한 장 — INSIDE 목록과 같은 카드(시안 `196:32243`)를 사본 `Frame 1984081004`
/// 바탕(`#19191C` · r8)에 얹는다.
class HomeInsideCard extends StatelessWidget {
const HomeInsideCard({
super.key,
required this.question,
required this.onTap,
});
final InsideQuestion question;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Material(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: InsideQuestionCard(question: question, onTap: onTap),
);
}
@@ -1,86 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../home_data.dart';
import '../home_screen.dart' show formatPop;
import '../../../core/i18n/app_locale.dart';
/// 인기 K-INSIDE 한 건 — Figma 「아미보이 / 10분 전 / 500 P / 제목 / 본문 / 3」.
///
/// 현상금(POP 바운티)이 붙은 Q&A 다. 정책 설계서 §P-11 — 자기 답변 채택은 막고
/// (`E-QNA-01`), 기한 만료 시 현상금은 자동 환불한다(`E-QNA-02`).
class InsideCard extends StatelessWidget {
const InsideCard({super.key, required this.digest, this.onTap});
final InsideDigest digest;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(digest.authorName, style: t.bodySmall),
const SizedBox(width: AppSpace.s2),
Text(digest.timeAgo, style: t.labelSmall),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s2,
vertical: 2,
),
decoration: BoxDecoration(
border: Border.all(color: AppColors.cosmicAccent),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
'${formatPop(digest.bountyPop)} P',
style: t.labelSmall?.copyWith(
color: AppColors.cosmicAccent,
),
),
),
],
),
const SizedBox(height: AppSpace.s3),
Text(digest.title, style: t.bodyMedium),
const SizedBox(height: AppSpace.s1),
Text(
digest.body,
style: t.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpace.s3),
Row(
children: [
const Icon(
Icons.mode_comment_outlined,
size: 14,
color: AppColors.onCosmicVariant,
),
const SizedBox(width: AppSpace.s1),
Text(
context.l10n.insideAnswers('${digest.answers}'),
style: t.labelSmall,
),
],
),
],
),
),
),
);
}
}
@@ -4,8 +4,9 @@ import '../../../core/theme/tokens.dart';
/// 상단 라이브 띠 — Figma 「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」.
///
/// 사본 `Frame 1984081132` — 헤더 바로 아래 전폭 검정 띠(높이 22 · 패딩 4/0), 가운데
/// 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의 표면이다.
/// 사본 `Frame 1984081132`(「홈」 `2097:16428`) — 헤더 바로 아래 전폭 띠(높이 22 · 패딩 4/0 ·
/// 바탕 없음), 가운데 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의
/// 표면이다.
class LiveTicker extends StatelessWidget {
const LiveTicker({super.key, required this.text});
@@ -14,7 +15,6 @@ class LiveTicker extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
height: 22,
color: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
alignment: Alignment.center,
child: Text(
+109 -202
View File
@@ -1,219 +1,130 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../home_data.dart';
import '../home_screen.dart' show formatPop;
import '../../../core/i18n/app_locale.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../quest/widgets/quest_card_tile.dart'
show QuestIconBox, questImageFor;
import '../../quest/widgets/time_attack_countdown.dart';
import '../home_data.dart';
/// TODAY'S QUEST 한 줄 — Figma 「오늘의 출석체크 / 출석 완료 / +{보상} / 보상받기」.
/// 홈 「TODAY'S QUEST」 묶음 — Figma 「홈」 `2097:16428`: 343×270 · r12 안에 343×90 줄
/// 세 개를 간격 없이 쌓는다. 앞 두 줄에 `#242424` 1 획(사본 spec) — 줄 사이 구분선으로 보인다.
///
/// 보상 숫자는 Figma 의 50 이 아니라 서버 지급액과 같은 원천에서 온다.
/// 항목은 설명 프레임 `2097:16397` 「관리자 퀘스트 1·2·3 — 최신순 최대 3개 · 클릭 시 해당
/// 퀘스트 상세」. 사용자 생성 퀘스트는 저장소가 거른다.
class QuestRows extends StatelessWidget {
const QuestRows({super.key, required this.quests, required this.onTap});
final List<QuestSummary> quests;
final ValueChanged<QuestSummary> onTap;
/// 시안 줄 수. 나머지는 「더 보기」(퀘스트 목록)에서 본다.
static const maxRows = 3;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
for (final (i, q) in quests.take(maxRows).indexed)
QuestRow(
quest: q,
divider: i < math.min(quests.length, maxRows) - 1,
onTap: () => onTap(q),
),
],
),
);
}
/// 한 줄 — `2097:16428` 「출석체크 / 오늘의 출석체크」 행: #1A1A1C · 패딩 20 · 왼쪽 50×50
/// #2E2E2E r16 아이콘 상자(42 이미지) · 간격 19 · 제목 18/600 + 보조 14/500(간격 4) ·
/// 오른쪽 chevron 24 #4F4F4F.
///
/// 상태별 오른쪽 버튼이 다르다. 정책 설계서 §P-05 — 조건 미충족 시 보상은
/// 지급하지 않는다(`E-QST-01`).
/// 보조 줄은 카테고리(사본 「오늘의 출석체크」 · 「커뮤니티」)다. 「홈」 프레임에는 보상 숫자 ·
/// 진행 상태가 없어 그리지 않는다. 예외는 긴급 타임어택의 남은 시간 하나 — FR-QST-06
/// 「홈 카운트다운」이 요구한다.
class QuestRow extends StatelessWidget {
const QuestRow({super.key, required this.quest, this.onTap});
const QuestRow({
super.key,
required this.quest,
this.onTap,
this.divider = false,
});
final QuestSummary quest;
final VoidCallback? onTap;
/// 아래 변 `#242424` 1 획.
final bool divider;
static const height = 90.0;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final done = quest.state == QuestState.done;
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
shape: divider
? const Border(bottom: BorderSide(color: Color(0xFF242424)))
: null,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(quest.title, style: t.bodyMedium),
const SizedBox(height: AppSpace.s1),
Text(_subtitle(quest), style: t.bodySmall),
],
),
),
const SizedBox(width: AppSpace.s3),
Text(
_reward(quest),
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
),
const SizedBox(width: AppSpace.s3),
_Action(state: quest.state, onTap: onTap),
],
),
),
),
);
}
static String _subtitle(QuestSummary q) => switch (q.state) {
QuestState.claimable => appL10n.questAttended,
QuestState.inProgress => q.progressLabel ?? appL10n.questInProgressSpaced,
QuestState.done => appL10n.questRewardPaid,
};
}
class _Action extends StatelessWidget {
const _Action({required this.state, this.onTap});
final QuestState state;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return switch (state) {
QuestState.claimable => FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
minimumSize: const Size(0, AppSizing.touchMinApp),
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
),
child: Text(_claimLabel),
),
QuestState.inProgress => OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, AppSizing.touchMinApp),
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
foregroundColor: AppColors.onCosmic,
side: const BorderSide(color: AppColors.cosmicOutline),
),
child: Text(_startLabel),
),
QuestState.done => Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2),
child: Text(_doneLabel, style: t.bodySmall),
),
};
}
}
/// 사본 HOME 「TODAY'S QUEST」 버튼 문구(`252:159354` spec). 사본 HOME 에 없는 완료 상태는
/// 퀘스트 화면(C 그룹)의 「보상 완료」와 맞춘다.
String get _claimLabel => appL10n.questClaim;
String get _startLabel => appL10n.questStart;
String get _doneLabel => appL10n.questRewardDone;
/// 홈 「TODAY'S QUEST」 가로 카드 — 사본 `Frame 1984081083`(196×137 · #1A1A1C · r12 ·
/// 패딩 12 · 간격 28): 제목 16 · 상태 14 #A3A2A0 · 오른쪽 「+보상」 20 #2AC28B, 아래 37 버튼
/// (보상 가능 = 초록 #3EB58A 흰 글자 · 진행 중 = 흰 바탕 검정 글자). 누르면 퀘스트 목록으로 간다.
class QuestCard extends StatelessWidget {
const QuestCard({super.key, required this.quest, this.onTap});
final QuestSummary quest;
final VoidCallback? onTap;
static const width = 196.0;
@override
Widget build(BuildContext context) {
final (label, fill, ink) = switch (quest.state) {
QuestState.claimable => (
_claimLabel,
const Color(0xFF3EB58A),
AppColors.onCosmic,
),
QuestState.inProgress => (_startLabel, AppColors.onCosmic, Colors.black),
QuestState.done => (
_doneLabel,
AppColors.cosmicSurfaceRaised,
AppColors.onCosmicMuted,
),
};
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
quest.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
child: ConstrainedBox(
// 글자 확대 때는 늘어난다 — 고정하면 잘린다.
constraints: const BoxConstraints(minHeight: height),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
QuestIconBox(image: questImageFor(quest.type, quest.title)),
const SizedBox(width: 19),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
quest.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 18,
fontWeight: FontWeight.w600,
height: 20 / 18,
color: AppColors.onCosmic,
),
const SizedBox(height: 6),
// 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06).
if (quest.endsAt case final endsAt?
when quest.state != QuestState.done)
TimeAttackCountdown(
endsAt: endsAt,
builder: (context, left) => _subtitleText(
left == null
? context.l10n.questTimeAttackClosed
: context.l10n.questTimeAttackLeft(
formatLeft(left),
),
),
)
else
_subtitleText(QuestRow._subtitle(quest)),
],
),
),
const SizedBox(width: AppSpace.s2),
Text(
_reward(quest),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1,
color: Color(0xFF2AC28B),
),
),
],
),
Container(
height: 37,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(
label,
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1,
color: ink,
),
const SizedBox(height: 4),
// 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06).
if (quest.endsAt case final endsAt? when !done)
TimeAttackCountdown(
endsAt: endsAt,
builder: (context, left) => _sub(
left == null
? context.l10n.questTimeAttackClosed
: context.l10n.questTimeAttackLeft(
formatLeft(left),
),
),
)
else
_sub(quest.category ?? ''),
],
),
),
),
],
const SizedBox(width: AppSpace.s2),
const ImageIcon(
AppIcons.chevronRight,
size: 24,
color: Color(0xFF4F4F4F),
),
],
),
),
),
),
@@ -221,19 +132,15 @@ class QuestCard extends StatelessWidget {
}
}
Widget _subtitleText(String text) => Text(
Widget _sub(String text) => Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.4,
fontWeight: FontWeight.w500,
height: 20 / 14,
color: AppColors.onCosmicVariant,
),
);
/// 운영값을 못 읽었으면 숫자를 지어내지 않는다.
String _reward(QuestSummary quest) =>
quest.rewardPop == null ? 'POP' : '+${formatPop(quest.rewardPop!)}';