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 nebulas; final ValueChanged 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 campaigns; final ValueChanged onOpen; @override State createState() => _CampaignCarouselState(); } class _CampaignCarouselState extends State { 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), ); }