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
@@ -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),
);
}