Files
fanuzu/app/lib/features/home/home_screen.dart
T

759 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/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_create_screen.dart';
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 탭 루트 — 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
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 _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,
),
);
},
),
);
}
/// 히어로 위에 얹는 헤더 — 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: HomeHero.miniMapSize,
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),
],
// 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: switch (data.campaign) {
final c? => CampaignCard(
campaign: c,
onTap: () => _openCampaigns(context, campaignRepository),
),
// 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,
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),
],
],
),
);
}
}
/// 상태 표시줄 가림막 — 히어로는 상태 표시줄 뒤까지 배경을 깔아야 해서 목록에
/// 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});
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),
),
);
}