Files
fanuzu/app/lib/features/home/widgets/home_sections.dart
T

552 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
);
}