552 lines
18 KiB
Dart
552 lines
18 KiB
Dart
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),
|
||
);
|
||
}
|