HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../home_data.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// AI Daily Briefing — Figma 홈의 번호 매긴 헤드라인 줄.
|
||||
///
|
||||
/// **구획을 News 와 SNS 로 나눈다** — 정책 원장 B-47 *News/SNS 각각 0 건이면 해당*
|
||||
/// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만
|
||||
/// 숨김」이라는 판정 자체가 성립하지 않는다. 블록 전체 숨김은 홈 화면이 맡는다.
|
||||
///
|
||||
/// **한 줄은 제목만 띄운다.** 출처명·게시시각·AI 요약을 어디에 배치할지는 발주사가
|
||||
/// HOME 기준으로 지정한 Figma 「REFINE v5A · HOME · Default」 확인 후 정한다.
|
||||
/// 데이터는 [BriefingItem] 에 5 요소가 전부 들어와 있으므로 배치만 남는다.
|
||||
///
|
||||
/// 줄을 누르면 원문보기로 나간다(B-75 5 요소 중 「원문보기」).
|
||||
class BriefingList extends StatelessWidget {
|
||||
const BriefingList({super.key, required this.items, this.onOpen});
|
||||
|
||||
final List<BriefingItem> items;
|
||||
|
||||
/// 원문보기 동작. 기본값은 기본 브라우저로 넘기는 것이고, 테스트가 대신 받는다 —
|
||||
/// 위젯 테스트에서 실제 브라우저를 띄울 수는 없다.
|
||||
final Future<void> Function(BriefingItem item)? onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
// 구획 순서는 enum 선언 순서다(News 다음 SNS). 수신 순서로 정하면 날마다 바뀐다.
|
||||
final sections = [
|
||||
for (final section in BriefingSection.values)
|
||||
(
|
||||
section: section,
|
||||
rows: items.where((i) => i.section == section).toList()
|
||||
..sort((a, b) => a.rank.compareTo(b.rank)),
|
||||
),
|
||||
].where((g) => g.rows.isNotEmpty).toList();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpace.s4,
|
||||
vertical: AppSpace.s2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (groupIndex, group) in sections.indexed) ...[
|
||||
if (groupIndex > 0) const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: AppSpace.s3,
|
||||
bottom: AppSpace.s1,
|
||||
),
|
||||
child: Text(
|
||||
group.section.label,
|
||||
style: t.labelSmall?.copyWith(
|
||||
color: AppColors.cosmicAccent,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final (i, item) in group.rows.indexed) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
_Row(item: item, onOpen: () => _open(context, item)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(BuildContext context, BriefingItem item) async {
|
||||
final handler = onOpen;
|
||||
if (handler != null) {
|
||||
await handler(item);
|
||||
return;
|
||||
}
|
||||
final opened = await launchUrl(
|
||||
Uri.parse(item.articleUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
// 열리지 않으면 알린다. 조용히 아무 일도 없으면 눌린 것인지조차 알 수 없다.
|
||||
if (!opened && context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text(context.l10n.briefingOpenFailed),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.item, required this.onOpen});
|
||||
|
||||
final BriefingItem item;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return InkWell(
|
||||
onTap: onOpen,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpace.s3),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
child: Text(
|
||||
'${item.rank}.',
|
||||
style: t.bodySmall?.copyWith(
|
||||
color: AppColors.cosmicAccent,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(item.headline, style: t.bodySmall)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../../campaign/campaign_share.dart' show shareHashtags, shareLink, shareBaseUrl;
|
||||
import '../home_data.dart';
|
||||
|
||||
/// 브리핑 공유 — 「홈_브리핑 공유」 `266:90445` · 인스타 `272:240423` · 틱톡 `272:240430`.
|
||||
///
|
||||
/// 별첨 1 ③-5 「카드 열람 → SNS 공유 카드 정상 노출」. 명세 `523:18300` — AI 브리핑
|
||||
/// 최대 3건 · `523:18306` 인스타그램 · 틱톡 · X · 링크 복사.
|
||||
///
|
||||
/// SNS 셋은 **카드 이미지 + 문구 + 링크**를 OS 공유 시트로 보낸다(인스타 · 틱톡은 이미지가
|
||||
/// 있어야 올라간다). 링크 복사는 문구와 링크를 클립보드에 둔다.
|
||||
|
||||
/// 그날 팬덤 브리핑으로 돌아오는 링크. 식별자는 팬덤과 KST 날짜다(상세정의 v1.6.1 「공유
|
||||
/// 링크에는 원래 문맥을 복원할 수 있는 식별자를 포함」 · B-47 브리핑은 팬덤 · 당일 단위).
|
||||
String? briefingShareLink(String fandomId, String dayKey, {String base = shareBaseUrl}) =>
|
||||
shareLink(['b', fandomId, dayKey], base: base);
|
||||
|
||||
/// 공유 문구 — 제목 한 줄 · 헤드라인(최대 3) · 링크 · 해시태그(B-78 동적 구성).
|
||||
String briefingShareText({
|
||||
required List<BriefingItem> items,
|
||||
String? fandomName,
|
||||
String? link,
|
||||
}) => [
|
||||
'AI DAILY BRIEFING',
|
||||
for (final (i, item) in items.take(3).indexed) '${i + 1}. ${item.headline}',
|
||||
?link,
|
||||
shareHashtags(fandomName: fandomName, topic: 'AIBriefing').join(' '),
|
||||
].join('\n');
|
||||
|
||||
/// 공유 오버레이. 캠페인 공유(`480:14594`)와 같은 틀이다.
|
||||
class BriefingShareDialog extends StatefulWidget {
|
||||
const BriefingShareDialog({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.fandomName,
|
||||
this.link,
|
||||
this.share,
|
||||
});
|
||||
|
||||
final List<BriefingItem> items;
|
||||
final String fandomName;
|
||||
final String? link;
|
||||
|
||||
/// 공유 시트 호출. 테스트가 대신 받는다 — 위젯 테스트에서 OS 공유 시트를 띄울 수 없다.
|
||||
final Future<void> Function(ShareParams params)? share;
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required List<BriefingItem> items,
|
||||
required String fandomName,
|
||||
String? link,
|
||||
Future<void> Function(ShareParams params)? share,
|
||||
}) => showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: context.l10n.commonClose,
|
||||
barrierColor: AppColors.cosmicVoid,
|
||||
pageBuilder: (_, _, _) => BriefingShareDialog(
|
||||
items: items,
|
||||
fandomName: fandomName,
|
||||
link: link,
|
||||
share: share,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
State<BriefingShareDialog> createState() => _BriefingShareDialogState();
|
||||
}
|
||||
|
||||
class _BriefingShareDialogState extends State<BriefingShareDialog> {
|
||||
final _card = GlobalKey();
|
||||
|
||||
static const _icons = [
|
||||
'assets/figma/share-instagram.png',
|
||||
'assets/figma/share-tiktok.png',
|
||||
'assets/figma/share-x.png',
|
||||
'assets/figma/share-link.png',
|
||||
];
|
||||
|
||||
String get _text => briefingShareText(
|
||||
items: widget.items,
|
||||
fandomName: widget.fandomName,
|
||||
link: widget.link,
|
||||
);
|
||||
|
||||
/// 카드 영역을 PNG 로 굽는다. 실패하면 null — 문구만이라도 보낸다.
|
||||
Future<XFile?> _capture() async {
|
||||
final boundary =
|
||||
_card.currentContext?.findRenderObject() as RenderRepaintBoundary?;
|
||||
if (boundary == null) return null;
|
||||
final image = await boundary.toImage(pixelRatio: 3);
|
||||
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
image.dispose();
|
||||
if (bytes == null) return null;
|
||||
return XFile.fromData(
|
||||
bytes.buffer.asUint8List(),
|
||||
mimeType: 'image/png',
|
||||
name: 'fanuzu-briefing.png',
|
||||
);
|
||||
}
|
||||
|
||||
/// 공유 중. 연타로 공유 시트가 겹쳐 뜨지 않게 한다.
|
||||
bool _busy = false;
|
||||
|
||||
/// [button] 은 누른 아이콘의 문맥 — iPad 공유 팝오버가 그 자리에서 뜬다.
|
||||
Future<void> _onTap(int index, BuildContext button) async {
|
||||
if (_busy) return;
|
||||
final box = button.findRenderObject() as RenderBox?;
|
||||
final origin = box == null
|
||||
? null
|
||||
: box.localToGlobal(Offset.zero) & box.size;
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
void toast(String message) => messenger
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
if (index == _icons.length - 1) {
|
||||
await Clipboard.setData(ClipboardData(text: _text));
|
||||
if (!mounted) return;
|
||||
// 오버레이를 닫고 알린다 — 열린 채로 띄우면 스낵바가 장막 뒤에 가린다.
|
||||
Navigator.of(context).pop();
|
||||
toast(widget.link == null
|
||||
? l10n.briefingShareTextCopied
|
||||
: l10n.briefingShareCopied);
|
||||
return;
|
||||
}
|
||||
final file = await _capture();
|
||||
await (widget.share ?? SharePlus.instance.share)(ShareParams(
|
||||
text: _text,
|
||||
subject: 'AI DAILY BRIEFING',
|
||||
files: file == null ? null : [file],
|
||||
sharePositionOrigin: origin,
|
||||
));
|
||||
} catch (_) {
|
||||
toast(l10n.commonRequestFailed);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = [
|
||||
context.l10n.shareInstagram,
|
||||
context.l10n.shareTiktok,
|
||||
'X',
|
||||
// 웹 브릿지 주소가 없으면 링크가 없으니 「문구 복사」다.
|
||||
widget.link == null ? context.l10n.shareCopyText : context.l10n.shareCopyLink,
|
||||
];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 23, 20, 22),
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: const Color(0xFF101010),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
icon: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 공유 이미지로 굽는 영역 — 제목 · 행성 · 헤드라인.
|
||||
RepaintBoundary(
|
||||
key: _card,
|
||||
child: ColoredBox(
|
||||
color: const Color(0xFF101010),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'AI DAILY BRIEFING',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Image.asset(
|
||||
'assets/figma/planet-home.png',
|
||||
width: 120,
|
||||
height: 120,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, item)
|
||||
in widget.items.take(3).indexed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppSpace.s2,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Text('${i + 1}', style: _row),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(item.headline, style: _row),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final (i, asset) in _icons.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 16),
|
||||
Tooltip(
|
||||
message: labels[i],
|
||||
child: Builder(
|
||||
builder: (button) => InkResponse(
|
||||
onTap: () => _onTap(i, button),
|
||||
radius: 26,
|
||||
child: Image.asset(asset, width: 36, height: 36),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _row = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: AppColors.onCosmicVariant,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../home_data.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// HOT ARCHIVE — 진행 중인 캠페인 한 건.
|
||||
///
|
||||
/// Figma 「다이브짱 / 92% 달성 / 강남역 초대형 LED 광고 / 48 · 24 · 82」.
|
||||
/// 정책 설계서 §P-09 — 실패 시 100% 자동 환불이 이 기능의 검수 기준이다.
|
||||
class CampaignCard extends StatelessWidget {
|
||||
const CampaignCard({super.key, required this.campaign, this.onTap});
|
||||
|
||||
final CampaignHighlight campaign;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Material(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 시안 HOT ARCHIVE 카드(252:159447) — 위에 이미지 342×228.
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 342 / 228,
|
||||
child: Image.asset('assets/figma/campaign-sample.png', fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Row(
|
||||
children: [
|
||||
Text(campaign.authorName, style: t.bodySmall),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpace.s2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicAccent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.campaignAchieved('${campaign.achievedPercent}'),
|
||||
style: t.labelSmall?.copyWith(
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Text(campaign.title, style: t.titleMedium),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(
|
||||
campaign.description,
|
||||
style: t.bodySmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: LinearProgressIndicator(
|
||||
value: (campaign.achievedPercent / 100).clamp(0.0, 1.0),
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.cosmicSurfaceRaised,
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Row(
|
||||
children: [
|
||||
_Stat(icon: Icons.people_outline, value: campaign.supporters),
|
||||
const SizedBox(width: AppSpace.s4),
|
||||
_Stat(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
value: campaign.comments,
|
||||
),
|
||||
const SizedBox(width: AppSpace.s4),
|
||||
_Stat(icon: Icons.favorite_border, value: campaign.likes),
|
||||
const Spacer(),
|
||||
Text(
|
||||
context.l10n.campaignView,
|
||||
style: t.bodySmall?.copyWith(color: AppColors.cosmicAccent),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 16,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Stat extends StatelessWidget {
|
||||
const _Stat({required this.icon, required this.value});
|
||||
|
||||
final IconData icon;
|
||||
final int value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppColors.onCosmicVariant),
|
||||
const SizedBox(width: AppSpace.s1),
|
||||
Text('$value', style: Theme.of(context).textTheme.labelSmall),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../home_data.dart';
|
||||
import '../home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 서버가 제공한 두 성장 조건을 각각 표시한다. 미조회는 0으로 보이지 않는다.
|
||||
class GrowthConditions extends StatelessWidget {
|
||||
const GrowthConditions({super.key, required this.growth, this.style});
|
||||
final PlanetGrowth growth;
|
||||
|
||||
/// 없으면 bodySmall. HOME 성장 카드는 사본의 14 흰색을 준다.
|
||||
final TextStyle? style;
|
||||
|
||||
String _value(num? value) => value == null
|
||||
? '—'
|
||||
: value == value.roundToDouble()
|
||||
? formatPop(value.toInt())
|
||||
: value.toString();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => growth.stage == 6
|
||||
? Text(
|
||||
context.l10n.growthFinalReached,
|
||||
style: style ?? Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final row in [
|
||||
(growth.popConditionLabel, growth.currentPop, growth.requiredPop),
|
||||
// 성운은 POP + 창립 멤버(B-10), 정식 행성은 POP + sFPI(B-11) 동시 충족이다.
|
||||
growth.isNebula
|
||||
? (
|
||||
growth.memberConditionLabel,
|
||||
growth.members,
|
||||
growth.requiredMembers,
|
||||
)
|
||||
: (
|
||||
growth.sfpiConditionLabel,
|
||||
growth.currentSfpi,
|
||||
growth.requiredSfpi,
|
||||
),
|
||||
])
|
||||
Text(
|
||||
'${row.$1} · ${_value(row.$2)} / ${_value(row.$3)}',
|
||||
style: style ?? Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (growth.isNebula && growth.nebulaGoalPassed)
|
||||
Text(
|
||||
context.l10n.growthNebulaGoalPassed,
|
||||
style: style ?? Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
import '../../../core/ui/planet_fx_scope.dart';
|
||||
import '../../galaxy/galaxy_data.dart';
|
||||
import '../../galaxy/zap_controller.dart';
|
||||
import '../../galaxy/widgets/zap_button.dart';
|
||||
import '../../galaxy/widgets/zap_feedback.dart';
|
||||
import '../../../core/ui/live_stream.dart';
|
||||
import '../../../core/ui/hero_time_status.dart';
|
||||
import 'growth_conditions.dart';
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../../../core/ui/live_planet.dart';
|
||||
import '../../../core/data/planet_visual_state.dart';
|
||||
import '../home_data.dart';
|
||||
import '../home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 홈 상단 히어로 — Figma 사본 `252:159354` 홈의 `Frame 1984081117`.
|
||||
///
|
||||
/// 배치는 사본의 기본 상태 변형(`343:205364` 등 ZAP 이 닫힌 홈)을 따른다. 배경은 상태
|
||||
/// 표시줄 포함 680 높이, 그 아래(124~680)에 「아바타·문구 → 48 → 행성 212 → 48 → 성장
|
||||
/// 카드」를 세로 가운데로 쌓는다(`Frame 1984081116` · gap 48 · CENTER). 원형 ZAP 은 카드
|
||||
/// 아래 변에 중심을 걸치므로 히어로 상자를 배경보다 24 길게 잡아 누를 자리를 넣는다.
|
||||
///
|
||||
/// 문구 규칙(Figma 명세) — *「{닉네임} 님의」 작게 · 「{POP} POP 기여로 / 꾸준하게
|
||||
/// 성장하고 있어요.」 크게 두 줄*. 텍스트는 왼쪽 정렬, 행성은 가운데 정렬이다.
|
||||
class HomeHero extends StatefulWidget {
|
||||
const HomeHero({
|
||||
super.key,
|
||||
required this.growth,
|
||||
this.fandomId = '',
|
||||
this.watchPlanet = noPlanetWatch,
|
||||
this.onHeroParticipate,
|
||||
this.onOpenHistory,
|
||||
this.onOpenPlanet,
|
||||
this.zap,
|
||||
this.zapData,
|
||||
this.ticker,
|
||||
this.onBirth,
|
||||
this.onCreateNebula,
|
||||
this.onJoin,
|
||||
this.unjoined = false,
|
||||
this.onItem,
|
||||
this.miniMap,
|
||||
});
|
||||
|
||||
final VoidCallback? onOpenHistory;
|
||||
|
||||
/// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다.
|
||||
final VoidCallback? onOpenPlanet;
|
||||
final String fandomId;
|
||||
final PlanetWatch watchPlanet;
|
||||
final VoidCallback? onHeroParticipate;
|
||||
|
||||
final PlanetGrowth growth;
|
||||
final ZapController? zap;
|
||||
final GalaxySnapshot? zapData;
|
||||
|
||||
/// 헤더 바로 아래(y102 · 높이 22) 라이브 띠. 없으면 자리도 비운다.
|
||||
final Widget? ticker;
|
||||
|
||||
/// 성운 상태에서 성운을 누를 때 — 행성 생성(F1-04)으로 간다. 전환 조건(B-10)을 둘 다
|
||||
/// 채웠을 때만 불린다.
|
||||
final VoidCallback? onBirth;
|
||||
|
||||
/// 소속이 없는 첫 사용자 홈(F1-05)의 「+」 시트 — 행성 생성(성운) · 행성 가입.
|
||||
final VoidCallback? onCreateNebula;
|
||||
final VoidCallback? onJoin;
|
||||
|
||||
/// 소속 팬덤이 없는 첫 사용자 상태(`343:212939`) — [HomeSnapshot.fandomId] 가 빈 경우.
|
||||
final bool unjoined;
|
||||
|
||||
/// ZAP 옵션 「아이템」 — 사본 「아이템 선택」 시트(`343:204868`).
|
||||
final VoidCallback? onItem;
|
||||
|
||||
/// GALAXY 미니맵 — 행성 영역 우측 상단(해석 확인 요청서 Q-DS-06 권고안 A · 회신 전 임시).
|
||||
/// 사본 홈에는 없는 요소라 행성 칸 모서리에만 얹고 문구·카드와 겹치지 않게 한다.
|
||||
final Widget? miniMap;
|
||||
|
||||
/// 사본 기준 배경 높이(상태 표시줄 48 포함).
|
||||
static const double designHeight = 680;
|
||||
|
||||
/// 배경 아래로 ZAP 원이 걸쳐 내려오는 몫. 다음 구획의 위 여백(24)과 같다.
|
||||
static const double zapOverhang = 24;
|
||||
|
||||
@override
|
||||
State<HomeHero> createState() => _HomeHeroState();
|
||||
}
|
||||
|
||||
class _HomeHeroState extends State<HomeHero> {
|
||||
final _fxKey = GlobalKey();
|
||||
final _planetKey = GlobalKey();
|
||||
final _buttonKey = GlobalKey();
|
||||
PlanetGrowth get growth => widget.growth;
|
||||
String get fandomId => widget.fandomId;
|
||||
PlanetWatch get watchPlanet => widget.watchPlanet;
|
||||
VoidCallback? get onHeroParticipate => widget.onHeroParticipate;
|
||||
|
||||
Offset _center(GlobalKey key) {
|
||||
final box = key.currentContext?.findRenderObject();
|
||||
final root = _fxKey.currentContext?.findRenderObject();
|
||||
return box is RenderBox && box.hasSize && root is RenderBox
|
||||
? box.localToGlobal(box.size.center(Offset.zero), ancestor: root)
|
||||
: Offset.zero;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 사본 좌표는 상태 표시줄 48 을 포함한다. 기기 상단 여백으로 바꿔 쓴다.
|
||||
final top = MediaQuery.paddingOf(context).top - 48;
|
||||
final background = top + HomeHero.designHeight;
|
||||
const planet = 212.0;
|
||||
final hasZap =
|
||||
widget.zap != null &&
|
||||
widget.zapData?.joined == true &&
|
||||
widget.zapData?.mine?.id == fandomId;
|
||||
|
||||
return PlanetFxScope(
|
||||
child: SizedBox(
|
||||
height: background + HomeHero.zapOverhang,
|
||||
child: Stack(
|
||||
key: _fxKey,
|
||||
children: [
|
||||
// 1. 우주 배경 — 시안 `252:159356`. 아래로 갈수록 화면 배경색으로 잠긴다.
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: background,
|
||||
child: LivePlanetLighting(
|
||||
fandomId: fandomId,
|
||||
watch: watchPlanet,
|
||||
child: Image.asset(
|
||||
'assets/figma/bg-space-home.png',
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2. 문구 · 행성 · 성장 카드 — `Frame 1984081116`(y124~680, gap 48).
|
||||
// 사본은 가운데 정렬이지만 카드가 사본(151)보다 한 줄 길어 가운데로 두면
|
||||
// 문구가 위로 밀린다. 사본 좌표(문구 y130 · 행성 y263)에 고정해 위에서 쌓는다.
|
||||
Positioned(
|
||||
top: top + 124,
|
||||
left: AppSpace.s4,
|
||||
right: AppSpace.s4,
|
||||
height: HomeHero.designHeight - 124,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 첫 사용자 홈(`343:212939`)은 아바타가 y124, 나머지 상태는 y130.
|
||||
if (!widget.unjoined) const SizedBox(height: 6),
|
||||
_Contribution(growth: growth, joined: !widget.unjoined),
|
||||
const SizedBox(height: AppSpace.s7),
|
||||
// HERO TIME 안내·큰 글자처럼 아래가 길어지면 행성이 먼저 줄어든다.
|
||||
Flexible(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) => SizedBox(
|
||||
width: double.infinity,
|
||||
height: math.min(planet, box.maxHeight),
|
||||
child: widget.unjoined
|
||||
? _EmptyPlanet(
|
||||
onCreateNebula: widget.onCreateNebula,
|
||||
onJoin: widget.onJoin,
|
||||
)
|
||||
: growth.isNebula
|
||||
? _Nebula(
|
||||
key: _planetKey,
|
||||
// 탄생은 창립자만(Q-PN-01 A안) — 다른 멤버에게는 안내 · 탭이 없다.
|
||||
ready: growth.nebulaReady && growth.isFounder,
|
||||
onBirth: widget.onBirth,
|
||||
)
|
||||
: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: LivePlanet25D(
|
||||
onOpenHistory: widget.onOpenHistory,
|
||||
key: _planetKey,
|
||||
fandomId: fandomId,
|
||||
watch: watchPlanet,
|
||||
stage: growth.stage,
|
||||
image: 'assets/figma/planet-home.png',
|
||||
size: math.min(planet, box.maxHeight),
|
||||
glow: AppColors.neonViolet,
|
||||
),
|
||||
),
|
||||
if (widget.miniMap case final mini?)
|
||||
Positioned(top: 0, right: 0, child: mini),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!widget.unjoined) ...[
|
||||
const SizedBox(height: AppSpace.s7),
|
||||
HeroTimeStatus(
|
||||
fandomId: fandomId,
|
||||
watch: watchPlanet,
|
||||
onParticipate: hasZap ? null : onHeroParticipate,
|
||||
),
|
||||
_GrowthOverlayCard(
|
||||
growth: growth,
|
||||
onOpen: widget.onOpenPlanet,
|
||||
zapClearance: hasZap,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.ticker case final ticker?)
|
||||
Positioned(top: top + 102, left: 0, right: 0, child: ticker),
|
||||
|
||||
// 3. 원형 ZAP — 사본 `Frame 1984081111`. 중심이 카드 아래 변에 걸친다.
|
||||
if (widget.zapData case final data?)
|
||||
if (widget.zap case final zap?)
|
||||
if (data.joined && data.mine?.id == fandomId)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: LiveStreamBuilder<PlanetVisualState>(
|
||||
key: ValueKey(('home-zap', fandomId)),
|
||||
create: () => watchPlanet(fandomId),
|
||||
builder: (context, state) {
|
||||
final hero =
|
||||
state?.isHeroTime == true &&
|
||||
state?.heroEndsAt?.isAfter(DateTime.now()) ==
|
||||
true &&
|
||||
onHeroParticipate != null &&
|
||||
zap.pending == 0 &&
|
||||
!zap.sending &&
|
||||
!zap.needsRetry;
|
||||
return Column(
|
||||
key: _buttonKey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (zap.needsRetry)
|
||||
TextButton(
|
||||
onPressed: zap.retry,
|
||||
child: Text(context.l10n.zapRetrySame),
|
||||
),
|
||||
ZapButton(
|
||||
round: true,
|
||||
showLabel: !growth.isNebula,
|
||||
onItem: widget.onItem,
|
||||
zapPop: data.zapPop,
|
||||
myPop:
|
||||
(zap.balance ?? data.myPop) -
|
||||
zap.pending * data.zapPop,
|
||||
isHeroTime: hero,
|
||||
blockedReason: zap.sending
|
||||
? context.l10n.zapApplying
|
||||
: zap.needsRetry
|
||||
? context.l10n.zapNeedsCheck
|
||||
: null,
|
||||
onZap: () => hero
|
||||
? onHeroParticipate!()
|
||||
: zap.tap(fandomId),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (widget.zap case final zap?)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ZapFeedback(
|
||||
touchSerial: zap.touchSerial,
|
||||
pending: zap.pending > 0 || zap.sending,
|
||||
result: zap.result,
|
||||
source: _center(_buttonKey),
|
||||
target: _center(_planetKey),
|
||||
radius:
|
||||
(_planetKey.currentContext?.findRenderObject()
|
||||
is RenderBox
|
||||
? (_planetKey.currentContext!.findRenderObject()
|
||||
as RenderBox)
|
||||
.size
|
||||
.shortestSide
|
||||
: planet) *
|
||||
0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 「BTS_Hong 님의 / 1,230 POP 기여로 꾸준하게 성장하고 있어요.」 — `Frame 1984081113`.
|
||||
///
|
||||
/// 아바타 26 원 + 9 + 닉네임(ABeeZee 12 흰색) 2 「님의」(42dot 12 #A3A2A0), 3 아래 문구
|
||||
/// (42dot Medium 20 · 행간 140%). 아바타 줄에는 검정 70% 4 그림자.
|
||||
class _Contribution extends StatelessWidget {
|
||||
const _Contribution({required this.growth, required this.joined});
|
||||
|
||||
final PlanetGrowth growth;
|
||||
|
||||
/// 소속 팬덤이 있는가. 없으면 첫 사용자 문구(`343:212939`)를 쓴다.
|
||||
final bool joined;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 성운·첫 사용자 상태의 「{닉네임} 님의」는 기여 여부와 무관한 인사 줄이다.
|
||||
if (growth.hasRecentContribution ||
|
||||
((!joined || growth.isNebula) &&
|
||||
growth.contributorName.trim().isNotEmpty)) ...[
|
||||
Row(
|
||||
children: [
|
||||
// 프로필 사진 자산이 데이터에 없어 이니셜로 그린다.
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.cosmicSurfaceRaised,
|
||||
boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)],
|
||||
),
|
||||
child: Text(
|
||||
growth.contributorName.characters.firstOrNull ?? '—',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(
|
||||
growth.contributorName,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 12,
|
||||
height: 1.3,
|
||||
letterSpacing: -0.3,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
context.l10n.heroOwnerSuffix,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.3,
|
||||
letterSpacing: -0.3,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
],
|
||||
Text(
|
||||
// 사본 문구의 「팬케이크」는 사용자 노출 브랜드라 「팬우주」로 바꾼다(공통 기준 4항).
|
||||
!joined
|
||||
? context.l10n.heroStartPrompt
|
||||
: growth.isNebula
|
||||
? context.l10n.heroNebulaPrompt
|
||||
: growth.hasRecentContribution
|
||||
? context.l10n.heroRecentContribution(formatPop(growth.contributedPop))
|
||||
: context.l10n.heroGrowingTogether,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
letterSpacing: -0.5,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 성장 카드 — 사본 기본 상태의 「BTS › / 행성 성장률 67% / 진행 바 / 6,270 / 10,000」
|
||||
/// (`Frame 1984081073` 343 폭 · `#040304` 40% · r12 · 패딩 12/16 · 간격 12 · 유리 흐림 4).
|
||||
///
|
||||
/// 둘째 줄 「POP 진행률」과 바 아래 두 조건 줄은 정책값이다 — growthPercent 는 POP 진행률
|
||||
/// 이고(전체 충족률 아님) 승급은 POP·sFPI 동시 충족이다(정책 설계서 §P-08 · UX·FX v1.5
|
||||
/// §2.1). 말풍선 꼬리는 바 채움 끝을 가리킨다(사본 `Group 563` 가운데 = 채움 끝 x238).
|
||||
class _GrowthOverlayCard extends StatelessWidget {
|
||||
const _GrowthOverlayCard({
|
||||
required this.growth,
|
||||
this.onOpen,
|
||||
this.zapClearance = false,
|
||||
});
|
||||
|
||||
final PlanetGrowth growth;
|
||||
final VoidCallback? onOpen;
|
||||
|
||||
/// 원형 ZAP(52)이 카드 아래 변에 걸친다. 둘째 조건 줄이 가려지지 않게 아래 여백을
|
||||
/// ZAP 윗변(아래 변 − 28) 위로 올린다. ZAP 좌표는 사본 그대로 둔다.
|
||||
final bool zapClearance;
|
||||
|
||||
static const _white14 = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 24 / 14,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = growth.popRatio;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
AppSpace.s3,
|
||||
AppSpace.s4,
|
||||
zapClearance ? 32 : AppSpace.s3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x66040304),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppFx.glassStroke),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) => Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onOpen,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
growth.fandomName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 32,
|
||||
height: 1.4,
|
||||
letterSpacing: -0.8,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onOpen != null)
|
||||
const ImageIcon(
|
||||
AppIcons.chevronRight,
|
||||
size: 24,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(context.l10n.heroPopProgress, style: _white14),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
growth.growthPercent == null
|
||||
? '—'
|
||||
: '${growth.growthPercent}%',
|
||||
style: _white14,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
_Bar(ratio: ratio ?? 0),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
GrowthConditions(
|
||||
growth: growth,
|
||||
style: _white14.copyWith(height: 16 / 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 말풍선 — 꼬리 끝이 바 위 9.5 에 닿는다(사본 581.5+40 → 바 631).
|
||||
// 성운 카드(`344:213809`)에는 말풍선이 없다.
|
||||
if (!growth.isNebula)
|
||||
Positioned(
|
||||
top: 45 + 24 + AppSpace.s3 - 49.5,
|
||||
left: (box.maxWidth * (ratio ?? 0.5)).clamp(
|
||||
84.0,
|
||||
box.maxWidth - 84,
|
||||
),
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, 0),
|
||||
child: _SpeechBubble(text: growth.growthStatus),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 진행 바 — 10 높이 · 바탕 검정 30% · 채움 `#D496C4 → #FF7DDD`.
|
||||
class _Bar extends StatelessWidget {
|
||||
const _Bar({required this.ratio});
|
||||
|
||||
final double ratio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x4D000000),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: ratio,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFD496C4), Color(0xFFFF7DDD)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 말풍선 — 사본 `Union`(검정 80% · 본체 30 + 아래 꼬리 10) · 42dot Medium 12 흰색.
|
||||
class _SpeechBubble extends StatelessWidget {
|
||||
const _SpeechBubble({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
static const _fill = Color(0xCC000000);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 30,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _fill,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
CustomPaint(
|
||||
size: const Size(10, 10),
|
||||
painter: _TailPainter(color: _fill),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _TailPainter extends CustomPainter {
|
||||
const _TailPainter({required this.color, this.up = false});
|
||||
final Color color;
|
||||
|
||||
/// 위를 가리키는 꼬리(말풍선이 대상 아래에 있을 때).
|
||||
final bool up;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final path = up
|
||||
? (Path()
|
||||
..moveTo(0, size.height)
|
||||
..lineTo(size.width, size.height)
|
||||
..lineTo(size.width / 2, 0)
|
||||
..close())
|
||||
: (Path()
|
||||
..moveTo(0, 0)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(size.width / 2, size.height)
|
||||
..close());
|
||||
canvas.drawPath(path, Paint()..color = color);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_TailPainter old) => old.color != color || old.up != up;
|
||||
}
|
||||
|
||||
/// 성운 — 사본 `홈_성장 완료`(`344:213808` 212). 두 전환 조건을 채우면 아래 말풍선
|
||||
/// 「성운을 클릭해서 / 행성으로 탄생 시켜보세요!」(`Group 563` 181×53 · 꼬리 위)가 뜨고
|
||||
/// 누르면 행성 생성으로 간다. 채우기 전에는 눌러도 아무 일이 없다(B-10 동시 충족).
|
||||
class _Nebula extends StatelessWidget {
|
||||
const _Nebula({super.key, required this.ready, this.onBirth});
|
||||
|
||||
final bool ready;
|
||||
final VoidCallback? onBirth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: SizedBox(
|
||||
width: 212,
|
||||
height: 212,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
// 사본 `Ellipse 40~45`(74×71 #D9D9D9) — 성운 뒤 왼쪽에 걸친 흰 원반.
|
||||
Positioned(
|
||||
left: -1,
|
||||
top: 69.5,
|
||||
child: Container(
|
||||
width: 74,
|
||||
height: 71,
|
||||
decoration: const ShapeDecoration(
|
||||
shape: OvalBorder(),
|
||||
color: Color(0xFFD9D9D9),
|
||||
),
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
button: ready && onBirth != null,
|
||||
label: context.l10n.heroNebula,
|
||||
child: GestureDetector(
|
||||
onTap: ready ? onBirth : null,
|
||||
child: Image.asset(
|
||||
'assets/figma/nebula-home.png',
|
||||
width: 212,
|
||||
height: 212,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (ready)
|
||||
Positioned(
|
||||
top: 177,
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: const Size(12, 9),
|
||||
painter: _TailPainter(
|
||||
color: const Color(0xCC000000),
|
||||
up: true,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 181,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC000000),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.heroNebulaTap,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 소속이 없는 첫 사용자 — 사본 `홈_초기사용자`(`Frame 1984081310`): 검정 원 190 안에
|
||||
/// 검정 40 상자(r8 · 분홍 빛 4) + 「+」. 누르면 「행성 생성 (성운) / 행성 가입 / 취소」 시트.
|
||||
class _EmptyPlanet extends StatelessWidget {
|
||||
const _EmptyPlanet({this.onCreateNebula, this.onJoin});
|
||||
|
||||
final VoidCallback? onCreateNebula;
|
||||
final VoidCallback? onJoin;
|
||||
|
||||
Future<void> _open(BuildContext context) async {
|
||||
final pick = await showModalBottomSheet<VoidCallback>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: const Color(0xB3000000),
|
||||
builder: (context) => _StartSheet(
|
||||
options: [
|
||||
(context.l10n.heroCreateNebula, onCreateNebula),
|
||||
(context.l10n.heroJoinPlanet, onJoin),
|
||||
],
|
||||
),
|
||||
);
|
||||
pick?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Container(
|
||||
width: 190,
|
||||
height: 190,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xB3000000), // `Ellipse 20` 검정 · 투명도 0.7
|
||||
),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: context.l10n.heroCreateOrJoin,
|
||||
child: GestureDetector(
|
||||
onTap: () => _open(context),
|
||||
// `Frame 1984081024` 에셋(40 상자 + 분홍 빛 4 여백 + 「+」).
|
||||
child: Image.asset(
|
||||
'assets/figma/home-plus-box.png',
|
||||
width: 48,
|
||||
height: 48,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본 `Frame 1984081055` — 좌우·아래 10 띄운 #2C2C2E r16 시트, 손잡이 36×4, 줄 높이 47.
|
||||
class _StartSheet extends StatelessWidget {
|
||||
const _StartSheet({required this.options});
|
||||
|
||||
final List<(String, VoidCallback?)> options;
|
||||
|
||||
static const _item = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.3,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
@override
|
||||
// 사본은 홈 인디케이터 위가 아니라 화면 아래에서 10 띄운다.
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
bottom: false,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(10, 0, 10, 10),
|
||||
padding: const EdgeInsets.fromLTRB(AppSpace.s4, 12, AppSpace.s4, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSheet,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4F4F4F),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final (label, action) in options)
|
||||
InkWell(
|
||||
onTap: () => Navigator.of(context).pop(action),
|
||||
child: SizedBox(
|
||||
height: 47,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(label, style: _item),
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: SizedBox(
|
||||
height: 47,
|
||||
child: Center(child: Text(context.l10n.commonCancel, style: _item)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../home_data.dart';
|
||||
import '../home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 인기 K-INSIDE 한 건 — Figma 「아미보이 / 10분 전 / 500 P / 제목 / 본문 / 3」.
|
||||
///
|
||||
/// 현상금(POP 바운티)이 붙은 Q&A 다. 정책 설계서 §P-11 — 자기 답변 채택은 막고
|
||||
/// (`E-QNA-01`), 기한 만료 시 현상금은 자동 환불한다(`E-QNA-02`).
|
||||
class InsideCard extends StatelessWidget {
|
||||
const InsideCard({super.key, required this.digest, this.onTap});
|
||||
|
||||
final InsideDigest digest;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Material(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(digest.authorName, style: t.bodySmall),
|
||||
const SizedBox(width: AppSpace.s2),
|
||||
Text(digest.timeAgo, style: t.labelSmall),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpace.s2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.cosmicAccent),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
'${formatPop(digest.bountyPop)} P',
|
||||
style: t.labelSmall?.copyWith(
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Text(digest.title, style: t.bodyMedium),
|
||||
const SizedBox(height: AppSpace.s1),
|
||||
Text(
|
||||
digest.body,
|
||||
style: t.bodySmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.mode_comment_outlined,
|
||||
size: 14,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
const SizedBox(width: AppSpace.s1),
|
||||
Text(
|
||||
context.l10n.insideAnswers('${digest.answers}'),
|
||||
style: t.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/backend.dart';
|
||||
import '../../../core/monetization/store.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../home_data.dart';
|
||||
import '../home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 아이템 시트 — Figma 사본 「아이템 구입」(헤더 지갑 · `252:160233`) ·
|
||||
/// 「아이템 선택」(ZAP 옵션 「아이템」 · `343:204868`).
|
||||
///
|
||||
/// 두 시트는 제목과 카드 아랫줄만 다르다 — 구입은 「보유 : N」 + 가격, 선택은 「보유 : N개」.
|
||||
/// 구입은 스토어 결제 → `fn-iap-verify` 검증 → 구매자 기여 POP · 팬덤 Drop(원장 B-84)이고
|
||||
/// 결과는 앱 전역 알림으로 온다([Store.messages]). 사본의 결제 시트(「앱 내 구입」)는 OS 결제
|
||||
/// 창이라 앱이 그리지 않는다. 구매가 곧 발동이라 보유 재고가 없어 「선택」(사용)은 준비 중이다.
|
||||
Future<void> showItemSheet(
|
||||
BuildContext context, {
|
||||
required List<ItemProduct> items,
|
||||
required bool purchase,
|
||||
String fandomId = '',
|
||||
}) => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
// 사본처럼 하단 탭 위를 덮는다.
|
||||
useRootNavigator: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: const Color(0xB3000000),
|
||||
builder: (_) =>
|
||||
_ItemSheet(items: items, purchase: purchase, fandomId: fandomId),
|
||||
);
|
||||
|
||||
class _ItemSheet extends StatelessWidget {
|
||||
const _ItemSheet({
|
||||
required this.items,
|
||||
required this.purchase,
|
||||
required this.fandomId,
|
||||
});
|
||||
|
||||
final List<ItemProduct> items;
|
||||
final bool purchase;
|
||||
final String fandomId;
|
||||
|
||||
void _say(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _tap(BuildContext context, ItemProduct item) async {
|
||||
if (purchase && fandomId.isEmpty) {
|
||||
// 결제 창을 열기 전에 막는다 — 서버도 소속 행성 없는 구매를 거부한다(E-PAY-01).
|
||||
_say(context, context.l10n.itemJoinFirst);
|
||||
return;
|
||||
}
|
||||
final store = Store.instance;
|
||||
if (!purchase || store == null || item.productId.isEmpty) {
|
||||
_say(context, purchase
|
||||
? context.l10n.itemBuyNotReady
|
||||
: context.l10n.itemUseNotReady);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await store.buy(item.productId);
|
||||
} on BackendException catch (error) {
|
||||
if (context.mounted) _say(context, error.message);
|
||||
} on Exception {
|
||||
if (context.mounted) _say(context, context.l10n.payOpenFailed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
// 사본 `Frame 1984081232` — #101010 · 위 r24 · 패딩 20/0 · 홈 인디케이터 자리까지 같은 색.
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.cosmicSurface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
20,
|
||||
AppSpace.s4,
|
||||
20 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
purchase ? context.l10n.itemBuy : context.l10n.itemSelect,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 사본 X 는 12 글리프 — 행 높이(22)를 늘리지 않도록 IconButton 대신 쓴다.
|
||||
Semantics(
|
||||
button: true,
|
||||
label: context.l10n.commonClose,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 20,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
if (items.isEmpty)
|
||||
SizedBox(
|
||||
height: 91,
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.commonComingSoon,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onCosmicMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final (i, item) in items.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: AppSpace.s4),
|
||||
_ItemCard(
|
||||
item: item,
|
||||
purchase: purchase,
|
||||
onTap: () => _tap(context, item),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본 `Button` — 343×91 · #1A1A1C · 1 테두리 #583118 · r8 · 패딩 8/12 · 간격 12.
|
||||
class _ItemCard extends StatelessWidget {
|
||||
const _ItemCard({
|
||||
required this.item,
|
||||
required this.purchase,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ItemProduct item;
|
||||
final bool purchase;
|
||||
final VoidCallback onTap;
|
||||
|
||||
static const _chip = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
);
|
||||
|
||||
Widget _tag(String text, Color fill, Color ink) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(text, style: _chip.copyWith(color: ink)),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
side: const BorderSide(color: Color(0xFF583118)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_tag(
|
||||
context.l10n.itemSendPop(formatPop(item.dropPop)),
|
||||
const Color(0xFF512F24),
|
||||
AppColors.cosmicOrange,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_tag(
|
||||
context.l10n.itemBuyerPop(formatPop(item.buyerPop)),
|
||||
const Color(0xFF204037),
|
||||
const Color(0xFF43AD86),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.description,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmicMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
purchase
|
||||
? context.l10n.itemOwned('${item.owned}')
|
||||
: context.l10n.itemOwnedCount('${item.owned}'),
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: purchase ? FontWeight.w500 : FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: purchase
|
||||
? const Color(0xFFDDDDDD)
|
||||
: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (purchase)
|
||||
Text(
|
||||
'₩${formatPop(item.priceWon)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
|
||||
/// 상단 라이브 띠 — Figma 「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」.
|
||||
///
|
||||
/// 사본 `Frame 1984081132` — 헤더 바로 아래 전폭 검정 띠(높이 22 · 패딩 4/0), 가운데
|
||||
/// 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의 표면이다.
|
||||
class LiveTicker extends StatelessWidget {
|
||||
const LiveTicker({super.key, required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
height: 22,
|
||||
color: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 14 / 12,
|
||||
letterSpacing: -0.3,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'growth_conditions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../home_data.dart';
|
||||
import '../home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 내 팬덤 행성의 성장 — Figma 홈의 「67% 행성 성장률 / 6,270 / 10,000」.
|
||||
///
|
||||
/// 정책 근거는 정책 설계서 §P-08. Level-up 은 POP 과 sFPI 를 **동시에** 충족해야
|
||||
/// 하므로 서버가 제공한 두 조건을 각각 표시한다(UX·FX v1.5 §2.1).
|
||||
class PlanetGrowthCard extends StatelessWidget {
|
||||
const PlanetGrowthCard({super.key, required this.growth});
|
||||
|
||||
final PlanetGrowth growth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final ratio = growth.popRatio;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.cosmicOutline),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 「BTS_Hong 님의 1,230 POP 기여로 꾸준하게 성장하고 있어요.」
|
||||
if (!growth.hasRecentContribution)
|
||||
Text(context.l10n.heroGrowingTogether, style: t.bodyMedium)
|
||||
else Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: growth.contributorName,
|
||||
style: t.bodyMedium?.copyWith(
|
||||
color: AppColors.cosmicAccent,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
TextSpan(text: context.l10n.growthOwnerOf, style: t.bodyMedium),
|
||||
TextSpan(
|
||||
text: '${formatPop(growth.contributedPop)} POP',
|
||||
style: t.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
TextSpan(text: context.l10n.growthSteadySuffix, style: t.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
growth.growthPercent == null ? '—' : '${growth.growthPercent}%',
|
||||
style: t.displayMedium?.copyWith(color: AppColors.cosmicAccent),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s1),
|
||||
child: Text(context.l10n.heroPopProgress, style: t.bodySmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio ?? 0,
|
||||
minHeight: 8,
|
||||
backgroundColor: AppColors.cosmicSurfaceRaised,
|
||||
valueColor: const AlwaysStoppedAnimation(AppColors.cosmicAccent),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
|
||||
GrowthConditions(growth: growth),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../home_data.dart';
|
||||
import '../home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../quest/widgets/time_attack_countdown.dart';
|
||||
|
||||
/// TODAY'S QUEST 한 줄 — Figma 「오늘의 출석체크 / 출석 완료 / +{보상} / 보상받기」.
|
||||
///
|
||||
/// 보상 숫자는 Figma 의 50 이 아니라 서버 지급액과 같은 원천에서 온다.
|
||||
///
|
||||
/// 상태별 오른쪽 버튼이 다르다. 정책 설계서 §P-05 — 조건 미충족 시 보상은
|
||||
/// 지급하지 않는다(`E-QST-01`).
|
||||
class QuestRow extends StatelessWidget {
|
||||
const QuestRow({super.key, required this.quest, this.onTap});
|
||||
|
||||
final QuestSummary quest;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Material(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(quest.title, style: t.bodyMedium),
|
||||
const SizedBox(height: AppSpace.s1),
|
||||
Text(_subtitle(quest), style: t.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s3),
|
||||
Text(
|
||||
_reward(quest),
|
||||
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s3),
|
||||
_Action(state: quest.state, onTap: onTap),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _subtitle(QuestSummary q) => switch (q.state) {
|
||||
QuestState.claimable => appL10n.questAttended,
|
||||
QuestState.inProgress => q.progressLabel ?? appL10n.questInProgressSpaced,
|
||||
QuestState.done => appL10n.questRewardPaid,
|
||||
};
|
||||
}
|
||||
|
||||
class _Action extends StatelessWidget {
|
||||
const _Action({required this.state, this.onTap});
|
||||
|
||||
final QuestState state;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return switch (state) {
|
||||
QuestState.claimable => FilledButton(
|
||||
onPressed: onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, AppSizing.touchMinApp),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
),
|
||||
child: Text(_claimLabel),
|
||||
),
|
||||
QuestState.inProgress => OutlinedButton(
|
||||
onPressed: onTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, AppSizing.touchMinApp),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
side: const BorderSide(color: AppColors.cosmicOutline),
|
||||
),
|
||||
child: Text(_startLabel),
|
||||
),
|
||||
QuestState.done => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2),
|
||||
child: Text(_doneLabel, style: t.bodySmall),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 사본 HOME 「TODAY'S QUEST」 버튼 문구(`252:159354` spec). 사본 HOME 에 없는 완료 상태는
|
||||
/// 퀘스트 화면(C 그룹)의 「보상 완료」와 맞춘다.
|
||||
String get _claimLabel => appL10n.questClaim;
|
||||
String get _startLabel => appL10n.questStart;
|
||||
String get _doneLabel => appL10n.questRewardDone;
|
||||
|
||||
/// 홈 「TODAY'S QUEST」 가로 카드 — 사본 `Frame 1984081083`(196×137 · #1A1A1C · r12 ·
|
||||
/// 패딩 12 · 간격 28): 제목 16 · 상태 14 #A3A2A0 · 오른쪽 「+보상」 20 #2AC28B, 아래 37 버튼
|
||||
/// (보상 가능 = 초록 #3EB58A 흰 글자 · 진행 중 = 흰 바탕 검정 글자). 누르면 퀘스트 목록으로 간다.
|
||||
class QuestCard extends StatelessWidget {
|
||||
const QuestCard({super.key, required this.quest, this.onTap});
|
||||
|
||||
final QuestSummary quest;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
static const width = 196.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, fill, ink) = switch (quest.state) {
|
||||
QuestState.claimable => (
|
||||
_claimLabel,
|
||||
const Color(0xFF3EB58A),
|
||||
AppColors.onCosmic,
|
||||
),
|
||||
QuestState.inProgress => (_startLabel, AppColors.onCosmic, Colors.black),
|
||||
QuestState.done => (
|
||||
_doneLabel,
|
||||
AppColors.cosmicSurfaceRaised,
|
||||
AppColors.onCosmicMuted,
|
||||
),
|
||||
};
|
||||
return Material(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s3),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
quest.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06).
|
||||
if (quest.endsAt case final endsAt?
|
||||
when quest.state != QuestState.done)
|
||||
TimeAttackCountdown(
|
||||
endsAt: endsAt,
|
||||
builder: (context, left) => _subtitleText(
|
||||
left == null
|
||||
? context.l10n.questTimeAttackClosed
|
||||
: context.l10n.questTimeAttackLeft(
|
||||
formatLeft(left),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
_subtitleText(QuestRow._subtitle(quest)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s2),
|
||||
Text(
|
||||
_reward(quest),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: Color(0xFF2AC28B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
height: 37,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _subtitleText(String text) => Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
);
|
||||
|
||||
/// 운영값을 못 읽었으면 숫자를 지어내지 않는다.
|
||||
String _reward(QuestSummary quest) =>
|
||||
quest.rewardPop == null ? 'POP' : '+${formatPop(quest.rewardPop!)}';
|
||||
Reference in New Issue
Block a user