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