HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -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!)}';