HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'home_data.dart';
|
||||
import 'home_screen.dart' show HomeHeader;
|
||||
import 'planet_create_screen.dart';
|
||||
import 'widgets/home_hero.dart';
|
||||
|
||||
/// 성장 완료 — Figma 사본 「홈_성장 완료」(`344:213778`).
|
||||
///
|
||||
/// 사본에서는 따로 된 페이지가 아니라 **HOME 탭의 성운 상태**다. HOME 은 서버 Stage 가
|
||||
/// 0(성운)이면 같은 [HomeHero] 성운 모드를 그리고, 이 화면은 그 상태를 값으로 직접 받아
|
||||
/// 띄운다(검수 대조용 입구).
|
||||
///
|
||||
/// 정책 근거는 정책 원장 B-10 — **10명 이상 + 1,000 POP 이상 두 조건을 모두** 채워야
|
||||
/// 정식 행성(Stage1)이 된다. 한쪽만 채우면 성운을 눌러도 행성 생성으로 가지 않는다.
|
||||
class GrowthCompleteScreen extends StatelessWidget {
|
||||
const GrowthCompleteScreen({
|
||||
super.key,
|
||||
this.nebulaName = 'BTS_RM',
|
||||
this.nickname = 'BTS_Hong',
|
||||
this.pop = 0,
|
||||
this.members = 12,
|
||||
this.requiredMembers = 10,
|
||||
this.contributedPop = 1240,
|
||||
this.requiredPop = 1000,
|
||||
this.isFounder = true,
|
||||
});
|
||||
|
||||
final String nebulaName;
|
||||
|
||||
/// 헤더 아래 「{닉네임} 님의」.
|
||||
final String nickname;
|
||||
|
||||
/// 헤더의 내 활동 POP.
|
||||
final int pop;
|
||||
final int members;
|
||||
final int requiredMembers;
|
||||
final int contributedPop;
|
||||
final int requiredPop;
|
||||
|
||||
/// 이 성운을 만든 창립자인가 — 탄생 안내는 창립자에게만 뜬다(Q-PN-01 A안).
|
||||
final bool isFounder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final growth = PlanetGrowth(
|
||||
stage: 0,
|
||||
fandomName: nebulaName,
|
||||
contributorName: nickname,
|
||||
contributedPop: contributedPop,
|
||||
growthPercent: requiredPop <= 0
|
||||
? null
|
||||
: (contributedPop * 100 / requiredPop).clamp(0, 100).round(),
|
||||
currentPop: contributedPop,
|
||||
requiredPop: requiredPop,
|
||||
members: members,
|
||||
requiredMembers: requiredMembers,
|
||||
isFounder: isFounder,
|
||||
);
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Stack(
|
||||
children: [
|
||||
HomeHero(
|
||||
growth: growth,
|
||||
fandomId: nebulaName,
|
||||
onBirth: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlanetCreateScreen(nebulaName: nebulaName),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: HomeHeader(pop: pop),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
/// 홈 화면이 그리는 값의 형태.
|
||||
///
|
||||
/// **화면과 데이터를 갈라 둔 이유** — 서버가 아직 없다. Firestore 연동은 5주차부터이고
|
||||
/// 그때 [HomeRepository] 구현체만 갈아 끼우면 화면은 손대지 않는다. 지금 화면 안에
|
||||
/// 숫자를 박아 두면 그 시점에 화면을 다시 짜야 한다.
|
||||
///
|
||||
/// 값의 근거는 Figma `03_Wireframe` 「홈」(`252:159354`)이다. 시연용 표본이며
|
||||
/// 정책값이 아니다 — 정책은 `정책 설계서 최종본 v1.0` 이 정한다.
|
||||
library;
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 팬덤 행성의 성장 상태. 정책 근거는 정책 설계서 §P-08.
|
||||
class PlanetGrowth {
|
||||
const PlanetGrowth({
|
||||
this.stage,
|
||||
this.currentSfpi,
|
||||
this.requiredSfpi,
|
||||
this.members,
|
||||
this.requiredMembers,
|
||||
this.nebulaGoalPassed = false,
|
||||
this.isFounder = false,
|
||||
required this.fandomName,
|
||||
required this.contributorName,
|
||||
required this.contributedPop,
|
||||
required this.growthPercent,
|
||||
required this.currentPop,
|
||||
required this.requiredPop,
|
||||
});
|
||||
|
||||
/// 서버 팬덤 Stage 0~6. 누락된 단계는 추정하지 않는다.
|
||||
final int? stage;
|
||||
final String fandomName;
|
||||
|
||||
/// 직전 기여자. 「BTS_Hong 님의 1,230 POP 기여로 …」 문구에 쓴다.
|
||||
final String contributorName;
|
||||
final int contributedPop;
|
||||
|
||||
bool get hasRecentContribution =>
|
||||
contributedPop > 0 && contributorName.trim().isNotEmpty;
|
||||
|
||||
/// 0~100 POP 진행률. 전체 성장 조건의 충족률은 아니다.
|
||||
final int? growthPercent;
|
||||
|
||||
/// 다음 Stage 진입까지의 누적 진행도. 정책 설계서 §P-08 은 POP 과 sFPI 동시 충족이라
|
||||
/// 정한다. 조건 누락은 null이며 개인 기여량으로 대체하지 않는다.
|
||||
final num? currentPop;
|
||||
final int? requiredPop;
|
||||
final num? currentSfpi;
|
||||
final num? requiredSfpi;
|
||||
|
||||
/// 성운(Stage0)의 창립 멤버 수와 전환 기준 인원. 정책 원장 B-10 — 성운은 **10명 이상 +
|
||||
/// 1,000 POP 이상**을 동시에 채워야 정식 행성(Stage1)이 된다. 기준값이 서버에서 오지
|
||||
/// 않으면 null 이며 정책 수치로 채우지 않는다.
|
||||
final int? members;
|
||||
final int? requiredMembers;
|
||||
|
||||
/// 성운의 목표 기간(기본 7일)이 지났지만 전환은 여전히 열려 있는가 — 해석 확인 요청 Q-PN-02
|
||||
/// 권고 A안. 남은 일수 대신 「목표 기간이 지났어요 · 조건을 채우면 …」를 보인다.
|
||||
final bool nebulaGoalPassed;
|
||||
|
||||
/// 내가 이 성운을 만든 창립자(`fandoms.founderUid`)인가 — 해석 확인 요청 Q-PN-01 A안.
|
||||
/// 탄생(형태 · 컬러 선택)은 창립자만 한다. 서버(`fn-planet-create`)도 같은 검사를 한다.
|
||||
final bool isFounder;
|
||||
|
||||
/// 성운 단계인가(B-08 Stage0 / Pre-Planet).
|
||||
bool get isNebula => stage == 0;
|
||||
|
||||
/// 성운 → 행성 전환 조건을 둘 다 채웠는가. 하나라도 모르면 채우지 않은 것으로 본다.
|
||||
bool get nebulaReady =>
|
||||
isNebula &&
|
||||
_met(currentPop, requiredPop) == true &&
|
||||
_met(members, requiredMembers) == true;
|
||||
|
||||
/// 조건 미조회와 실제 0을 구분한다. 승급 자체는 서버 확정으로만 처리한다.
|
||||
String get popConditionLabel => _condition('POP', currentPop, requiredPop);
|
||||
String get sfpiConditionLabel =>
|
||||
_condition('sFPI', currentSfpi, requiredSfpi);
|
||||
String get memberConditionLabel =>
|
||||
_condition(appL10n.growthFounders, members, requiredMembers);
|
||||
String get growthStatus => stage == 6
|
||||
? appL10n.growthFinalStage
|
||||
: _met(currentPop, requiredPop) == true &&
|
||||
_met(currentSfpi, requiredSfpi) == true
|
||||
? appL10n.growthPromotionPending
|
||||
: appL10n.growthCheckConditions;
|
||||
|
||||
double? get popRatio =>
|
||||
currentPop == null || requiredPop == null || requiredPop! <= 0
|
||||
? null
|
||||
: (currentPop! / requiredPop!).clamp(0.0, 1.0);
|
||||
|
||||
/// 조건 충족 여부. 값을 모르면 null.
|
||||
static bool? _met(num? current, num? required) {
|
||||
if (current == null ||
|
||||
required == null ||
|
||||
required < 0 ||
|
||||
!current.isFinite ||
|
||||
!required.isFinite)
|
||||
return null;
|
||||
return current >= required;
|
||||
}
|
||||
|
||||
static String _condition(String name, num? current, num? required) =>
|
||||
switch (_met(current, required)) {
|
||||
null => appL10n.growthConditionChecking(name),
|
||||
true => appL10n.growthConditionMet(name),
|
||||
false => appL10n.growthConditionUnmet(name),
|
||||
};
|
||||
}
|
||||
|
||||
/// 오늘의 퀘스트 한 줄.
|
||||
enum QuestState {
|
||||
/// 조건을 채웠고 보상을 아직 받지 않았다.
|
||||
claimable,
|
||||
|
||||
/// 진행 중.
|
||||
inProgress,
|
||||
|
||||
/// 보상까지 받았다.
|
||||
done,
|
||||
}
|
||||
|
||||
class QuestSummary {
|
||||
const QuestSummary({
|
||||
required this.title,
|
||||
required this.rewardPop,
|
||||
required this.state,
|
||||
this.progressLabel,
|
||||
this.id = '',
|
||||
this.endsAt,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
/// 보상 POP — 서버 지급액과 같은 원천. 못 읽으면 null.
|
||||
final int? rewardPop;
|
||||
final QuestState state;
|
||||
|
||||
/// 「0/1,500 걸음」 같은 진행 표기. 없으면 표시하지 않는다.
|
||||
final String? progressLabel;
|
||||
|
||||
/// `quests` 문서 ID. 타임어택 카드가 상세로 바로 들어갈 때 쓴다.
|
||||
final String id;
|
||||
|
||||
/// 타임어택 마감(`quests.activeTo`). 있으면 카드가 남은 시간을 센다.
|
||||
final DateTime? endsAt;
|
||||
}
|
||||
|
||||
/// AI Daily Briefing 구획 — 정책 원장 B-47 이 News 와 SNS 둘로 확정했다.
|
||||
///
|
||||
/// *News/SNS 각각 0 건이면 해당 섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*
|
||||
enum BriefingSection {
|
||||
news('News', 'NEWS'),
|
||||
sns('SNS', 'SNS');
|
||||
|
||||
const BriefingSection(this.key, this.label);
|
||||
|
||||
/// 서버 `briefings.section` 값. 대소문자까지 서버와 같아야 한다.
|
||||
final String key;
|
||||
|
||||
/// 구획 제목.
|
||||
final String label;
|
||||
|
||||
/// 서버 값에서 구획을 찾는다. 모르는 값은 null 이고 화면이 그 줄을 버린다 —
|
||||
/// 정책이 정한 둘 밖의 섹션을 임의로 그리면 근거 없는 구획이 생긴다.
|
||||
static BriefingSection? of(String key) {
|
||||
for (final section in values) {
|
||||
if (section.key == key) return section;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// AI Daily Briefing 한 줄 — 정책 원장 B-47 · B-48 · B-75.
|
||||
///
|
||||
/// **문안은 발주사 Agent 엔진이 만든다.** B-48 이 「외부 Agent 엔진은 발주사 영역,
|
||||
/// 저희 범위는 Candidate/Draft 수신 · Admin 승인 · 노출」로 확정했다.
|
||||
///
|
||||
/// **5 요소가 전부 필수다.** B-75 — *각 항목에 출처명·게시/발행시각·원본 URL·원문보기·*
|
||||
/// *AI 요약 표시를 제공합니다. 출처 확인 불가 AI 내용은 게시하지 않습니다.* 필드를
|
||||
/// 선택값으로 두면 메타가 빈 카드가 화면까지 내려오고, 그것이 곧 B-75 위반이다.
|
||||
/// 서버도 같은 이유로 수신 단계에서 거부한다(`fn-briefing-ingest`).
|
||||
///
|
||||
/// 화면 표시는 지금 제목 한 줄이다. 출처명·게시시각·AI 요약을 어디에 배치할지는
|
||||
/// 발주사가 HOME 기준으로 지정한 Figma 「REFINE v5A · HOME · Default」 확인 후
|
||||
/// 정한다 — 임의로 보조행을 넣지 않는다.
|
||||
class BriefingItem {
|
||||
const BriefingItem({
|
||||
required this.rank,
|
||||
required this.headline,
|
||||
required this.section,
|
||||
required this.sourceName,
|
||||
required this.sourceAt,
|
||||
required this.sourceUrl,
|
||||
required this.articleUrl,
|
||||
required this.aiSummary,
|
||||
});
|
||||
|
||||
/// 구획 안에서의 순서. 구획마다 1 부터 시작한다.
|
||||
final int rank;
|
||||
|
||||
/// 카드에 뜨는 제목.
|
||||
final String headline;
|
||||
|
||||
final BriefingSection section;
|
||||
|
||||
/// 출처명 — 언론사·계정명.
|
||||
final String sourceName;
|
||||
|
||||
/// 게시·발행 시각.
|
||||
final DateTime sourceAt;
|
||||
|
||||
/// 원본 URL.
|
||||
final String sourceUrl;
|
||||
|
||||
/// 원문보기 링크. 원본과 같을 수 있다.
|
||||
final String articleUrl;
|
||||
|
||||
/// AI 요약 본문.
|
||||
final String aiSummary;
|
||||
}
|
||||
|
||||
/// HOT ARCHIVE — 진행 중인 캠페인 한 건.
|
||||
class CampaignHighlight {
|
||||
const CampaignHighlight({
|
||||
required this.authorName,
|
||||
required this.achievedPercent,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.supporters,
|
||||
required this.comments,
|
||||
required this.likes,
|
||||
});
|
||||
|
||||
final String authorName;
|
||||
final int achievedPercent;
|
||||
final String title;
|
||||
final String description;
|
||||
final int supporters;
|
||||
final int comments;
|
||||
final int likes;
|
||||
}
|
||||
|
||||
/// 인기 K-INSIDE 한 건.
|
||||
class InsideDigest {
|
||||
const InsideDigest({
|
||||
required this.authorName,
|
||||
required this.timeAgo,
|
||||
required this.bountyPop,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.answers,
|
||||
});
|
||||
|
||||
final String authorName;
|
||||
final String timeAgo;
|
||||
|
||||
/// 현상금. 정책 설계서 §P-11 — 채택 기한은 아직 미확정이다.
|
||||
final int bountyPop;
|
||||
final String title;
|
||||
final String body;
|
||||
final int answers;
|
||||
}
|
||||
|
||||
/// 홈 한 화면이 필요로 하는 것 전부.
|
||||
class HomeSnapshot {
|
||||
const HomeSnapshot({
|
||||
required this.popBalance,
|
||||
required this.liveTicker,
|
||||
this.fandomId = '',
|
||||
required this.growth,
|
||||
required this.quests,
|
||||
required this.briefings,
|
||||
required this.campaign,
|
||||
required this.insides,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
/// 활동 POP 잔액. 정책 설계서 §P-02 — 활동/지원 원장은 분리되어 있고
|
||||
/// 홈 상단에는 활동 POP 을 띄운다.
|
||||
final int popBalance;
|
||||
|
||||
/// 내 소속 팬덤. 이클립스 경보 배너와 라이브 띠가 **어느 팬덤을 구독할지**를
|
||||
/// 이 값으로 정한다. 비어 있으면 소속이 없다는 뜻이라 둘 다 뜨지 않는다.
|
||||
final String fandomId;
|
||||
|
||||
/// 상단 라이브 띠의 **폴백** 문구. 원천은 `fandoms/{id}/liveEvents` 구독이고
|
||||
/// (별첨 1 ① 시나리오 5) 이 문자열은 라이브 이벤트가 아직 없을 때만 쓴다.
|
||||
/// `fandoms/{id}.liveTicker` 를 쓰는 서버 코드는 시드 스크립트뿐이다.
|
||||
final String? liveTicker;
|
||||
|
||||
final PlanetGrowth growth;
|
||||
final List<QuestSummary> quests;
|
||||
final List<BriefingItem> briefings;
|
||||
final CampaignHighlight? campaign;
|
||||
final List<InsideDigest> insides;
|
||||
|
||||
/// 헤더 지갑 · ZAP 「아이템」 시트의 상품. Admin Catalog(B-84 · B-85)
|
||||
/// `heroGiftProducts` 에서 온다.
|
||||
final List<ItemProduct> items;
|
||||
}
|
||||
|
||||
/// 아이템(Hero Gift) 상품 한 줄 — 사본 `아이템 구입`(`252:160233`) 카드.
|
||||
///
|
||||
/// 구매하면 구매자에게 기여 POP, 팬덤 Drop Pool 에 같은 수량의 일반 POP 이 생긴다
|
||||
/// (원장 B-84). 수치는 Admin 저장값이며 표본은 사본 문구다.
|
||||
class ItemProduct {
|
||||
const ItemProduct({
|
||||
this.productId = '',
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.dropPop,
|
||||
required this.buyerPop,
|
||||
required this.priceWon,
|
||||
required this.owned,
|
||||
});
|
||||
|
||||
/// 스토어 상품 ID(= Catalog 문서 ID). 표본은 비어 있다.
|
||||
final String productId;
|
||||
final String name;
|
||||
final String description;
|
||||
|
||||
/// 「N POP 전송」 — 팬덤 Drop Pool 에 생기는 일반 POP.
|
||||
final int dropPop;
|
||||
|
||||
/// 「구매자 +N POP」.
|
||||
final int buyerPop;
|
||||
final int priceWon;
|
||||
|
||||
/// 보유 수량.
|
||||
final int owned;
|
||||
}
|
||||
|
||||
/// 홈이 값을 얻어 오는 경로.
|
||||
///
|
||||
/// 5주차 Firestore 연동 때 이 인터페이스의 구현체만 바꾼다.
|
||||
abstract interface class HomeRepository {
|
||||
Future<HomeSnapshot> load();
|
||||
Stream<PlanetGrowth> watchGrowth(String fandomId);
|
||||
|
||||
/// 성운(Stage0) 생성 — `fn-nebula-create`(원장 B-08 · B-09). 만든 팬덤 ID 와, 대표 팬덤이
|
||||
/// 바뀌었는지(C-15 30일 대기 중이면 false — 성운만 생기고 대표는 그대로)를 돌려준다.
|
||||
Future<({String fandomId, bool primaryChanged})> createNebula(String name);
|
||||
|
||||
/// 성운 → 정식 행성 탄생 — `fn-planet-create`. 형태 · 컬러 두 값만 보낸다(C-50).
|
||||
Future<void> createPlanet({
|
||||
required String fandomId,
|
||||
required String planetType,
|
||||
required String colorKey,
|
||||
});
|
||||
|
||||
/// 성운 전환 목표 — 서버(`fn-nebula-create` · `fn-planet-create`)와 같은 `config/policy`
|
||||
/// 키(`nebulaMinPop` · `nebulaMinMembers` · `nebulaDays`). 읽지 못하면 [defaultNebulaTargets].
|
||||
Future<NebulaTargets> nebulaTargets();
|
||||
}
|
||||
|
||||
/// 성운 전환 목표 — POP · 창립 인원 · 목표 기간(일).
|
||||
typedef NebulaTargets = ({int pop, int members, int days});
|
||||
|
||||
/// 정책 원장 B-10 확정값. `config/policy` 가 없을 때의 출발점이다.
|
||||
const NebulaTargets defaultNebulaTargets = (pop: 1000, members: 10, days: 7);
|
||||
|
||||
/// `config/policy` 문서에서 성운 목표를 읽는다. 키가 없거나 잘못된 값이면 기본값.
|
||||
NebulaTargets nebulaTargetsFrom(Map<String, dynamic>? policy) {
|
||||
int read(String key, int fallback) => switch (policy?[key]) {
|
||||
num n when n.isFinite && n > 0 => n.toInt(),
|
||||
_ => fallback,
|
||||
};
|
||||
return (
|
||||
pop: read('nebulaMinPop', defaultNebulaTargets.pop),
|
||||
members: read('nebulaMinMembers', defaultNebulaTargets.members),
|
||||
days: read('nebulaDays', defaultNebulaTargets.days),
|
||||
);
|
||||
}
|
||||
|
||||
/// 서버가 붙기 전까지 쓰는 표본.
|
||||
///
|
||||
/// **정책값이 아니다.** 화면이 어떤 모양으로 서는지 확인하기 위한 것이며,
|
||||
/// 실제 수치는 수치표 v1.4 와 정책 설계서가 정한다.
|
||||
class SampleHomeRepository implements HomeRepository {
|
||||
const SampleHomeRepository();
|
||||
|
||||
@override
|
||||
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<NebulaTargets> nebulaTargets() async => defaultNebulaTargets;
|
||||
|
||||
@override
|
||||
Future<({String fandomId, bool primaryChanged})> createNebula(
|
||||
String name,
|
||||
) async => (fandomId: 'bts', primaryChanged: true);
|
||||
|
||||
@override
|
||||
Future<void> createPlanet({
|
||||
required String fandomId,
|
||||
required String planetType,
|
||||
required String colorKey,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
// 브리핑 표본이 `sourceAt` 에 DateTime 을 담아 const 로 둘 수 없다.
|
||||
Future<HomeSnapshot> load() async => HomeSnapshot(
|
||||
popBalance: 12540,
|
||||
fandomId: 'bts',
|
||||
liveTicker: '[LIVE] 아미보이님의 아이템 사용으로 버프가 발동되었습니다!',
|
||||
growth: PlanetGrowth(
|
||||
fandomName: 'BTS',
|
||||
contributorName: 'BTS_Hong',
|
||||
contributedPop: 1230,
|
||||
growthPercent: 67,
|
||||
currentPop: 6270,
|
||||
requiredPop: 10000,
|
||||
),
|
||||
quests: [
|
||||
QuestSummary(
|
||||
title: '오늘의 출석체크',
|
||||
// 서버 `config/policy.attendPop` 기본값과 맞춘다. Figma 의 50 이 아니다.
|
||||
rewardPop: 10,
|
||||
state: QuestState.claimable,
|
||||
),
|
||||
QuestSummary(
|
||||
title: '만보기',
|
||||
rewardPop: 150,
|
||||
state: QuestState.inProgress,
|
||||
progressLabel: '0/1,500 걸음',
|
||||
),
|
||||
],
|
||||
briefings: _sampleBriefings,
|
||||
campaign: CampaignHighlight(
|
||||
authorName: '다이브짱',
|
||||
achievedPercent: 92,
|
||||
title: '강남역 초대형 LED 광고',
|
||||
description: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.',
|
||||
supporters: 48,
|
||||
comments: 24,
|
||||
likes: 82,
|
||||
),
|
||||
// 사본 `아이템 구입` 표본(Admin 값 아님).
|
||||
items: const [
|
||||
ItemProduct(
|
||||
name: '베이직 드랍',
|
||||
description: '소소하지만 확실한 팬덤 기여',
|
||||
dropPop: 500,
|
||||
buyerPop: 100,
|
||||
priceWon: 1200,
|
||||
owned: 11,
|
||||
),
|
||||
ItemProduct(
|
||||
name: '메가 드랍',
|
||||
description: '팬덤의 열기를 달구는 강력한 화력',
|
||||
dropPop: 2000,
|
||||
buyerPop: 500,
|
||||
priceWon: 3900,
|
||||
owned: 10,
|
||||
),
|
||||
ItemProduct(
|
||||
name: '에픽 드랍',
|
||||
description: '전설적인 영웅의 등장, 압도적 물량',
|
||||
dropPop: 5000,
|
||||
buyerPop: 1500,
|
||||
priceWon: 8900,
|
||||
owned: 10,
|
||||
),
|
||||
],
|
||||
insides: [
|
||||
InsideDigest(
|
||||
authorName: '아미보이',
|
||||
timeAgo: '10분 전',
|
||||
bountyPop: 500,
|
||||
title: '화양연화 세계관 해석해주실 분?',
|
||||
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
|
||||
answers: 3,
|
||||
),
|
||||
InsideDigest(
|
||||
authorName: '다이브짱',
|
||||
timeAgo: '1시간 전',
|
||||
bountyPop: 200,
|
||||
title: '공방 신청 꿀팁 공유합니다.',
|
||||
body: '공방 신청할 때 폼 가장 빨리 제출하는 손크로 팁 알려드립! 궁금하신 분 질문 남겨주세요',
|
||||
answers: 8,
|
||||
),
|
||||
InsideDigest(
|
||||
authorName: '네누',
|
||||
timeAgo: '3시간 전',
|
||||
bountyPop: 100,
|
||||
title: 'BTS 이 포즈 뭔가요?',
|
||||
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
|
||||
answers: 8,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 브리핑 표본 — **정책값이 아니다.** B-75 5 요소를 전건 채워 두어 화면이 무엇을
|
||||
/// 받게 되는지 보인다. News 2 건 · SNS 1 건으로 구획 분기도 함께 확인된다.
|
||||
final List<BriefingItem> _sampleBriefings = [
|
||||
BriefingItem(
|
||||
rank: 1,
|
||||
section: BriefingSection.news,
|
||||
headline: 'NewJeans, 빌보드 ‘Women in Music’ 어워드 올해의 그룹상 수상 확정',
|
||||
sourceName: '연합뉴스',
|
||||
sourceAt: DateTime(2026, 9, 14, 8, 20),
|
||||
sourceUrl: 'https://example.com/news/newjeans-billboard',
|
||||
articleUrl: 'https://example.com/news/newjeans-billboard',
|
||||
aiSummary: '빌보드가 올해의 그룹상 수상자를 확정해 발표했습니다.',
|
||||
),
|
||||
BriefingItem(
|
||||
rank: 2,
|
||||
section: BriefingSection.news,
|
||||
headline: 'NewJeans, ‘Super Shy’, 스포티파이 글로벌 차트 장기 집권 중',
|
||||
sourceName: '스포티파이 차트',
|
||||
sourceAt: DateTime(2026, 9, 14, 7, 5),
|
||||
sourceUrl: 'https://example.com/news/super-shy-chart',
|
||||
articleUrl: 'https://example.com/news/super-shy-chart',
|
||||
aiSummary: '글로벌 차트 상위권을 여러 주 유지하고 있습니다.',
|
||||
),
|
||||
BriefingItem(
|
||||
rank: 1,
|
||||
section: BriefingSection.sns,
|
||||
headline: '버니즈를 위한 도쿄돔 팬미팅 비하인드 독점 공개 예정',
|
||||
sourceName: '공식 X 계정',
|
||||
sourceAt: DateTime(2026, 9, 14, 6, 40),
|
||||
sourceUrl: 'https://example.com/sns/tokyo-dome',
|
||||
articleUrl: 'https://example.com/sns/tokyo-dome',
|
||||
aiSummary: '팬미팅 비하인드 영상 공개 일정이 예고되었습니다.',
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'home_data.dart';
|
||||
import 'home_screen.dart' show HomeHeader;
|
||||
import 'widgets/home_hero.dart';
|
||||
|
||||
/// 초기 사용자 홈 — Figma 사본 「홈_초기사용자」(`343:212939`).
|
||||
///
|
||||
/// 사본에서는 **HOME 탭의 무소속 상태**다. HOME 은 소속 팬덤이 없으면(`fandomId` 가
|
||||
/// 비면) 같은 [HomeHero] 첫 사용자 모드를 그리고, 이 화면은 그 상태를 직접 띄운다.
|
||||
/// 「+」 를 누르면 「행성 생성 (성운) / 행성 가입 / 취소」 시트가 뜬다.
|
||||
class HomeOnboardingScreen extends StatelessWidget {
|
||||
const HomeOnboardingScreen({
|
||||
super.key,
|
||||
this.pop = 12540,
|
||||
this.nickname = 'BTS_Hong',
|
||||
this.onCreateNebula,
|
||||
this.onJoin,
|
||||
});
|
||||
|
||||
/// 헤더의 내 활동 POP.
|
||||
final int pop;
|
||||
|
||||
/// 헤더 아래 「{닉네임} 님의」.
|
||||
final String nickname;
|
||||
final VoidCallback? onCreateNebula;
|
||||
final VoidCallback? onJoin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Stack(
|
||||
children: [
|
||||
HomeHero(
|
||||
growth: PlanetGrowth(
|
||||
fandomName: '',
|
||||
contributorName: nickname,
|
||||
contributedPop: 0,
|
||||
growthPercent: null,
|
||||
currentPop: null,
|
||||
requiredPop: null,
|
||||
),
|
||||
unjoined: true,
|
||||
onCreateNebula: onCreateNebula,
|
||||
onJoin: onJoin,
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: HomeHeader(pop: pop),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp;
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/data/invite_code.dart';
|
||||
import '../../core/data/server_clock.dart';
|
||||
import 'home_data.dart';
|
||||
import '../quest/quest_data.dart' show QuestType;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 홈의 실 서버 구현 — 화면 대조 원장 F1.
|
||||
///
|
||||
/// [SampleHomeRepository] 와 같은 인터페이스라 화면은 어느 쪽이 붙었는지 모른다.
|
||||
/// 값의 출처만 표본에서 Firestore 로 바뀐다.
|
||||
///
|
||||
/// 성장 조건은 팬덤 공동 상태와 Stage 마스터에서 읽는다.
|
||||
/// 개인 멤버 기여량/개인 행성 Stage는 공동 성장으로 대체하지 않는다.
|
||||
class FirestoreHomeRepository implements HomeRepository {
|
||||
FirestoreHomeRepository(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
@override
|
||||
Stream<PlanetGrowth> watchGrowth(String fandomId) {
|
||||
if (fandomId.isEmpty) return const Stream.empty();
|
||||
return _backend.db
|
||||
.doc('fandoms/$fandomId')
|
||||
.snapshots(includeMetadataChanges: true)
|
||||
.where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites)
|
||||
.asyncMap((s) => _growth(s.data() ?? const {}, fandomId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<({String fandomId, bool primaryChanged})> createNebula(
|
||||
String name,
|
||||
) async {
|
||||
final data = await _backend.call('fnNebulaCreate', {'name': name});
|
||||
final fandomId = data['fandomId'];
|
||||
// ID 없이 성공으로 돌아오면 화면이 빈 ID 로 성운 상세를 연다 — 실패로 다룬다.
|
||||
if (fandomId is! String || fandomId.isEmpty) {
|
||||
throw BackendException('E-SYS-01', appL10n.nebulaCreateFailed);
|
||||
}
|
||||
return (
|
||||
fandomId: fandomId,
|
||||
primaryChanged: data['primaryChanged'] != false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> createPlanet({
|
||||
required String fandomId,
|
||||
required String planetType,
|
||||
required String colorKey,
|
||||
}) => _backend.call('fnPlanetCreate', {
|
||||
'fandomId': fandomId,
|
||||
'planetType': planetType,
|
||||
'colorKey': colorKey,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<NebulaTargets> nebulaTargets() async {
|
||||
try {
|
||||
return nebulaTargetsFrom(
|
||||
(await _backend.db.doc('config/policy').get()).data(),
|
||||
);
|
||||
} catch (_) {
|
||||
return defaultNebulaTargets;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HomeSnapshot> load() async {
|
||||
final uid = _backend.requireUid;
|
||||
// 공유 링크 `?ref=` 용 — 기다리지 않는다.
|
||||
InviteCode.ensure(_backend);
|
||||
final db = _backend.db;
|
||||
|
||||
final userSnap = await db.doc('users/$uid').get();
|
||||
final user = userSnap.data() ?? const <String, dynamic>{};
|
||||
final fandomId = user.strOr('fandomId');
|
||||
|
||||
// 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다.
|
||||
final walletF = db.doc('users/$uid/wallet/main').get();
|
||||
final fandomF = fandomId.isEmpty ? null : db.doc('fandoms/$fandomId').get();
|
||||
final questsF = db
|
||||
.collection('quests')
|
||||
.where('active', isEqualTo: true)
|
||||
.limit(5)
|
||||
.get();
|
||||
// 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에
|
||||
// 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`.
|
||||
// 질의는 단말 시계 기준 1시간 여유로 넓게 받고, 마감 판정은 아래에서 서버 시각으로 한다.
|
||||
// ponytail: 단말 시계가 1시간 넘게 빠르면 열린 창을 놓친다. 그때는 여유를 늘린다.
|
||||
final timeAttacksF = fandomId.isEmpty
|
||||
? null
|
||||
: db
|
||||
.collection('quests')
|
||||
.where('type', isEqualTo: 'timeattack')
|
||||
.where('fandomId', isEqualTo: fandomId)
|
||||
.where(
|
||||
'activeTo',
|
||||
isGreaterThan: Timestamp.fromDate(
|
||||
DateTime.now().subtract(const Duration(hours: 1)),
|
||||
),
|
||||
)
|
||||
.limit(10)
|
||||
.get();
|
||||
final progressF = db.collection('users/$uid/questProgress').get();
|
||||
// 출석 보상은 서버가 `config/policy.attendPop` 으로 지급한다. 같은 곳을 읽는다.
|
||||
final policyF = db.doc('config/policy').get();
|
||||
// 브리핑은 **게시된 당일분만** 읽는다 — 정책 원장 B-47 「당일 승인된 콘텐츠만
|
||||
// 노출 · 미승인 Draft 노출 0건 · 전일 콘텐츠 자동 재사용 없음」. 세 조건을
|
||||
// 질의에 두어 화면이 걸러 낼 것이 남지 않게 한다. 앱에서 거르면 초안이 기기에
|
||||
// 한 번 내려온 뒤에 지워지는 것이라 「노출 0건」이 아니다.
|
||||
final briefingsF = db
|
||||
.collection('briefings')
|
||||
.where('fandomId', isEqualTo: fandomId)
|
||||
.where('status', isEqualTo: 'published')
|
||||
.where('dayKey', isEqualTo: kstDayKey())
|
||||
// **섹션별로 rank 를 매기므로 정렬 키가 둘이다.** `rank` 만으로 자르면
|
||||
// News 3 건 + SNS 1 건일 때 rank 3 두 개가 경쟁해 News 셋째 줄이 잘린다.
|
||||
// 상한 6 은 두 구획 각 3 줄 기준이다(Figma 홈의 3 줄 × 구획 2).
|
||||
.orderBy('section')
|
||||
.orderBy('rank')
|
||||
.limit(6)
|
||||
.get();
|
||||
// 차단한 개설자의 캠페인은 건너뛴다 — 다섯 건을 읽어 첫 비차단 캠페인을 쓴다.
|
||||
final campaignF = db
|
||||
.collection('campaigns')
|
||||
.where('status', isEqualTo: 'funding')
|
||||
.orderBy('raisedPop', descending: true)
|
||||
.limit(5)
|
||||
.get();
|
||||
final blocksF = db.collection('users/$uid/blocks').get();
|
||||
// Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가
|
||||
// 결제 창을 열기 전에 막는다(`fn-iap-verify` 도 거부한다).
|
||||
final itemsF = db
|
||||
.collection('heroGiftProducts')
|
||||
.where('active', isEqualTo: true)
|
||||
.get();
|
||||
final insidesF = db
|
||||
.collection('questions')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.limit(3)
|
||||
.get();
|
||||
|
||||
final wallet = (await walletF).data() ?? const <String, dynamic>{};
|
||||
final fandom = fandomF == null
|
||||
? const <String, dynamic>{}
|
||||
: ((await fandomF).data() ?? const <String, dynamic>{});
|
||||
final timeAttacks = [
|
||||
for (final doc in (await timeAttacksF)?.docs ?? const [])
|
||||
if (doc.data()['active'] != false) doc,
|
||||
];
|
||||
if (timeAttacks.isNotEmpty) await ServerClock.ensure(_backend);
|
||||
final serverNow = ServerClock.now();
|
||||
final quests = [
|
||||
...timeAttacks.where((d) {
|
||||
final to = d.data()['activeTo'];
|
||||
return to is Timestamp && to.toDate().isAfter(serverNow);
|
||||
}),
|
||||
// 타임어택은 위에서 창이 열린 것만 받았다. 여기서 다시 받으면 마감된 것이 남는다.
|
||||
...(await questsF).docs.where((d) => d.data()['type'] != 'timeattack'),
|
||||
];
|
||||
final progress = (await progressF).docs;
|
||||
final briefings = (await briefingsF).docs;
|
||||
final blocked = {for (final d in (await blocksF).docs) d.id};
|
||||
final campaigns = (await campaignF).docs
|
||||
.where((d) => !blocked.contains(d.data()['creatorUid']))
|
||||
.toList();
|
||||
final insides = (await insidesF).docs;
|
||||
|
||||
final progressById = {for (final doc in progress) doc.id: doc.data()};
|
||||
final policy = (await policyF).data() ?? const <String, dynamic>{};
|
||||
|
||||
return HomeSnapshot(
|
||||
popBalance: wallet.intOr('activityBalance'),
|
||||
fandomId: fandomId,
|
||||
liveTicker: fandom.strOr('liveTicker').isEmpty
|
||||
? null
|
||||
: fandom.strOr('liveTicker'),
|
||||
growth: await _growth(fandom, fandomId),
|
||||
quests: [
|
||||
for (final doc in quests)
|
||||
_quest(doc.id, doc.data(), progressById[doc.id], policy),
|
||||
],
|
||||
briefings: [for (final doc in briefings) ?_briefing(doc.data())],
|
||||
campaign: campaigns.isEmpty ? null : _campaign(campaigns.first.data()),
|
||||
insides: [for (final doc in insides) _inside(doc.data())],
|
||||
items: [for (final doc in (await itemsF).docs) _item(doc.id, doc.data())],
|
||||
);
|
||||
}
|
||||
|
||||
/// 문서 ID 가 스토어 상품 ID 다. 수치는 Admin 저장값 그대로(B-84 · 환율 재계산 없음).
|
||||
ItemProduct _item(String productId, Map<String, dynamic> data) => ItemProduct(
|
||||
productId: productId,
|
||||
name: data.strOr('name', productId),
|
||||
description: data.strOr('description'),
|
||||
dropPop: data.intOr('dropPop'),
|
||||
buyerPop: data.intOr('buyerPop'),
|
||||
priceWon: data.intOr('priceWon'),
|
||||
// 구매 즉시 Drop 이 열려 보유 재고가 생기지 않는다.
|
||||
owned: 0,
|
||||
);
|
||||
|
||||
/// 브리핑 한 줄 — 정책 원장 B-75 5 요소가 전건 있어야 그린다.
|
||||
///
|
||||
/// **하나라도 비면 그 줄을 버린다.** 서버가 수신 단계에서 이미 거부하지만
|
||||
/// (`fn-briefing-ingest`), Admin 수동 편집이나 예전 스키마 문서가 섞일 수 있다.
|
||||
/// 여기서 통과시키면 출처가 빈 카드가 화면에 뜨고 그것이 B-75 위반이다.
|
||||
/// 섹션이 News·SNS 밖이면 정책이 정한 구획이 아니므로 역시 버린다.
|
||||
BriefingItem? _briefing(Map<String, dynamic> data) {
|
||||
final section = BriefingSection.of(data.strOr('section'));
|
||||
final headline = data.strOr('headline');
|
||||
final sourceName = data.strOr('sourceName');
|
||||
final sourceUrl = data.strOr('sourceUrl');
|
||||
final articleUrl = data.strOr('articleUrl');
|
||||
final aiSummary = data.strOr('aiSummary');
|
||||
final sourceAt = data['sourceAt'];
|
||||
if (section == null ||
|
||||
headline.isEmpty ||
|
||||
sourceName.isEmpty ||
|
||||
sourceUrl.isEmpty ||
|
||||
articleUrl.isEmpty ||
|
||||
aiSummary.isEmpty ||
|
||||
sourceAt == null) {
|
||||
return null;
|
||||
}
|
||||
return BriefingItem(
|
||||
rank: data.intOr('rank', 1),
|
||||
headline: headline,
|
||||
section: section,
|
||||
sourceName: sourceName,
|
||||
sourceAt: data.dateOr('sourceAt', DateTime.now()),
|
||||
sourceUrl: sourceUrl,
|
||||
articleUrl: articleUrl,
|
||||
aiSummary: aiSummary,
|
||||
);
|
||||
}
|
||||
|
||||
Future<PlanetGrowth> _growth(
|
||||
Map<String, dynamic> fandom,
|
||||
String fandomId,
|
||||
) async {
|
||||
final stage = switch (fandom['stage']) {
|
||||
int n when n >= 0 && n <= 6 => n,
|
||||
_ => null,
|
||||
};
|
||||
// Stage0의 탄생은 별도의 멤버/POP 조건이다. Stage1 마스터의 0으로 승급을 추정하지 않는다.
|
||||
final next = stage != null && stage > 0 && stage < 6
|
||||
? (await _backend.db.doc('planetStages/${stage + 1}').get()).data()
|
||||
: null;
|
||||
int? validInt(dynamic value) => value is int && value >= 0 ? value : null;
|
||||
num? validNumber(dynamic value) =>
|
||||
value is num && value.isFinite && value >= 0 ? value : null;
|
||||
final current = validNumber(fandom['growthPop']);
|
||||
final required = validInt(next?['requiredPop']);
|
||||
// 성운(Stage0)의 전환 목표는 서버와 같은 `config/policy` 키로 읽는다.
|
||||
final policy = stage == 0
|
||||
? (await _backend.db.doc('config/policy').get()).data()
|
||||
: null;
|
||||
final targets = stage == 0 ? nebulaTargetsFrom(policy) : null;
|
||||
final goal = targets?.pop ?? required;
|
||||
// Q-PN-02 — 기간이 지난 성운은 운영 설정이 막지 않는 한 조건만 채우면 탄생한다(서버와 같은 판정).
|
||||
final deadline = fandom['nebulaDeadline'];
|
||||
final goalPassed =
|
||||
stage == 0 &&
|
||||
deadline is Timestamp &&
|
||||
ServerClock.now().isAfter(deadline.toDate()) &&
|
||||
policy?['nebulaConversionDeadlineEnforced'] != true;
|
||||
return PlanetGrowth(
|
||||
stage: stage,
|
||||
fandomName: fandom.strOr('name', fandomId),
|
||||
// 기존 데이터에 이메일 형태가 남아 있어도 가린다.
|
||||
contributorName: fandom.containsKey('lastContributorName')
|
||||
? publicName(fandom['lastContributorName'])
|
||||
: '',
|
||||
contributedPop: fandom.intOr('lastContributedPop'),
|
||||
growthPercent: current != null && goal != null && goal > 0
|
||||
? ((current / goal) * 100).clamp(0, 100).round()
|
||||
: null,
|
||||
currentPop: current,
|
||||
requiredPop: goal,
|
||||
currentSfpi: validNumber(fandom['sfpi']),
|
||||
members: validInt(fandom['memberCount']),
|
||||
requiredMembers: targets?.members,
|
||||
// Q-PN-01 A안 — 탄생은 창립자만.
|
||||
isFounder: fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
|
||||
requiredSfpi: validNumber(next?['requiredSfpi']),
|
||||
nebulaGoalPassed: goalPassed,
|
||||
);
|
||||
}
|
||||
|
||||
QuestSummary _quest(
|
||||
String id,
|
||||
Map<String, dynamic> quest,
|
||||
Map<String, dynamic>? progress,
|
||||
Map<String, dynamic> policy,
|
||||
) {
|
||||
final done =
|
||||
(progress?.intOr('count') ?? 0) >= quest.intOr('dailyLimit', 1);
|
||||
final started = (progress?.intOr('count') ?? 0) > 0;
|
||||
return QuestSummary(
|
||||
title: quest.strOr('title'),
|
||||
rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
|
||||
state: done
|
||||
? QuestState.done
|
||||
: started
|
||||
? QuestState.claimable
|
||||
: QuestState.inProgress,
|
||||
progressLabel: quest.strOr('progressLabel').isEmpty
|
||||
? null
|
||||
: quest.strOr('progressLabel'),
|
||||
id: id,
|
||||
endsAt: quest['type'] == 'timeattack' && quest['activeTo'] is Timestamp
|
||||
? (quest['activeTo'] as Timestamp).toDate()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
CampaignHighlight _campaign(Map<String, dynamic> data) {
|
||||
// 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다.
|
||||
final goal = data.intOr('targetPop', data.intOr('goalPop'));
|
||||
final raised = data.intOr('raisedPop');
|
||||
return CampaignHighlight(
|
||||
authorName: publicName(data['creatorName'] ?? data['authorName']),
|
||||
achievedPercent: goal == 0 ? 0 : ((raised / goal) * 100).round(),
|
||||
title: data.strOr('title'),
|
||||
description: data.strOr('summary', data.strOr('description')),
|
||||
supporters: data.intOr('donorCount'),
|
||||
comments: data.intOr('commentCount'),
|
||||
likes: data.intOr('likeCount'),
|
||||
);
|
||||
}
|
||||
|
||||
InsideDigest _inside(Map<String, dynamic> data) {
|
||||
final created = data.dateOr('createdAt', DateTime.now());
|
||||
return InsideDigest(
|
||||
authorName: publicName(data['creatorName'] ?? data['authorName']),
|
||||
timeAgo: _timeAgo(created),
|
||||
bountyPop: data.intOr('bountyPop'),
|
||||
title: data.strOr('title'),
|
||||
body: data.strOr('body'),
|
||||
answers: data.intOr('answerCount'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 상대 시각. 화면이 「10분 전」 형태를 그대로 쓰므로 여기서 문자열로 만든다.
|
||||
String _timeAgo(DateTime at) {
|
||||
final gap = DateTime.now().difference(at);
|
||||
if (gap.inMinutes < 1) return appL10n.timeJustNow;
|
||||
if (gap.inHours < 1) return appL10n.timeMinutesAgo('${gap.inMinutes}');
|
||||
if (gap.inDays < 1) return appL10n.timeHoursAgo('${gap.inHours}');
|
||||
return appL10n.timeDaysAgo('${gap.inDays}');
|
||||
}
|
||||
|
||||
/// 오늘 날짜(KST `YYYY-MM-DD`) — 서버 `briefings.dayKey` 와 같은 규약이다.
|
||||
///
|
||||
/// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이
|
||||
/// 안 보인다. 브리핑은 한국 시간 기준 하루다.
|
||||
String kstDayKey([DateTime? at]) => (at ?? DateTime.now().toUtc())
|
||||
.toUtc()
|
||||
.add(const Duration(hours: 9))
|
||||
.toIso8601String()
|
||||
.substring(0, 10);
|
||||
@@ -1,15 +1,694 @@
|
||||
// formatPop 은 core/format.dart 로 옮겼다. 기존 `show formatPop` 가져오기를 위해 다시 내보낸다.
|
||||
export '../../core/format.dart' show formatPop;
|
||||
|
||||
import '../planet/planet_history_screen.dart';
|
||||
import '../galaxy/widgets/galaxy_mini_map.dart';
|
||||
import '../galaxy/galaxy_screen.dart';
|
||||
import '../galaxy/galaxy_data.dart';
|
||||
import '../galaxy/zap_controller.dart';
|
||||
import '../planet/hero_participation_sheet.dart';
|
||||
import '../planet/planet_data.dart';
|
||||
import '../planet/planet_detail_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/ui/tab_placeholder.dart';
|
||||
import '../../core/format.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/data/planet_visual_state.dart';
|
||||
import '../../core/ui/live_stream.dart';
|
||||
import '../../core/ui/section_header.dart';
|
||||
import '../eclipse/eclipse_data.dart';
|
||||
import '../eclipse/widgets/eclipse_banner.dart';
|
||||
import '../eclipse/widgets/live_proof.dart';
|
||||
import 'home_data.dart';
|
||||
import 'nebula_create_screen.dart';
|
||||
import 'planet_create_screen.dart';
|
||||
import 'widgets/briefing_list.dart';
|
||||
import 'widgets/campaign_card.dart';
|
||||
import 'widgets/home_hero.dart';
|
||||
import 'widgets/item_sheet.dart';
|
||||
import 'widgets/inside_card.dart';
|
||||
import 'widgets/live_ticker.dart';
|
||||
import '../campaign/campaign_data.dart' show CampaignRepository;
|
||||
import '../campaign/campaign_list_screen.dart';
|
||||
import '../notification/notification_screen.dart';
|
||||
import '../quest/quest_data.dart' show QuestRepository;
|
||||
import '../quest/quest_list_screen.dart';
|
||||
import '../quest/quest_detail_screen.dart';
|
||||
import 'home_repository_firestore.dart' show kstDayKey;
|
||||
import 'widgets/quest_row.dart';
|
||||
import 'widgets/briefing_share.dart';
|
||||
|
||||
/// HOME 탭 루트 — S-H-HOME 오늘의 퀘스트.
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
/// HOME 탭 루트 — Figma `03_Wireframe` 「홈」(`252:159354`).
|
||||
///
|
||||
/// 화면 프레임은 `252:159354` 다. 그 아래 `252:159361` 이하가 이 화면의 구성
|
||||
/// 요소이며, 화면 대조 원장은 프레임 번호로 화면을 셈한다.
|
||||
///
|
||||
/// 화면 대조 원장 F1 의 1번 화면이다. 구성 순서는 Figma 를 따른다 —
|
||||
/// 잔액 · 라이브 띠 · 행성 성장 · TODAY'S QUEST · AI Daily Briefing ·
|
||||
/// HOT ARCHIVE · 인기 K-INSIDE.
|
||||
///
|
||||
/// 값은 [HomeRepository] 가 준다. 지금은 [SampleHomeRepository] 라 서버 없이 뜨고,
|
||||
/// 5주차 Firestore 연동 때 구현체만 바꾼다.
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
this.watchPlanet = noPlanetWatch,
|
||||
this.planetRepository,
|
||||
this.galaxyRepository,
|
||||
required this.repository,
|
||||
required this.questRepository,
|
||||
required this.eclipse,
|
||||
this.notifications = const SampleNotificationRepository(),
|
||||
this.campaignRepository,
|
||||
});
|
||||
|
||||
final HomeRepository repository;
|
||||
final PlanetWatch watchPlanet;
|
||||
final PlanetRepositoryFactory? planetRepository;
|
||||
final GalaxyRepository? galaxyRepository;
|
||||
|
||||
/// F4 로 넘겨줄 저장소. 홈이 퀘스트 화면을 여는 유일한 입구라, 여기서 받지 않으면
|
||||
/// 퀘스트 화면이 스스로 표본을 만들게 된다(저장소 선택은 repositories.dart 한 곳).
|
||||
final QuestRepository questRepository;
|
||||
|
||||
/// HOT ARCHIVE 로 넘겨줄 캠페인 저장소. null 이면 목록이 표본을 쓴다.
|
||||
final CampaignRepository? campaignRepository;
|
||||
|
||||
/// 이클립스 경보 배너와 라이브 띠의 원천. 저장소 선택은 [Repositories] 한 곳에서만
|
||||
/// 하므로(공통 규칙) 화면이 표본을 기본값으로 만들지 않는다.
|
||||
final EclipseRepository eclipse;
|
||||
|
||||
/// 헤더의 알림 입구가 여는 알림함(F0)의 저장소.
|
||||
final NotificationRepository notifications;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const TabPlaceholder(
|
||||
tab: 'HOME',
|
||||
screenId: 'S-H-HOME',
|
||||
screenName: '오늘의 퀘스트',
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
late Future<HomeSnapshot> _snapshot;
|
||||
Future<GalaxySnapshot>? _galaxy;
|
||||
ZapController? _zap;
|
||||
|
||||
void _onZapChanged() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_zap?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snapshot = widget.repository.load();
|
||||
if (widget.galaxyRepository case final repository?) {
|
||||
_galaxy = repository.load();
|
||||
_zap = ZapController(
|
||||
repository,
|
||||
onFailure: (message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
},
|
||||
)..addListener(_onZapChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// 성운·행성 생성 화면은 성공하면 팬덤 ID 를 들고 닫힌다. 그때만 다시 읽는다.
|
||||
Future<void> _createThenReload(Widget screen) async {
|
||||
final created = await Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<String>(builder: (_) => screen),
|
||||
);
|
||||
if (created == null || !mounted) return;
|
||||
if (widget.galaxyRepository case final repository?) {
|
||||
setState(() { _galaxy = repository.load(); });
|
||||
}
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final next = widget.repository.load();
|
||||
setState(() => _snapshot = next);
|
||||
await next;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
// 시안은 헤더가 우주 배경 위에 얹힌다. AppBar 를 쓰면 배경이 헤더 아래에서
|
||||
// 끊기므로 본문 안에 헤더 행을 그리고 Scaffold 는 배경만 준다.
|
||||
body: FutureBuilder<HomeSnapshot>(
|
||||
future: _snapshot,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
// 원인이 화면에 보이지 않으므로 로그로 남긴다 — adb logcat | grep '\[HOME\]'
|
||||
debugPrint('[HOME] load failed: ${snap.error}');
|
||||
return SafeArea(child: _LoadFailed(onRetry: _refresh));
|
||||
}
|
||||
return _HomeBody(
|
||||
data: snap.data!,
|
||||
galaxy: _galaxy,
|
||||
zap: _zap,
|
||||
repository: widget.repository,
|
||||
watchPlanet: widget.watchPlanet,
|
||||
planetRepository: widget.planetRepository,
|
||||
onRefresh: _refresh,
|
||||
onCreate: _createThenReload,
|
||||
questRepository: widget.questRepository,
|
||||
campaignRepository: widget.campaignRepository,
|
||||
eclipse: widget.eclipse,
|
||||
notifications: widget.notifications,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 히어로 위에 얹는 헤더 — Figma 홈 상단 「Fancake 로고 · POP 알약 · 알림」.
|
||||
/// 브랜드는 공통 기준 4항대로 FANUZU 다.
|
||||
/// 라이브 띠 한 자리. 스트림에 값이 오면 그 문구를, 없으면 폴백을 그린다.
|
||||
///
|
||||
/// 폴백을 남겨 둔 이유는 시드 데이터로 도는 목 모드와 옛 문서 때문이다. 둘 다
|
||||
/// 없으면 띠 자체를 접는다 — 빈 알약이 홈 상단에 남으면 고장으로 읽힌다.
|
||||
class _LiveTickerSlot extends StatelessWidget {
|
||||
const _LiveTickerSlot({
|
||||
required this.eclipse,
|
||||
required this.fandomId,
|
||||
required this.fallback,
|
||||
});
|
||||
|
||||
final EclipseRepository eclipse;
|
||||
final String fandomId;
|
||||
final String? fallback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<List<LiveEvent>>(
|
||||
key: ValueKey('live-$fandomId'),
|
||||
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
|
||||
create: () => fandomId.isEmpty
|
||||
? Stream.value(const <LiveEvent>[])
|
||||
: eclipse.liveEvents(fandomId: fandomId, limit: 5),
|
||||
builder: (context, events) {
|
||||
final latest = (events == null || events.isEmpty)
|
||||
? fallback
|
||||
: liveEventMessage(events.first);
|
||||
if (latest == null) return const SizedBox.shrink();
|
||||
return LiveTicker(text: latest);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 홈 헤더 — 성장 완료 · 첫 사용자 상태 화면도 같은 헤더를 쓴다.
|
||||
class HomeHeader extends StatelessWidget {
|
||||
const HomeHeader({
|
||||
super.key,
|
||||
required this.pop,
|
||||
this.notifications = const SampleNotificationRepository(),
|
||||
this.items = const [],
|
||||
this.fandomId = '',
|
||||
});
|
||||
|
||||
final int pop;
|
||||
final NotificationRepository notifications;
|
||||
|
||||
/// 소속 팬덤. 비었으면 Hero Gift 결제 창을 열지 않는다(Drop 을 열 곳이 없다).
|
||||
final String fandomId;
|
||||
|
||||
/// 지갑 아이콘이 여는 「아이템 구입」 시트의 상품.
|
||||
final List<ItemProduct> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
// 사본 `Top Navigation` — 높이 54 · 패딩 6/16 · 오른쪽 묶음은 알약 + 42 아이콘 두 개.
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 6),
|
||||
child: SizedBox(
|
||||
height: 42,
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'FANUZU',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.4,
|
||||
letterSpacing: -0.5,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_PopBalance(value: pop),
|
||||
IconButton(
|
||||
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Image(
|
||||
image: AssetImage('assets/figma/icon-wallet.png'),
|
||||
width: 42,
|
||||
height: 42,
|
||||
),
|
||||
// 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트.
|
||||
onPressed: () =>
|
||||
showItemSheet(
|
||||
context,
|
||||
items: items,
|
||||
purchase: true,
|
||||
fandomId: fandomId,
|
||||
),
|
||||
tooltip: context.l10n.homeItemsTooltip,
|
||||
),
|
||||
IconButton(
|
||||
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 24,
|
||||
icon: const Icon(
|
||||
Icons.notifications_none,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
// 알림 — 화면 대조 원장 F0 · `229:11265`.
|
||||
onPressed: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => NotificationScreen(repository: notifications),
|
||||
),
|
||||
),
|
||||
tooltip: context.l10n.homeNotificationsTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _HomeBody extends StatelessWidget {
|
||||
const _HomeBody({
|
||||
required this.watchPlanet,
|
||||
required this.galaxy,
|
||||
required this.zap,
|
||||
required this.planetRepository,
|
||||
required this.data,
|
||||
required this.repository,
|
||||
required this.onRefresh,
|
||||
required this.onCreate,
|
||||
required this.questRepository,
|
||||
required this.campaignRepository,
|
||||
required this.eclipse,
|
||||
required this.notifications,
|
||||
});
|
||||
|
||||
/// 성운·행성 생성 화면을 열고, 만들어졌으면 HOME 을 다시 읽는다.
|
||||
final Future<void> Function(Widget screen) onCreate;
|
||||
|
||||
final NotificationRepository notifications;
|
||||
|
||||
final HomeSnapshot data;
|
||||
final Future<GalaxySnapshot>? galaxy;
|
||||
final ZapController? zap;
|
||||
final HomeRepository repository;
|
||||
final PlanetWatch watchPlanet;
|
||||
final PlanetRepositoryFactory? planetRepository;
|
||||
final EclipseRepository eclipse;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
/// F4 로 들고 갈 저장소. 홈은 값만 옮기고 어느 구현인지는 모른다.
|
||||
final QuestRepository questRepository;
|
||||
final CampaignRepository? campaignRepository;
|
||||
|
||||
/// 섹션은 ListView 의 직계 자식으로 둔다. 하나의 Column 으로 감싸면 첫 프레임에
|
||||
/// 전부 빌드되어 지연 렌더링을 잃고, 테스트의 scrollUntilVisible 도 「이미 보인다」고
|
||||
/// 오판한다. 좌우 여백은 자식마다 준다.
|
||||
static const _h = EdgeInsets.symmetric(horizontal: AppSpace.s4);
|
||||
|
||||
/// 사본 `Frame 4241` — 구획 사이 36, 구획 머리와 본문 사이 16.
|
||||
static const _sectionGap = 36.0;
|
||||
static const _headerGap = AppSpace.s4;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final top = MediaQuery.paddingOf(context).top;
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s7),
|
||||
children: [
|
||||
// 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 SafeArea 를 쓰지 않고
|
||||
// 헤더 행에 상단 여백을 직접 준다.
|
||||
Stack(
|
||||
children: [
|
||||
LiveStreamBuilder<PlanetGrowth>(
|
||||
key: ValueKey((repository, data.fandomId)),
|
||||
create: () => repository.watchGrowth(data.fandomId),
|
||||
builder: (context, growth) => FutureBuilder<GalaxySnapshot>(
|
||||
future: galaxy,
|
||||
builder: (context, galaxyState) => HomeHero(
|
||||
zap: zap,
|
||||
zapData: galaxyState.data,
|
||||
miniMap:
|
||||
galaxyState.data == null ||
|
||||
planetRepository == null ||
|
||||
zap == null
|
||||
? null
|
||||
: GalaxyMiniMap(
|
||||
size: 140,
|
||||
data: galaxyState.data!,
|
||||
onOpen: () =>
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GalaxyScreen(
|
||||
repository: zap!.repository,
|
||||
planetRepository: planetRepository!,
|
||||
watchPlanet: watchPlanet,
|
||||
eclipse: eclipse,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 라이브 띠 — `fandoms/{id}/liveEvents` 를 구독한다. 이벤트가 아직 없으면
|
||||
// 스냅샷의 `liveTicker` 문자열로 물러난다(폴백).
|
||||
ticker: _LiveTickerSlot(
|
||||
eclipse: eclipse,
|
||||
fandomId: data.fandomId,
|
||||
fallback: data.liveTicker,
|
||||
),
|
||||
growth: growth ?? data.growth,
|
||||
fandomId: data.fandomId,
|
||||
unjoined: data.fandomId.isEmpty,
|
||||
watchPlanet: watchPlanet,
|
||||
onOpenPlanet:
|
||||
planetRepository == null || data.fandomId.isEmpty
|
||||
? null
|
||||
: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlanetDetailScreen(
|
||||
repository: planetRepository!(data.fandomId),
|
||||
watchPlanet: watchPlanet,
|
||||
eclipse: eclipse,
|
||||
),
|
||||
),
|
||||
),
|
||||
onOpenHistory: planetRepository == null
|
||||
? null
|
||||
: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlanetHistoryScreen(
|
||||
repository: planetRepository!(data.fandomId),
|
||||
fandomName:
|
||||
galaxyState.data?.mine?.name ??
|
||||
context.l10n.homeFandomHistory,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 만들고 돌아오면 HOME 이 새 성운/행성(내 Primary)을 다시 그린다.
|
||||
onBirth: () => onCreate(
|
||||
PlanetCreateScreen(
|
||||
nebulaName: (growth ?? data.growth).fandomName,
|
||||
fandomId: data.fandomId,
|
||||
repository: repository,
|
||||
),
|
||||
),
|
||||
onCreateNebula: () => onCreate(
|
||||
NebulaCreateScreen(
|
||||
repository: repository,
|
||||
myPop: data.popBalance,
|
||||
),
|
||||
),
|
||||
// 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」).
|
||||
onJoin: () => GoRouter.maybeOf(context)?.go('/galaxy'),
|
||||
onItem: () => showItemSheet(
|
||||
context,
|
||||
items: data.items,
|
||||
purchase: false,
|
||||
),
|
||||
onHeroParticipate:
|
||||
planetRepository == null || data.fandomId.isEmpty
|
||||
? null
|
||||
: () {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => HeroParticipationSheet(
|
||||
fandomId: data.fandomId,
|
||||
watch: watchPlanet,
|
||||
repository: planetRepository!(data.fandomId),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: HomeHeader(
|
||||
pop: zap?.balance ?? data.popBalance,
|
||||
notifications: notifications,
|
||||
items: data.items,
|
||||
fandomId: data.fandomId,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 이클립스 인앱 경보 — 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱
|
||||
// 워닝 배너 병행」. 활성 이클립스가 없으면 자리도 차지하지 않는다.
|
||||
if (data.fandomId.isNotEmpty)
|
||||
EclipseBanner(
|
||||
repository: eclipse,
|
||||
fandomId: data.fandomId,
|
||||
// 히어로 아래 24 는 히어로 상자(ZAP 걸침 몫)가 이미 준다. 구획 사이는 36(`Frame 4241`).
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
0,
|
||||
AppSpace.s4,
|
||||
_sectionGap,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: SectionHeader(
|
||||
title: 'TODAY’S QUEST',
|
||||
onMore: () =>
|
||||
_openQuests(context, questRepository, data.popBalance),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _headerGap),
|
||||
// 사본 카드(196×137)를 가로로 늘어놓는다(`Frame 1984081088` · 간격 8).
|
||||
SizedBox(
|
||||
// 글자 확대 시 잘리지 않게 글자 몫(22+6+20+28)만 배율을 따른다.
|
||||
height: 12 * 2 + 37 + MediaQuery.textScalerOf(context).scale(76),
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: _h,
|
||||
itemCount: data.quests.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: AppSpace.s2),
|
||||
itemBuilder: (context, i) => SizedBox(
|
||||
width: QuestCard.width,
|
||||
child: QuestCard(
|
||||
quest: data.quests[i],
|
||||
// 타임어택은 목록 첫 페이지에 든다는 보장이 없어 상세로 바로 간다.
|
||||
onTap: data.quests[i].endsAt == null
|
||||
? () =>
|
||||
_openQuests(context, questRepository, data.popBalance)
|
||||
: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => QuestDetailScreen(
|
||||
questId: data.quests[i].id,
|
||||
repository: questRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _sectionGap),
|
||||
|
||||
// 0 건이면 헤더까지 그리지 않는다 — 정책 원장 B-47 「News·SNS 모두 0건 시
|
||||
// Briefing 블록 전체 숨김(빈 상태 문구도 노출하지 않음)」.
|
||||
if (data.briefings.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: SectionHeader(
|
||||
title: 'AI Daily Briefing',
|
||||
// 「홈_브리핑 공유」 — 이 자리가 공유 버튼이다(`266:90529` · 명세 `179:29117`).
|
||||
moreLabel: context.l10n.commonShare,
|
||||
onMore: () => BriefingShareDialog.show(
|
||||
context,
|
||||
items: data.briefings,
|
||||
fandomName: data.growth.fandomName,
|
||||
link: briefingShareLink(data.fandomId, kstDayKey()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _headerGap),
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: BriefingList(items: data.briefings),
|
||||
),
|
||||
const SizedBox(height: _sectionGap),
|
||||
],
|
||||
|
||||
if (data.campaign case final c?) ...[
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: SectionHeader(
|
||||
title: 'HOT ARCHIVE',
|
||||
onMore: () => _openCampaigns(context, campaignRepository),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _headerGap),
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: CampaignCard(
|
||||
campaign: c,
|
||||
onTap: () => _openCampaigns(context, campaignRepository),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _sectionGap),
|
||||
],
|
||||
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: SectionHeader(
|
||||
title: context.l10n.homePopularInside,
|
||||
onMore: () => _notYet(context, context.l10n.homeWhereInsideList),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _headerGap),
|
||||
for (final i in data.insides) ...[
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: InsideCard(
|
||||
digest: i,
|
||||
onTap: () =>
|
||||
_notYet(context, context.l10n.homeWhereInsideDetail),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 상단 활동 POP 잔액. 정책 설계서 §P-02 — 화면 표기는 「활동/지원」이다.
|
||||
class _PopBalance extends StatelessWidget {
|
||||
const _PopBalance({this.value});
|
||||
|
||||
final int? value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
// 사본 `Frame 1984081119` — 민트 10% 알약 · 1 테두리 · 패딩 4/8 · 간격 5.
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicMint.withValues(alpha: 0.1),
|
||||
border: Border.all(color: AppColors.cosmicMint),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Image(
|
||||
image: AssetImage('assets/figma/icon-bolt.png'),
|
||||
width: 10,
|
||||
height: 10.4,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
value == null ? '—' : formatPop(value!),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
letterSpacing: -0.3,
|
||||
color: AppColors.cosmicMint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LoadFailed extends StatelessWidget {
|
||||
const _LoadFailed({required this.onRetry});
|
||||
|
||||
final Future<void> Function() onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s5),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.l10n.homeLoadFailed, style: text.titleMedium),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(
|
||||
context.l10n.homeRetryLater,
|
||||
style: text.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
FilledButton(
|
||||
onPressed: onRetry,
|
||||
child: Text(context.l10n.commonRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 아직 라우트가 없는 곳으로 가려 할 때.
|
||||
///
|
||||
/// 조용히 무시하면 검수 때 「눌러도 아무 일이 없다」로 보인다. 무엇이 언제
|
||||
/// 열리는지 알려 주는 편이 낫다.
|
||||
void _notYet(BuildContext context, String where) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.commonNotYet(where)),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 홈의 HOT ARCHIVE 에서 캠페인 목록으로 — 화면 대조 원장 F1 → F5.
|
||||
///
|
||||
/// 홈 카드에는 캠페인 ID 가 없다([CampaignHighlight] 는 표시용 요약이다).
|
||||
/// 상세로 바로 보내려면 ID 를 홈 데이터에 넣어야 하는데, 그러면 홈이 캠페인
|
||||
/// 스키마를 알게 된다. 목록을 거쳐 들어간다.
|
||||
void _openCampaigns(BuildContext context, CampaignRepository? repository) {
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => CampaignListScreen(repository: repository),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 홈의 오늘의 퀘스트에서 퀘스트 목록으로 — 화면 대조 원장 F1 → F4.
|
||||
///
|
||||
/// 보유 POP 을 함께 넘긴다. 목록 헤더가 같은 값을 다시 읽으면 왕복이 하나 늘고,
|
||||
/// 두 화면의 숫자가 한순간 어긋난다.
|
||||
void _openQuests(
|
||||
BuildContext context,
|
||||
QuestRepository repository,
|
||||
int popBalance,
|
||||
) {
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
QuestListScreen(repository: repository, myPop: popBalance),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import 'home_data.dart';
|
||||
import 'home_screen.dart' show formatPop;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 성운 생성 — Figma 사본 「홈/성운 생성」(`340:75466`) · 이름 입력 후(`340:75644`).
|
||||
///
|
||||
/// 틀은 사본(이름 입력 → 성운 → 목표 영역 → 「성운 생성」)이고 숫자는 정책 원장이다.
|
||||
/// B-10 — 성운은 **10명 이상 + 1,000 POP 이상**을 기본 목표 **7일** 안에 채우면 정식
|
||||
/// 행성이 된다. 목표가 고정값이라 사본의 목표 슬라이더 자리에는 조작할 수 없는 고정
|
||||
/// 표시를 둔다(사본 슬라이더 ↔ 원장 고정값, 해석 확인 문항). B-19 — 진화 참여자에게는
|
||||
/// 기념 배지만 지급하므로 사본의 「1T 등급 · 거버넌스」 문장은 그 내용으로 바꾼다.
|
||||
///
|
||||
/// 사본이 함께 그린 다이얼로그 두 개를 그대로 쓴다.
|
||||
/// - 「선택한 행성을 기본 행성으로 변경할까요?」 취소 / 변경
|
||||
/// - 「이미 오늘 성운을 생성하셨습니다.」 확인 ← **일 1회 제한**
|
||||
class NebulaCreateScreen extends StatefulWidget {
|
||||
const NebulaCreateScreen({
|
||||
super.key,
|
||||
this.alreadyCreatedToday = false,
|
||||
this.myPop = 0,
|
||||
this.repository = const SampleHomeRepository(),
|
||||
});
|
||||
|
||||
/// 생성 경로(`fn-nebula-create`). 성공하면 만든 팬덤 ID 를 들고 이 화면을 닫는다.
|
||||
final HomeRepository repository;
|
||||
|
||||
/// 오늘 이미 만들었는가. Figma 의 「생성 횟수 초과」 상태.
|
||||
final bool alreadyCreatedToday;
|
||||
|
||||
/// 「나의 보유 POP」 — 여는 쪽이 지갑의 활동 POP 을 넘긴다.
|
||||
final int myPop;
|
||||
|
||||
@override
|
||||
State<NebulaCreateScreen> createState() => _NebulaCreateScreenState();
|
||||
}
|
||||
|
||||
class _NebulaCreateScreenState extends State<NebulaCreateScreen> {
|
||||
final _name = TextEditingController();
|
||||
|
||||
String get _value => _name.text.trim();
|
||||
|
||||
/// 서버와 같은 `config/policy` 운영값. 읽기 전 · 실패 시 원장 기본값.
|
||||
NebulaTargets _targets = defaultNebulaTargets;
|
||||
/// 서버가 이미 쓰인다고 답한 이름(E-FDM-01). 중복 판정은 서버만 한다 —
|
||||
/// `fandomNames` 는 클라이언트가 읽지 못한다.
|
||||
String? _takenName;
|
||||
bool get _taken => _takenName != null && _takenName == _value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_name.addListener(() => setState(() {}));
|
||||
widget.repository.nebulaTargets().then((t) {
|
||||
if (mounted) setState(() => _targets = t);
|
||||
}, onError: (_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static const _muted = Color(0xFFCDCDCD);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
// 사본 `Top Navigation` — 왼쪽 X 만.
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: 56,
|
||||
height: 56,
|
||||
),
|
||||
icon: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.nebulaHeadline,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 28 / 20,
|
||||
letterSpacing: -0.5,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 사본 문단 상자는 84(4줄) — 줄 수가 달라도 아래 배치가 흔들리지 않게 높이를 잡는다.
|
||||
SizedBox(
|
||||
height: 84,
|
||||
child: Text(
|
||||
context.l10n.nebulaBody('${_targets.days}'),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
letterSpacing: -0.5,
|
||||
color: _muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
_NameInput(controller: _name, error: _taken),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 17,
|
||||
child: _taken
|
||||
? Text(
|
||||
context.l10n.nebulaNameTaken,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
color: Color(0xFFEC6D6D),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
Center(
|
||||
child: Image.asset(
|
||||
'assets/figma/nebula-home.png',
|
||||
width: 212,
|
||||
height: 212,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
_Target(myPop: widget.myPop, targets: _targets),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 이름을 넣은 뒤(`340:75644`)에만 「성운 생성」이 나온다.
|
||||
if (_value.isNotEmpty)
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
0,
|
||||
AppSpace.s4,
|
||||
0,
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: _taken ? null : _confirm,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.nebulaCreate),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _confirm() async {
|
||||
// 일 1회 제한이 걸려 있으면 확인 다이얼로그를 띄우지 않고 사유를 알린다.
|
||||
if (widget.alreadyCreatedToday) {
|
||||
await showAlertDialog(context, context.l10n.nebulaAlreadyToday);
|
||||
return;
|
||||
}
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.galaxySetPrimaryConfirm,
|
||||
);
|
||||
if (!ok || !mounted || _busy) return;
|
||||
_busy = true;
|
||||
final name = _value;
|
||||
try {
|
||||
final created = await widget.repository.createNebula(name);
|
||||
if (!mounted) return;
|
||||
// C-15 — 대표 변경 30일 대기 중이면 성운만 생기고 대표는 그대로다. 알리고 닫는다.
|
||||
if (!created.primaryChanged) {
|
||||
await showAlertDialog(
|
||||
context,
|
||||
context.l10n.nebulaCreatedKeepPrimary,
|
||||
);
|
||||
if (!mounted) return;
|
||||
}
|
||||
Navigator.of(context).pop(created.fandomId);
|
||||
} on BackendException catch (e) {
|
||||
if (!mounted) return;
|
||||
// 이름 중복은 사본의 입력칸 오류 상태로, 나머지(일 1회 E-FDM-02 · 이름 규칙)는 알림으로.
|
||||
if (e.code == 'E-FDM-01') {
|
||||
setState(() => _takenName = name);
|
||||
} else {
|
||||
await showAlertDialog(context, e.message);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) await showAlertDialog(
|
||||
context,
|
||||
context.l10n.nebulaCreateFailed,
|
||||
);
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 생성 요청 중 — 연타로 두 번 부르지 않는다.
|
||||
bool _busy = false;
|
||||
}
|
||||
|
||||
/// 사본 `Input` — 271×58 · 분홍 10% · r8 · 패딩 12/16 · 42dot Medium 18 가운데.
|
||||
/// 이름이 들어가면 분홍 1 테두리(`340:75650`).
|
||||
class _NameInput extends StatelessWidget {
|
||||
const _NameInput({required this.controller, required this.error});
|
||||
|
||||
final TextEditingController controller;
|
||||
final bool error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
OutlineInputBorder border(Color c) => OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
borderSide: BorderSide(color: c),
|
||||
);
|
||||
final filled = controller.text.trim().isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 36),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
letterSpacing: -0.45,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.nebulaNameHint,
|
||||
hintStyle: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onCosmicMuted,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.cosmicAccent.withValues(alpha: 0.1),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpace.s4,
|
||||
vertical: 20,
|
||||
),
|
||||
enabledBorder: border(
|
||||
filled ? AppColors.cosmicAccent : Colors.transparent,
|
||||
),
|
||||
focusedBorder: border(AppColors.cosmicAccent),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 목표 영역 — 사본 `Frame 1984081264`. 슬라이더 자리는 원장 고정값을 보여 주기만 한다.
|
||||
class _Target extends StatelessWidget {
|
||||
const _Target({required this.myPop, required this.targets});
|
||||
|
||||
final int myPop;
|
||||
final NebulaTargets targets;
|
||||
|
||||
static const _scale = TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
color: Color(0xFFCDCDCD),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
// 사본은 ABeeZee Italic 이지만 한글은 기울지 않는다 — 라틴 글자만 기울인다.
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: context.l10n.nebulaGoal,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'POP',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
TextSpan(text: context.l10n.nebulaFounders),
|
||||
],
|
||||
),
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 28 / 18,
|
||||
letterSpacing: -0.5,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${formatPop(targets.pop)}P / '
|
||||
'${targets.members}',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
letterSpacing: -0.45,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
// 고정 목표 — 트랙 전체를 채우고 손잡이는 두지 않는다(끌 수 없다는 표시).
|
||||
Semantics(
|
||||
label:
|
||||
context.l10n.nebulaGoalSemantics(
|
||||
formatPop(targets.pop),
|
||||
'${targets.members}',
|
||||
'${targets.days}',
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||
child: Container(
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Text('0', style: _scale),
|
||||
const Spacer(),
|
||||
Text(formatPop(targets.pop), style: _scale),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: context.l10n.commonMyPop,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: formatPop(myPop),
|
||||
style: const TextStyle(color: AppColors.onCosmic),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
letterSpacing: -0.35,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 취소 / 변경 두 갈래.
|
||||
Future<bool> showConfirmDialog(BuildContext context, String message) async {
|
||||
final r = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.commonChange),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return r ?? false;
|
||||
}
|
||||
|
||||
/// 확인 하나뿐인 알림.
|
||||
Future<void> showAlertDialog(BuildContext context, String message) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.commonConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,422 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/ui/planet_25d.dart' show planetColors;
|
||||
import 'home_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 행성 생성 — Figma 사본 「홈_성장 완료/행성 생성」(`344:214421`) → 「행성 색 선택」
|
||||
/// (`2066:17600`) → 생성 확인(`373:358639`).
|
||||
///
|
||||
/// 정책 원장 C-50 — 생성 때 고르는 것은 **최초 기본 모델의 형태와 컬러 두 가지**뿐이고,
|
||||
/// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과
|
||||
/// 직접 색 고르기(스포이트) 칩은 두지 않는다.
|
||||
///
|
||||
/// 형태 칸은 사본에서도 모두 같은 자리표시 이미지다(개별 형태 에셋은 S08 확인 대상).
|
||||
class PlanetCreateScreen extends StatefulWidget {
|
||||
const PlanetCreateScreen({
|
||||
super.key,
|
||||
this.nebulaName = 'BTS',
|
||||
this.fandomId = '',
|
||||
this.repository = const SampleHomeRepository(),
|
||||
});
|
||||
|
||||
final String nebulaName;
|
||||
|
||||
/// 탄생시킬 성운(팬덤) ID.
|
||||
final String fandomId;
|
||||
|
||||
/// 생성 경로(`fn-planet-create`). 성공하면 팬덤 ID 를 들고 이 화면을 닫는다.
|
||||
final HomeRepository repository;
|
||||
|
||||
/// 형태 키. 사본 칸에 이름이 없어 순번으로 둔다(에셋 확정 때 이름을 붙인다).
|
||||
static const shapes = [
|
||||
'shape1',
|
||||
'shape2',
|
||||
'shape3',
|
||||
'shape4',
|
||||
'shape5',
|
||||
'shape6',
|
||||
];
|
||||
|
||||
/// 색 키와 칩 색 — 렌더링과 같은 표([planetColors]).
|
||||
static const colors = planetColors;
|
||||
|
||||
@override
|
||||
State<PlanetCreateScreen> createState() => _PlanetCreateScreenState();
|
||||
}
|
||||
|
||||
class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
||||
int _shape = 0;
|
||||
int _color = 0;
|
||||
bool _colorStep = false;
|
||||
|
||||
static const _placeholder = AssetImage(
|
||||
'assets/figma/planet-shape-placeholder.png',
|
||||
);
|
||||
|
||||
static const _heading = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 24 / 18,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
Widget _preview() => Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 212,
|
||||
child: _colorStep
|
||||
? ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
PlanetCreateScreen.colors[_color].$2,
|
||||
BlendMode.modulate,
|
||||
),
|
||||
child: const Image(image: _placeholder),
|
||||
)
|
||||
: const Image(image: _placeholder),
|
||||
),
|
||||
);
|
||||
|
||||
/// 사본 `Frame 1984081334` — #2C2C2C r12 칸, 고른 칸은 분홍 2 테두리.
|
||||
Widget _tile(int i, double size) => GestureDetector(
|
||||
onTap: () => setState(() => _shape = i),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: _shape == i,
|
||||
label: context.l10n.planetShape('${i + 1}'),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2C2C2C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: _shape == i
|
||||
? Border.all(color: AppColors.cosmicAccent, width: 2)
|
||||
: null,
|
||||
),
|
||||
// 사본 칸 그림(렌더에서 4 안쪽을 자른 것). 6번 칸은 사본에서 버튼에 가려
|
||||
// 모양이 보이지 않아 자리표시를 둔다.
|
||||
padding: i < 5 ? const EdgeInsets.all(4) : null,
|
||||
child: Image.asset(
|
||||
i < 5
|
||||
? 'assets/figma/planet-shape-${i + 1}.png'
|
||||
: 'assets/figma/planet-shape-placeholder.png',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _shapes() => LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final third = (box.maxWidth - 16) / 3;
|
||||
final half = (box.maxWidth - 8) / 2;
|
||||
// 사본 배치 — 3칸 · 2칸 · 1칸(아래는 버튼 뒤로 스크롤).
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
for (final i in [0, 1, 2]) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
_tile(i, third),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
Row(
|
||||
children: [
|
||||
_tile(3, half),
|
||||
const SizedBox(width: 8),
|
||||
_tile(4, half),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
_tile(5, box.maxWidth),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// 사본 `Frame 1984081340` — 28 원 · 간격 8 · 고른 칩 분홍 4 테두리, 나머지 흰 2.
|
||||
Widget _colors() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.planetBgColor, style: _heading),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < PlanetCreateScreen.colors.length; i++)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _color = i),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: _color == i,
|
||||
label: PlanetCreateScreen.colors[i].$1,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: PlanetCreateScreen.colors[i].$2,
|
||||
border: Border.all(
|
||||
color: _color == i
|
||||
? AppColors.cosmicAccent
|
||||
: AppColors.onCosmic,
|
||||
width: _color == i ? 4 : 2,
|
||||
strokeAlign: BorderSide.strokeAlignInside,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_TopBar(title: _colorStep
|
||||
? context.l10n.planetColorSelect
|
||||
: context.l10n.planetTypeSelect),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
// 사본 주 프레임(`344:214421`)만 본문이 21 아래서 시작한다(y125).
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
AppSpace.s4 + (_colorStep ? 0 : 21),
|
||||
AppSpace.s4,
|
||||
AppSpace.s4,
|
||||
),
|
||||
children: [
|
||||
_preview(),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
if (_colorStep) _colors() else _shapes(),
|
||||
],
|
||||
),
|
||||
),
|
||||
ColoredBox(
|
||||
color: AppColors.cosmicVoid,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: _colorStep
|
||||
// 사본 `2066:17600` — 반폭 두 버튼(이전 검정 · 완료 분홍), 간격 10.
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Button(
|
||||
label: context.l10n.commonPrev,
|
||||
fill: Colors.black,
|
||||
ink: AppColors.onCosmic,
|
||||
onTap: () => setState(() => _colorStep = false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _Button(label: context.l10n.commonDone, onTap: _confirm),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpace.s4,
|
||||
),
|
||||
child: _Button(
|
||||
label: context.l10n.commonNext,
|
||||
onTap: () => setState(() => _colorStep = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _confirm() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierColor: const Color(0xB3000000),
|
||||
builder: (context) => const _ConfirmDialog(),
|
||||
);
|
||||
if (ok != true || !mounted || _busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
await widget.repository.createPlanet(
|
||||
fandomId: widget.fandomId,
|
||||
planetType: PlanetCreateScreen.shapes[_shape],
|
||||
colorKey: PlanetCreateScreen.colors[_color].$1,
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(widget.fandomId);
|
||||
} on BackendException catch (e) {
|
||||
_fail(e.message);
|
||||
} catch (_) {
|
||||
_fail(context.l10n.planetCreateFailed);
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 생성 요청 중 — 연타로 두 번 부르지 않는다.
|
||||
bool _busy = false;
|
||||
|
||||
void _fail(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사본 `Top Navigation` — 왼쪽 X(24) · 가운데 제목(16 SemiBold) · 오른쪽 빈 자리(「완료」 투명도 0).
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
constraints: const BoxConstraints.tightFor(width: 56, height: 56),
|
||||
icon: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 사본의 오른쪽 「완료」는 투명도 0 이라 보이지 않는다. 제목 가운데 정렬용 자리만 둔다.
|
||||
const SizedBox(width: 56),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본 `Button` — 56 높이 · r8 · 16 SemiBold.
|
||||
class _Button extends StatelessWidget {
|
||||
const _Button({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.fill = AppColors.cosmicAccent,
|
||||
this.ink = AppColors.onCosmicAccent,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final Color fill;
|
||||
final Color ink;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FilledButton(
|
||||
onPressed: onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fill,
|
||||
foregroundColor: ink,
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본 `Frame 1984080943` — 312 폭 #282828 r12 · 패딩 24/16 · 취소(흰 1 테두리) / 생성(분홍).
|
||||
class _ConfirmDialog extends StatelessWidget {
|
||||
const _ConfirmDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Dialog(
|
||||
backgroundColor: const Color(0xFF282828),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 31),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.planetCreateConfirm,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: Color(0xFFF8F8F8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
side: const BorderSide(color: AppColors.onCosmic),
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.commonCreate),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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