사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정
This commit is contained in:
@@ -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` 와 달라 브리핑이
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user