HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE
This commit is contained in:
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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!)}';
|
||||
|
||||
Reference in New Issue
Block a user