사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정

This commit is contained in:
Sn-Kinos
2026-10-05 00:44:39 +09:00
parent c8d29d032b
commit 8a0297eb92
66 changed files with 1812 additions and 959 deletions
@@ -5,6 +5,7 @@ import '../../core/data/invite_code.dart';
import '../../core/data/server_clock.dart';
import 'home_data.dart';
import '../quest/quest_data.dart' show QuestType;
import '../quest/quest_repository_firestore.dart' show questToday;
import '../../core/i18n/app_locale.dart';
/// 홈의 실 서버 구현 — 화면 대조 원장 F1.
@@ -137,10 +138,13 @@ class FirestoreHomeRepository implements HomeRepository {
.collection('heroGiftProducts')
.where('active', isEqualTo: true)
.get();
// 인기 K-INSIDE — 기능명세서 v1.4 §2.114-7 「최대 3개 소팅(미해결 + 고액 포인트 순)」.
// 미해결(`open`) 먼저, 그 안에서 현상금 큰 순. 정렬은 아래 [popularInsides].
// ponytail: 현상금 상위 20건 안에서만 고른다. (status, bountyPop) 색인을 배포하면 질의로 옮긴다.
final insidesF = db
.collection('questions')
.orderBy('createdAt', descending: true)
.limit(3)
.orderBy('bountyPop', descending: true)
.limit(20)
.get();
final wallet = (await walletF).data() ?? const <String, dynamic>{};
@@ -167,7 +171,9 @@ class FirestoreHomeRepository implements HomeRepository {
final campaigns = (await campaignF).docs
.where((d) => !blocked.contains(d.data()['creatorUid']))
.toList();
final insides = (await insidesF).docs;
final insides = popularInsides([
for (final doc in (await insidesF).docs) doc.data(),
]);
final progressById = {for (final doc in progress) doc.id: doc.data()};
final policy = (await policyF).data() ?? const <String, dynamic>{};
@@ -185,7 +191,7 @@ class FirestoreHomeRepository implements HomeRepository {
],
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())],
insides: [for (final data in insides) _inside(data)],
items: [for (final doc in (await itemsF).docs) _item(doc.id, doc.data())],
);
}
@@ -284,7 +290,8 @@ class FirestoreHomeRepository implements HomeRepository {
members: validInt(fandom['memberCount']),
requiredMembers: targets?.members,
// Q-PN-01 A안 — 탄생은 창립자만.
isFounder: fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
isFounder:
fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
requiredSfpi: validNumber(next?['requiredSfpi']),
nebulaGoalPassed: goalPassed,
);
@@ -296,9 +303,10 @@ class FirestoreHomeRepository implements HomeRepository {
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;
// 퀘스트 목록 · 상세와 같은 판정(`questToday`) — 오늘(KST) 행만 센다.
final (:count, :done) =
questToday(QuestType.parse(quest.strOr('type')), quest, progress, policy);
final started = count > 0;
return QuestSummary(
title: quest.strOr('title'),
rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
@@ -354,6 +362,15 @@ String _timeAgo(DateTime at) {
return appL10n.timeDaysAgo('${gap.inDays}');
}
/// 인기 K-INSIDE 3건 — 미해결(`status == open`) 먼저, 같은 묶음 안에서는 현상금 큰 순.
/// 입력이 현상금 내림차순이라 미해결 묶음만 앞으로 옮기면 된다.
List<Map<String, dynamic>> popularInsides(
List<Map<String, dynamic>> byBounty,
) => [
...byBounty.where((q) => q['status'] == 'open'),
...byBounty.where((q) => q['status'] != 'open'),
].take(3).toList();
/// 오늘 날짜(KST `YYYY-MM-DD`) — 서버 `briefings.dayKey` 와 같은 규약이다.
///
/// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이
+92 -28
View File
@@ -31,6 +31,7 @@ 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_create_screen.dart';
import '../campaign/campaign_list_screen.dart';
import '../notification/notification_screen.dart';
import '../quest/quest_data.dart' show QuestRepository;
@@ -150,19 +151,22 @@ class _HomeScreenState extends State<HomeScreen> {
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,
return _StatusBarScrim(
height: MediaQuery.paddingOf(context).top,
child: _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,
),
);
},
),
@@ -352,7 +356,7 @@ class _HomeBody extends StatelessWidget {
zap == null
? null
: GalaxyMiniMap(
size: 140,
size: HomeHero.miniMapSize,
data: galaxyState.data!,
onOpen: () =>
Navigator.of(context, rootNavigator: true).push(
@@ -531,24 +535,39 @@ class _HomeBody extends StatelessWidget {
const SizedBox(height: _sectionGap),
],
if (data.campaign case final c?) ...[
Padding(
padding: _h,
child: SectionHeader(
title: 'HOT ARCHIVE',
onMore: () => _openCampaigns(context, campaignRepository),
),
// HOT ARCHIVE 는 모금 중 캠페인이 없어도 둔다 — 기능명세서 v1.4 §2.114-6
// (`2097:16415`) 「더보기 버튼 - HOT ARCHIVE 화면 이동」에 숨김 규칙이 없고,
// 캠페인 목록·개설로 가는 길이 이 구획뿐이다(공통 기준 5항).
Padding(
padding: _h,
child: SectionHeader(
title: 'HOT ARCHIVE',
onMore: () => _openCampaigns(context, campaignRepository),
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: CampaignCard(
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: switch (data.campaign) {
final c? => CampaignCard(
campaign: c,
onTap: () => _openCampaigns(context, campaignRepository),
),
),
const SizedBox(height: _sectionGap),
],
// 0 건 — 「새 캠페인」(Figma `179:29123` 캠페인 개설)으로 바로 간다.
null => Center(
child: CampaignFab(
label: context.l10n.campaignNew,
onTap: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) =>
CampaignCreateScreen(repository: campaignRepository),
),
),
),
),
},
),
const SizedBox(height: _sectionGap),
Padding(
padding: _h,
@@ -575,6 +594,51 @@ class _HomeBody extends StatelessWidget {
}
}
/// 상태 표시줄 가림막 — 히어로는 상태 표시줄 뒤까지 배경을 깔아야 해서 목록에
/// SafeArea 를 주지 않는다. 대신 세로로 스크롤되면 상태 표시줄 자리를 화면 배경색으로
/// 덮어, 아래 구획 머리글(TODAY'S QUEST 등)이 시계·배터리와 겹쳐 그려지지 않게 한다.
class _StatusBarScrim extends StatefulWidget {
const _StatusBarScrim({required this.height, required this.child});
final double height;
final Widget child;
@override
State<_StatusBarScrim> createState() => _StatusBarScrimState();
}
class _StatusBarScrimState extends State<_StatusBarScrim> {
bool _scrolled = false;
bool _onScroll(ScrollNotification n) {
// 퀘스트 카드의 가로 스크롤은 무시한다 — 바깥 세로 목록만 본다.
if (n.depth != 0 || n.metrics.axis != Axis.vertical) return false;
final scrolled = n.metrics.pixels > 0;
if (scrolled != _scrolled) setState(() => _scrolled = scrolled);
return false;
}
@override
Widget build(BuildContext context) => Stack(
fit: StackFit.expand,
children: [
NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: widget.child,
),
if (_scrolled)
Positioned(
key: const ValueKey('home-status-scrim'),
top: 0,
left: 0,
right: 0,
height: widget.height,
child: const ColoredBox(color: AppColors.cosmicVoid),
),
],
);
}
/// 상단 활동 POP 잔액. 정책 설계서 §P-02 — 화면 표기는 「활동/지원」이다.
class _PopBalance extends StatelessWidget {
const _PopBalance({this.value});
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart';
import '../../core/data/backend.dart' show BackendException;
import 'home_data.dart';
@@ -406,38 +407,3 @@ class _Target extends StatelessWidget {
],
);
}
/// 취소 / 변경 두 갈래.
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),
),
],
),
);
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../core/data/backend.dart' show BackendException;
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/planet_25d.dart' show planetColors;
import 'home_data.dart';
@@ -245,12 +246,12 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
);
Future<void> _confirm() async {
final ok = await showDialog<bool>(
context: context,
barrierColor: const Color(0xB3000000),
builder: (context) => const _ConfirmDialog(),
final ok = await showConfirmDialog(
context,
context.l10n.planetCreateConfirm,
confirmLabel: context.l10n.commonCreate,
);
if (ok != true || !mounted || _busy) return;
if (!ok || !mounted || _busy) return;
_busy = true;
try {
await widget.repository.createPlanet(
@@ -356,67 +357,3 @@ class _Button extends StatelessWidget {
);
}
/// 사본 `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),
),
),
],
),
],
),
),
);
}
+21 -4
View File
@@ -78,10 +78,21 @@ class HomeHero extends StatefulWidget {
/// ZAP 옵션 「아이템」 — 사본 「아이템 선택」 시트(`343:204868`).
final VoidCallback? onItem;
/// GALAXY 미니맵 — 행성 영역 우측 상단(해석 확인 요청서 Q-DS-06 권고안 A · 회신 전 임시).
/// 사본 홈에는 없는 요소라 행성 칸 모서리에만 얹고 문구·카드와 겹치지 않게 한다.
/// GALAXY 미니맵 — 히어로 우측 상단 [miniMapTop] · 오른쪽 끝(여백 0).
final Widget? miniMap;
/// 미니맵 자리(C-113 「최신 확정 HOME Figma 위치」). Figma `89rQgL1JEFlA8yxl9L77bS`
/// 「홈」(`2097:16428`) 375pt 실측 — 헤더(y0~124) 바로 아래 히어로 본문 시작점, 인사 문구
/// (「1,230 POP 기여로…」) 줄과 같은 높이, 오른쪽 끝에 붙음(여백 0).
static const Alignment miniMapAlignment = Alignment.topRight;
/// 미니맵 위 변 — 상태 표시줄 아래 기준 사본 y(히어로 본문 `Frame` y124).
static const double miniMapTop = 124;
/// 미니맵 한 변 — Figma 「홈」(`2097:16428`) 실측 약 93×90pt. C-113(위치·크기 = Figma)을
/// C-105 「약 140pt」보다 우선한다(2026-10-05 대표 결정).
static const double miniMapSize = 93;
/// 사본 기준 배경 높이(상태 표시줄 48 포함).
static const double designHeight = 680;
@@ -191,8 +202,7 @@ class _HomeHeroState extends State<HomeHero> {
glow: AppColors.neonViolet,
),
),
if (widget.miniMap case final mini?)
Positioned(top: 0, right: 0, child: mini),
],
),
),
@@ -215,6 +225,13 @@ class _HomeHeroState extends State<HomeHero> {
),
),
if (widget.miniMap case final mini?)
Positioned(
top: top + HomeHero.miniMapTop,
right: 0,
child: mini,
),
if (widget.ticker case final ticker?)
Positioned(top: top + 102, left: 0, right: 0, child: ticker),