사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · 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
+10
View File
@@ -26,6 +26,12 @@ service cloud.firestore {
// ───────────────────────── 공통 헬퍼 ───────────────────────── // ───────────────────────── 공통 헬퍼 ─────────────────────────
function signedIn() { return request.auth != null; } function signedIn() { return request.auth != null; }
// `{questId}_{uid}` 꼴 문서 ID 가 호출자 본인 것인가(뒤가 `_` + uid).
function isOwnSubmissionId(id) {
let tail = '_' + request.auth.uid;
return id.size() > tail.size() && id[id.size() - tail.size():id.size()] == tail;
}
function isSelf(u) { return signedIn() && request.auth.uid == u; } function isSelf(u) { return signedIn() && request.auth.uid == u; }
function role() { return signedIn() ? request.auth.token.get('role', '') : ''; } function role() { return signedIn() ? request.auth.token.get('role', '') : ''; }
function isViewer() { return role() == 'master' || role() == 'ops' || role() == 'viewer'; } function isViewer() { return role() == 'master' || role() == 'ops' || role() == 'viewer'; }
@@ -489,6 +495,10 @@ service cloud.firestore {
allow read: if signedIn() && (resource.data.uid == request.auth.uid allow read: if signedIn() && (resource.data.uid == request.auth.uid
|| resource.data.questAuthorUid == request.auth.uid || resource.data.questAuthorUid == request.auth.uid
|| isOps()); || isOps());
// 자기 제출 건은 문서 ID(`{questId}_{uid}` — fn-quest-submit)로 **없어도** 조회한다.
// 없는 문서는 resource 가 null 이라 위 조건이 거부되고, 앱의 퀘스트 목록이 UGC 퀘스트
// 하나 때문에 통째로 실패했다(2026-10-04 실기기).
allow get: if signedIn() && isOwnSubmissionId(submissionId);
allow write: if false; allow write: if false;
} }
@@ -1697,6 +1697,17 @@ describe('사용자 생성 Quest — 제출 · 검토 · 수정 이력 (C-07 ·
where('questId', '==', 'q-ugc'), where('questAuthorUid', '==', 'creator')))); where('questId', '==', 'q-ugc'), where('questAuthorUid', '==', 'creator'))));
}); });
test('아직 없는 자기 제출 건도 문서 ID 로 조회한다 — 목록이 UGC 퀘스트 하나로 막히지 않게', async () => {
// 2026-10-04 실기기 — 없는 문서 get 이 permission-denied 로 떨어져 퀘스트 목록 전체가 실패했다.
await assertSucceeds(getDoc(doc(dbFor('bob'), 'questSubmissions/q-ugc_bob')));
// 남의 제출 건 ID 로는 여전히 못 읽는다(있든 없든).
await assertFails(getDoc(doc(dbFor('bob'), 'questSubmissions/q-ugc_alice')));
await assertFails(getDoc(doc(dbFor('bob'), 'questSubmissions/q-new_alice')));
// 접미만 같은 ID(`_bob` 없이 `bob`) 는 본인 것이 아니다.
await assertFails(getDoc(doc(dbFor('bob'), 'questSubmissions/q-ugcbob')));
await assertFails(getDoc(doc(anonymousDb(), 'questSubmissions/q-ugc_bob')));
});
test('제출 건 상태 · 예약은 앱이 쓰지 못한다 — 자기 승인 · 위조 제출 거부', async () => { test('제출 건 상태 · 예약은 앱이 쓰지 못한다 — 자기 승인 · 위조 제출 거부', async () => {
await assertFails(updateDoc(doc(dbFor('alice'), 'questSubmissions/q-ugc_alice'), { status: 'paid' })); await assertFails(updateDoc(doc(dbFor('alice'), 'questSubmissions/q-ugc_alice'), { status: 'paid' }));
await assertFails(updateDoc(doc(dbFor('creator'), 'questSubmissions/q-ugc_alice'), { status: 'approved' })); await assertFails(updateDoc(doc(dbFor('creator'), 'questSubmissions/q-ugc_alice'), { status: 'approved' }));
+1
View File
@@ -179,6 +179,7 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
push: repos.push, push: repos.push,
auth: auth, auth: auth,
campaigns: repos.campaign, campaigns: repos.campaign,
insides: repos.inside,
), ),
), ),
], ],
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import '../i18n/app_locale.dart';
import '../theme/tokens.dart';
/// 앱 공통 확인 창 — Figma `2066:17799`(행성 생성 확인) · `554:24669`(설정 확인 팝업).
///
/// 카드 312 폭 · #282828 r12 · 안쪽 24/16 · 문구 16/600 가운데 정렬,
/// 그 아래 **취소(흰 테두리) · 실행(분홍 채움)을 가로로 반씩**(48 높이 · 간격 8 · r8).
/// 단일 버튼 알림(Figma `511:86714` · `554:23103`)은 실행 버튼 하나가 폭을 다 쓴다.
///
/// [content] 는 입력 칸이 있는 창(반려 사유 · 인증 제출 등)에서 문구 대신 넣는다.
class AppDialog extends StatelessWidget {
const AppDialog({
super.key,
required this.title,
this.body,
this.content,
this.cancelLabel,
required this.confirmLabel,
});
final String title;
final String? body;
final Widget? content;
/// null 이면 실행 버튼 하나만 둔다.
final String? cancelLabel;
final String confirmLabel;
static const _shape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)),
);
static TextStyle _text(double size, FontWeight weight, Color color) =>
TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: weight,
height: 1.5,
letterSpacing: size * -0.025,
color: color,
);
@override
Widget build(BuildContext context) {
Widget button(String label, bool value, {required bool filled}) {
final text = Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _text(
16,
FontWeight.w600,
filled ? AppColors.onCosmicAccent : AppColors.onCosmic,
).copyWith(height: 1),
);
void pop() => Navigator.of(context).pop(value);
return Expanded(
child: filled
? FilledButton(
onPressed: pop,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: const EdgeInsets.symmetric(horizontal: 12),
backgroundColor: AppColors.cosmicAccent,
shape: _shape,
),
child: text,
)
: OutlinedButton(
onPressed: pop,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: const EdgeInsets.symmetric(horizontal: 12),
side: const BorderSide(color: AppColors.onCosmic),
shape: _shape,
),
child: text,
),
);
}
return Dialog(
backgroundColor: const Color(0xFF282828), // Figma 2066:17799 fill
insetPadding: const EdgeInsets.symmetric(horizontal: 31.5, vertical: 24),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: _text(16, FontWeight.w600, AppColors.onCosmic),
),
if (body case final b?) ...[
const SizedBox(height: 16),
Text(
b,
textAlign: TextAlign.center,
style: _text(16, FontWeight.w500, AppColors.onCosmic)
.copyWith(height: 1.4),
),
],
if (content case final c?) ...[
const SizedBox(height: 16),
c,
],
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
if (cancelLabel case final c?) ...[
button(c, false, filled: false),
const SizedBox(width: 8),
],
button(confirmLabel, true, filled: true),
],
),
],
),
),
);
}
}
const _barrier = Color(0xB3000000);
/// 취소 · 실행 두 갈래. 실행이면 true. [confirmLabel] 기본은 「변경」.
Future<bool> showConfirmDialog(
BuildContext context,
String title, {
String? body,
Widget? content,
String? cancelLabel,
String? confirmLabel,
}) async {
final r = await showDialog<bool>(
context: context,
barrierColor: _barrier,
builder: (context) => AppDialog(
title: title,
body: body,
content: content,
cancelLabel: cancelLabel ?? context.l10n.commonCancel,
confirmLabel: confirmLabel ?? context.l10n.commonChange,
),
);
return r ?? false;
}
/// 확인 하나뿐인 알림 — Figma `511:86714`(타이틀 가운데 · 확인 버튼 가운데).
Future<void> showAlertDialog(
BuildContext context,
String title, {
String? body,
String? confirmLabel,
}) => showDialog<void>(
context: context,
barrierColor: _barrier,
builder: (context) => AppDialog(
title: title,
body: body,
confirmLabel: confirmLabel ?? context.l10n.commonConfirm,
),
);
-39
View File
@@ -1,39 +0,0 @@
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// 표본 데이터 알림(G-08) — 본문을 밀지 않도록 앱바 오른쪽에 작은 「MOCK」 알약으로 둔다.
/// 사유 문구는 길게 눌렀을 때와 스크린리더로 읽힌다.
class MockTag extends StatelessWidget {
const MockTag({super.key, required this.note});
final String note;
@override
Widget build(BuildContext context) => Tooltip(
message: note,
child: Semantics(
label: 'MOCK · $note',
excludeSemantics: true,
child: Container(
margin: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.neonViolet),
),
child: Text(
'MOCK',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 10,
fontWeight: FontWeight.w600,
height: 1.2,
letterSpacing: -0.25,
color: AppColors.neonViolet,
),
),
),
),
);
}
+1 -1
View File
@@ -87,7 +87,7 @@ final class AuthNotConfigured extends AuthOutcome {
final AuthProviderKind provider; final AuthProviderKind provider;
/// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다. /// 투입해야 하는 것들(파일명·키명). 개발자용이라 로그·README 에만 쓰고 화면에는 내보내지 않는다.
final List<String> missing; final List<String> missing;
} }
+9 -22
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/firebase/firebase_boot.dart'; import '../../core/firebase/firebase_boot.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
@@ -232,23 +234,11 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
Future<void> _showNotConfigured(AuthNotConfigured outcome) async { Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
final name = outcome.provider.shortName; final name = outcome.provider.shortName;
await showDialog<void>( await showAlertDialog(
context: context, context,
builder: (context) => AlertDialog( context.l10n.authNotConfiguredTitle(name),
title: Text(context.l10n.authNotConfiguredTitle(name)), // 설정 항목(outcome.missing)은 개발자용이라 화면에 내보내지 않는다.
content: Text( body: context.l10n.authNotConfiguredBody(name),
context.l10n.authNotConfiguredBody(
name,
outcome.missing.join('\n· '),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.commonConfirm),
),
],
),
); );
} }
@@ -381,11 +371,8 @@ String? authNotice(AuthOutcome outcome, [AppLocalizations? l10n]) =>
AuthSucceeded() => null, AuthSucceeded() => null,
AuthCancelled() => null, AuthCancelled() => null,
AuthFailed(:final code, :final message) => '[$code] $message', AuthFailed(:final code, :final message) => '[$code] $message',
AuthNotConfigured(:final provider, :final missing) => AuthNotConfigured(:final provider) =>
(l10n ?? koL10n).authNotConfiguredNotice( (l10n ?? koL10n).authNotConfiguredNotice(provider.shortName),
provider.shortName,
missing.join(' · '),
),
}; };
TextStyle _abz(double size, Color color) => TextStyle( TextStyle _abz(double size, Color color) => TextStyle(
@@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/data/backend.dart' show BackendException; import '../../core/data/backend.dart' show BackendException;
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart'; import '../../core/ui/app_icons.dart';
import '../../core/ui/mock_tag.dart';
import '../home/home_screen.dart' show formatPop; import '../home/home_screen.dart' show formatPop;
import 'campaign_data.dart'; import 'campaign_data.dart';
import 'widgets/campaign_period_sheet.dart'; import 'widgets/campaign_period_sheet.dart';
@@ -123,8 +124,6 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
), ),
title: Text(context.l10n.campaignNew), title: Text(context.l10n.campaignNew),
actions: [ actions: [
// G-08 — 지원 종류 가격은 인스타 광고 300,000 외 자리표시다.
const MockTag(note: '상품 목록은 Catalog 연결 전 표본입니다'),
TextButton( TextButton(
onPressed: _ready && !_saving ? _confirm : null, onPressed: _ready && !_saving ? _confirm : null,
// 시안 — 오른쪽 끝에서 16(x 335). // 시안 — 오른쪽 끝에서 16(x 335).
@@ -457,31 +456,19 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
); );
Future<void> _confirm() async { Future<void> _confirm() async {
final go = await showDialog<bool>( final go = await showConfirmDialog(
context: context, context,
builder: (context) => AlertDialog( context.l10n.campaignSubmitTitle,
title: Text(context.l10n.campaignSubmitTitle), body: context.l10n.campaignSubmitBody(
content: Text(
context.l10n.campaignSubmitBody(
_title.text.trim(), _title.text.trim(),
formatPop(_goalPop), formatPop(_goalPop),
'$_days', '$_days',
formatPop(campaignRegistrationFeePop), formatPop(campaignRegistrationFeePop),
), ),
), cancelLabel: context.l10n.commonKeepEditing,
actions: [ confirmLabel: context.l10n.campaignSubmit,
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonKeepEditing),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.campaignSubmit),
),
],
),
); );
if (go != true || !mounted) return; if (!go || !mounted) return;
setState(() => _saving = true); setState(() => _saving = true);
final String id; final String id;
@@ -511,18 +498,10 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
} }
if (!mounted) return; if (!mounted) return;
// 올린 캠페인은 심사 전이라 목록에 보이지 않는다 — 어디 갔는지 알려 준다. // 올린 캠페인은 심사 전이라 목록에 보이지 않는다 — 어디 갔는지 알려 준다.
await showDialog<void>( await showAlertDialog(
context: context, context,
builder: (context) => AlertDialog( context.l10n.campaignPendingTitle,
title: Text(context.l10n.campaignPendingTitle), body: context.l10n.campaignPendingBody,
content: Text(context.l10n.campaignPendingBody),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.commonConfirm),
),
],
),
); );
if (mounted) Navigator.of(context).pop(id); if (mounted) Navigator.of(context).pop(id);
} }
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -935,32 +937,19 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
/// 철회 확인 — 등록비 반환 여부를 먼저 알린다(B-58 · 공개 전만 반환). /// 철회 확인 — 등록비 반환 여부를 먼저 알린다(B-58 · 공개 전만 반환).
Future<void> _withdraw(CampaignCardData card) async { Future<void> _withdraw(CampaignCardData card) async {
final beforePublish = card.status == CampaignStatus.review; final beforePublish = card.status == CampaignStatus.review;
final go = await showDialog<bool>( final go = await showConfirmDialog(
context: context, context,
builder: (context) => AlertDialog( context.l10n.campaignWithdrawTitle,
title: Text(context.l10n.campaignWithdrawTitle), confirmLabel: context.l10n.campaignWithdrawConfirm,
content: Text( body: beforePublish
beforePublish
? context.l10n.campaignWithdrawRefund( ? context.l10n.campaignWithdrawRefund(
formatPop(campaignRegistrationFeePop), formatPop(campaignRegistrationFeePop),
) )
: context.l10n.campaignWithdrawNoRefund( : context.l10n.campaignWithdrawNoRefund(
formatPop(campaignRegistrationFeePop), formatPop(campaignRegistrationFeePop),
), ),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.campaignWithdrawConfirm),
),
],
),
); );
if (go != true || !mounted) return; if (!go || !mounted) return;
try { try {
await widget.repository.withdraw(card.id); await widget.repository.withdraw(card.id);
} on Object catch (error) { } on Object catch (error) {
+1 -1
View File
@@ -65,7 +65,7 @@ class EclipseState {
/// 방어 마감. 없으면 남은 시간을 띄우지 않는다. /// 방어 마감. 없으면 남은 시간을 띄우지 않는다.
final DateTime? endsAt; final DateTime? endsAt;
/// 실데이터가 아닌 경우의 사유. 화면이 그대로 배지로 띄운다(대조표 3-5). /// 실데이터가 아닌 경우의 사유. 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
final String? mockNote; final String? mockNote;
bool get isMock => mockNote != null; bool get isMock => mockNote != null;
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart'; import '../../../core/theme/tokens.dart';
import '../../../core/ui/live_stream.dart'; import '../../../core/ui/live_stream.dart';
import '../../home/home_screen.dart' show formatPop; import '../../home/home_screen.dart' show formatPop;
import '../../planet/fpi_ranking_screen.dart' show MockBadge;
import '../eclipse_data.dart'; import '../eclipse_data.dart';
import '../../../core/i18n/app_locale.dart'; import '../../../core/i18n/app_locale.dart';
@@ -136,9 +135,6 @@ class EclipseBannerCard extends StatelessWidget {
), ),
style: t.labelSmall, style: t.labelSmall,
), ),
// 실데이터가 아니면 숨기지 않는다 — 대조표 3-5 · 공통 규칙 (4).
if (state.mockNote case final note?)
MockBadge(note: note),
], ],
), ),
), ),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../core/data/planet_visual_state.dart'; import '../../core/data/planet_visual_state.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart'; import '../../core/ui/app_icons.dart';
import '../notification/notification_screen.dart'; import '../notification/notification_screen.dart';
import '../eclipse/eclipse_data.dart'; import '../eclipse/eclipse_data.dart';
@@ -25,6 +26,15 @@ import '../../core/i18n/app_locale.dart';
/// 궤도 맵([GalaxyScreen])은 시안의 갤럭시맵 명세(HUD · 3D 갤럭시)를 구현한 화면이라 /// 궤도 맵([GalaxyScreen])은 시안의 갤럭시맵 명세(HUD · 3D 갤럭시)를 구현한 화면이라
/// 그대로 두고, 카드나 우측 상단 목록 아이콘에서 들어간다. /// 그대로 두고, 카드나 우측 상단 목록 아이콘에서 들어간다.
class GalaxyHomeScreen extends StatefulWidget { class GalaxyHomeScreen extends StatefulWidget {
/// 카드 그림 — HOME · 맵과 같이 팬덤 `stage` 로 고른다. Stage0 성운만 성운 그림이고
/// 정식 행성은 시안 행성 그림(`image 8` · 파란 행성 벡터)을 칸마다 번갈아 쓴다.
/// 예전엔 칸 순서로 골라 둘째 칸이 행성이어도 성운 그림이 됐다.
static String cardImageOf(PlanetNode p, int i) => p.stage == 0
? 'assets/figma/nebula-card.png'
: (i.isEven
? 'assets/figma/planet-detail-hero.png'
: 'assets/figma/planet-earth.png');
const GalaxyHomeScreen({ const GalaxyHomeScreen({
super.key, super.key,
this.watchPlanet = noPlanetWatch, this.watchPlanet = noPlanetWatch,
@@ -247,7 +257,7 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
Expanded( Expanded(
child: _PlanetCard( child: _PlanetCard(
planet: p, planet: p,
image: _imageFor(i), image: GalaxyHomeScreen.cardImageOf(p, i),
selected: p.isMine, selected: p.isMine,
onTap: () => _choose(p), onTap: () => _choose(p),
), ),
@@ -279,13 +289,6 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
), ),
); );
} }
/// 카드 그림. 시안 세 칸(`image 8` · `image 15` · 파란 행성 벡터)을 순서대로 쓴다.
static String _imageFor(int i) => switch (i) {
0 => 'assets/figma/planet-detail-hero.png',
1 => 'assets/figma/nebula-card.png',
_ => 'assets/figma/planet-earth.png',
};
} }
/// 행성 카드 — 시안 109×109 #2c2c2c r12, 그림 88(안쪽 11·10), 선택은 #ff7ddd 테두리와 /// 행성 카드 — 시안 109×109 #2c2c2c r12, 그림 88(안쪽 11·10), 선택은 #ff7ddd 테두리와
+13 -6
View File
@@ -31,7 +31,8 @@ import '../../core/i18n/app_locale.dart';
/// 화면 대조 원장 F2 의 2번. **09-03 GALAXY 2.5D 1차 시연 대상**이다. /// 화면 대조 원장 F2 의 2번. **09-03 GALAXY 2.5D 1차 시연 대상**이다.
/// ///
/// 명세대로 갖춘 것 /// 명세대로 갖춘 것
/// - 3d갤럭시 브라우징 줌인·줌아웃, 터치 이동 → [InteractiveViewer] /// - 3d갤럭시 브라우징 줌인·줌아웃 → [InteractiveViewer] 핀치. 터치 이동은 행성 탭 중심
/// 전환이 맡고, 한 손가락 끌기는 궤도 회전이다(맵 팬 없음 — 장면이 화면 밖으로 밀리지 않게)
/// - 내 행성 한가운데, 행성 터치 시 화면 중심 전환 이동 /// - 내 행성 한가운데, 행성 터치 시 화면 중심 전환 이동
/// - HUD 에 Rank · FPI 총점 · 접속자수, 터치 시 FPI 상세로 /// - HUD 에 Rank · FPI 총점 · 접속자수, 터치 시 FPI 상세로
/// - GALAXY 텍스트 좌측 하단 · 돋보기 우측 하단 → 검색 화면 /// - GALAXY 텍스트 좌측 하단 · 돋보기 우측 하단 → 검색 화면
@@ -40,8 +41,7 @@ import '../../core/i18n/app_locale.dart';
/// ///
/// 2026-09-09 검토회신서 3절이 지적한 것과 그 답 /// 2026-09-09 검토회신서 3절이 지적한 것과 그 답
/// - *드래그·회전에 관성과 감속이 없다* → 궤도 각속도를 [GalaxyClock] 이 물리로 /// - *드래그·회전에 관성과 감속이 없다* → 궤도 각속도를 [GalaxyClock] 이 물리로
/// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다. 이동(팬)의 /// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다.
/// 관성은 [InteractiveViewer] 가 이미 가진 플링을 그대로 쓴다.
/// - *행성 간 이동감이 없다* → 중심 전환이 순간이동이 아니라 /// - *행성 간 이동감이 없다* → 중심 전환이 순간이동이 아니라
/// [GalaxyMotion.recenter] 동안 감속하며 날아간다. 목표 좌표는 매 프레임 다시 /// [GalaxyMotion.recenter] 동안 감속하며 날아간다. 목표 좌표는 매 프레임 다시
/// 잡으므로 도는 행성도 정확히 잡는다. /// 잡으므로 도는 행성도 정확히 잡는다.
@@ -82,6 +82,10 @@ class _GalaxyScreenState extends State<GalaxyScreen>
late Future<GalaxySnapshot> _snapshot; late Future<GalaxySnapshot> _snapshot;
final _clock = GalaxyClock(); final _clock = GalaxyClock();
@visibleForTesting
GalaxyClock get clockForTest => _clock;
final _view = TransformationController(); final _view = TransformationController();
late final Ticker _ticker; late final Ticker _ticker;
@@ -394,9 +398,12 @@ class _Map extends StatelessWidget {
minScale: GalaxyMotion.minZoom, minScale: GalaxyMotion.minZoom,
maxScale: GalaxyMotion.maxZoom, maxScale: GalaxyMotion.maxZoom,
boundaryMargin: const EdgeInsets.all(240), boundaryMargin: const EdgeInsets.all(240),
// 이동(팬)의 관성은 InteractiveViewer 의 플링이 이미 준다. // 한 손가락 끌기는 맵을 옮기지 않고 **궤도를 돌린다**(프로토타입 View
// 여기서 더하는 것은 **회전 관성**이다 — 가로로 끌면 궤도가 따라 // Rotation · 별첨 1 ①-3 드래그 360° 회전). 팬을 켜 두면 가로로 끌 때
// 돌고, 손을 떼면 마찰이 기본 표류로 되돌린다. // 장면이 통째로 밀려 화면 밖으로 사라진다(10/4 실기기). 손을 떼면
// [GalaxyClock] 마찰이 기본 표류로 되돌린다(관성 · 체크리스트 3-2).
// 핀치 줌과 행성 탭 중심 전환은 그대로다.
panEnabled: false,
onInteractionStart: (_) => onGrab(), onInteractionStart: (_) => onGrab(),
// 세로 드래그·핀치 줌까지 넘기면 각속도를 덮어써 궤도가 선다. // 세로 드래그·핀치 줌까지 넘기면 각속도를 덮어써 궤도가 선다.
onInteractionUpdate: (d) => onDrag( onInteractionUpdate: (d) => onDrag(
@@ -19,6 +19,8 @@ import '../../../core/i18n/app_locale.dart';
/// - **게이지 조절 8개는 넣지 않는다(C-51).** /// - **게이지 조절 8개는 넣지 않는다(C-51).**
/// - 색은 테마 결정 전까지 프로토타입 값을 쓴다(C-49). /// - 색은 테마 결정 전까지 프로토타입 값을 쓴다(C-49).
/// - 데이터는 받은 [GalaxySnapshot] 만 쓴다. 프로토타입의 표본 5행성은 쓰지 않는다. /// - 데이터는 받은 [GalaxySnapshot] 만 쓴다. 프로토타입의 표본 5행성은 쓰지 않는다.
/// - 궤도는 FPI 순위, 첫 화면 시점은 내 행성 중앙(C-103). 140pt 에서는 내 행성 간략 카드
/// 한 장만, 다른 행성 HUD 없음(C-105). 드래그 회전은 관성 없음(C-105).
/// ///
/// 반응: 스냅샷이 바뀌면(순위·FPI) 행성이 새 궤도로 미끄러지고 HUD 숫자가 바뀐다(프로토타입 /// 반응: 스냅샷이 바뀌면(순위·FPI) 행성이 새 궤도로 미끄러지고 HUD 숫자가 바뀐다(프로토타입
/// `updateFPI`). 가로로 끌면 장면이 돈다(±180°, 프로토타입 View Rotation 범위). 누르면 [onOpen]. /// `updateFPI`). 가로로 끌면 장면이 돈다(±180°, 프로토타입 View Rotation 범위). 누르면 [onOpen].
@@ -33,7 +35,7 @@ class GalaxyMiniMap extends StatefulWidget {
final GalaxySnapshot data; final GalaxySnapshot data;
final VoidCallback onOpen; final VoidCallback onOpen;
/// 정사각 한 변. HOME 행성 영역 우측 상단(Q-DS-06 권고안 A · 회신 전 임시). /// 정사각 한 변. HOME 은 약 140pt(C-105 · 자리는 C-113 → `HomeHero.miniMapAlignment`).
final double size; final double size;
@override @override
@@ -172,6 +174,10 @@ final _tanHalfFov = math.tan(35 * math.pi / 180);
const _hudW = 512.0; const _hudW = 512.0;
const _hudH = 256.0; const _hudH = 256.0;
/// 별·위성 점 크기(논리 px). 프로토타입 점은 원근으로 1px 아래로 줄어 GL 최소 크기(버퍼
/// 1px, 픽셀비 2 → 0.5pt)로 찍힌다. 0 이면 1 물리 px 이라 3x 기기에서 1/3 로 옅어진다.
const _dot = 0.4;
/// 1 논리 픽셀보다 가는 선은 머리카락 선(1 물리 픽셀)으로. /// 1 논리 픽셀보다 가는 선은 머리카락 선(1 물리 픽셀)으로.
double _px(double w) => w < 1 ? 0 : w; double _px(double w) => w < 1 ? 0 : w;
@@ -293,18 +299,22 @@ ui.Picture _hudPicture(PlanetNode p, Color color, double share) {
return rec.endRecording(); return rec.endRecording();
} }
/// 반지름 1 단위의 광륜(6R, 가산 합성) + 발광 구체. /// 프로토타입 광륜 반지름(행성 반지름 배수). `aura.scale = R × curScale × 6` 이 다시
/// curScale(Mass x1.5)로 커지는 group 안에 있어 지름은 `6 × 1.5 R` 이다.
const _auraR = 3 * _massVolume;
/// 반지름 1 단위의 광륜(가산 합성) + 발광 구체.
ui.Picture _spherePicture(Color c) { ui.Picture _spherePicture(Color c) {
final rec = ui.PictureRecorder(); final rec = ui.PictureRecorder();
final canvas = Canvas(rec); final canvas = Canvas(rec);
canvas.drawCircle( canvas.drawCircle(
Offset.zero, Offset.zero,
3, _auraR,
Paint() Paint()
..blendMode = BlendMode.plus ..blendMode = BlendMode.plus
..shader = ui.Gradient.radial( ..shader = ui.Gradient.radial(
Offset.zero, Offset.zero,
3, _auraR,
[ [
c.withValues(alpha: 0.4), c.withValues(alpha: 0.4),
c.withValues(alpha: 0.15), c.withValues(alpha: 0.15),
@@ -360,19 +370,22 @@ class GalaxyMiniMapPainter extends CustomPainter {
/// 그리는 행성 수(내 행성은 순위 밖이어도 더한다). /// 그리는 행성 수(내 행성은 순위 밖이어도 더한다).
static const maxPlanets = 10; static const maxPlanets = 10;
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비). /// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비). 넘치면 카드를 통째로 줄인다(숫자는 자르지 않는다).
static const hudFloor = 0.7; static const hudFloor = 0.55;
/// 이 폭 이상이면 프로토타입 HUD 카드를 모든 행성에 그대로 그린다. /// 이 폭 이상이면 프로토타입 HUD 카드를 모든 행성에 그대로 그린다.
static const fullHudMin = 280.0; static const fullHudMin = 280.0;
/// 간략 카드 글자 크기(pt) — 이름 · 둘째 줄. /// 간략 카드 글자 크기(pt) — 이름 · 숫자 줄. 프로토타입 텍스처의 줄 구성(이름 / RANK · FPI /
static const compactNamePt = 11.0; /// LIVE)을 세 줄로 줄인 것이다. 140pt 에서 카드가 장면을 덮지 않게 한 줄을 짧게 둔다.
static const compactLinePt = 9.0; static const compactNamePt = 9.0;
static const compactLinePt = 8.0;
/// 간략 카드 둘째 줄 — `#순위 · FPI n · LIVE n`. /// 간략 카드 숫자 줄 — `#순위 · FPI n` / `LIVE n`.
static String compactLine(PlanetNode p) => static List<String> compactLines(PlanetNode p) => [
'#${p.rank} · FPI ${formatPop(p.fpi)} · LIVE ${formatPop(p.online)}'; '#${p.rank} · FPI ${formatPop(p.fpi)}',
'LIVE ${formatPop(p.online)}',
];
ui.Picture? _compact; ui.Picture? _compact;
Object? _compactKey; Object? _compactKey;
@@ -388,42 +401,49 @@ class GalaxyMiniMapPainter extends CustomPainter {
if (key == _compactKey && _compact != null) return _compact!; if (key == _compactKey && _compact != null) return _compact!;
_compact?.dispose(); _compact?.dispose();
_compactKey = key; _compactKey = key;
const pad = 6.0, barW = 3.0, gap = 4.0; const pad = 4.0, barW = 2.0, gap = 3.0;
final textMax = maxW - pad * 2 - barW - gap; TextPainter line(
TextPainter line(String text, double pt, FontWeight weight, Color c) => String text,
TextPainter( double pt,
FontWeight weight,
Color c, [
double maxWidth = double.infinity,
]) => TextPainter(
text: TextSpan( text: TextSpan(
text: text, text: text,
style: TextStyle( style: TextStyle(
fontFamily: AppFonts.body, fontFamily: AppFonts.body,
fontSize: pt, fontSize: pt,
fontWeight: weight, fontWeight: weight,
height: 1.2, height: 1.15,
color: c, color: c,
), ),
), ),
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
maxLines: 2, maxLines: 1,
ellipsis: '…', ellipsis: '…',
)..layout(maxWidth: textMax); )..layout(maxWidth: maxWidth);
final name = line( final texts = [
b.node.name.toUpperCase(), (b.node.name.toUpperCase(), compactNamePt, FontWeight.w800, b.color),
compactNamePt, for (final t in compactLines(b.node))
FontWeight.w800, (t, compactLinePt, FontWeight.w700, const Color(0xE6FFFFFF)),
b.color, ];
); final stats = [
// 좁으면 LIVE 앞에서 둘째 줄이 한 번 꺾인다(글자 크기는 줄이지 않는다). for (final (t, pt, w, c) in texts.skip(1)) line(t, pt, w, c),
final stats = line( ];
compactLine(b.node), final statsW = stats.fold<double>(0, (m, l) => math.max(m, l.width));
compactLinePt, // 긴 이름은 말줄임 — 카드 폭은 숫자 줄(또는 최대 폭)이 정한다.
FontWeight.w700, final (nt, npt, nw, nc) = texts.first;
const Color(0xE6FFFFFF), final nameMax = math.max(statsW, maxW - pad * 2 - gap - barW);
); final lines = [line(nt, npt, nw, nc, nameMax), ...stats];
final textW = math.max(name.width, stats.width); final textW = lines.fold<double>(0, (m, l) => math.max(m, l.width));
final textH = lines.fold<double>(0, (m, l) => m + l.height);
final w = pad * 2 + textW + gap + barW; final w = pad * 2 + textW + gap + barW;
final h = pad * 2 + name.height + 2 + stats.height; final h = pad * 2 + textH;
// 숫자 줄이 최대 폭을 넘을 때만 카드를 줄인다 — 줄을 자르면 순위·FPI 숫자가 사라진다.
final k = math.min(1.0, maxW / w);
final rec = ui.PictureRecorder(); final rec = ui.PictureRecorder();
final canvas = Canvas(rec); final canvas = Canvas(rec)..scale(k);
final box = Rect.fromLTWH(0, 0, w, h); final box = Rect.fromLTWH(0, 0, w, h);
canvas.drawRect(box, Paint()..color = const Color(0xE6000514)); canvas.drawRect(box, Paint()..color = const Color(0xE6000514));
canvas.drawRect( canvas.drawRect(
@@ -433,8 +453,13 @@ class GalaxyMiniMapPainter extends CustomPainter {
..strokeWidth = 1 ..strokeWidth = 1
..color = b.color.withValues(alpha: 0.8), ..color = b.color.withValues(alpha: 0.8),
); );
name.paint(canvas, const Offset(pad, pad)); var y = pad;
stats.paint(canvas, Offset(pad, pad + name.height + 2)); for (final l in lines) {
l.paint(canvas, Offset(pad, y));
y += l.height;
l.dispose();
}
// 프로토타입 막대 `160 * fpi / 5000` — 1위 대비 비율.
final barH = h - pad * 2; final barH = h - pad * 2;
canvas.drawRect( canvas.drawRect(
Rect.fromLTWH( Rect.fromLTWH(
@@ -445,13 +470,8 @@ class GalaxyMiniMapPainter extends CustomPainter {
), ),
Paint()..color = _emerald, Paint()..color = _emerald,
); );
compactText = [ compactText = [for (final (t, pt, _, _) in texts) (t, pt * k)];
(b.node.name.toUpperCase(), compactNamePt), _compactSize = Size(w * k, h * k);
(compactLine(b.node), compactLinePt),
];
name.dispose();
stats.dispose();
_compactSize = Size(w, h);
return _compact = rec.endRecording(); return _compact = rec.endRecording();
} }
@@ -466,6 +486,15 @@ class GalaxyMiniMapPainter extends CustomPainter {
@visibleForTesting @visibleForTesting
Color? colorOf(String id) => _bodies[id]?.color; Color? colorOf(String id) => _bodies[id]?.color;
/// 행성의 현재 월드 좌표(x, z). 장면 원점 = 화면 중앙. 없으면 null.
@visibleForTesting
Offset? worldOf(String id) =>
_bodies[id] == null ? null : Offset(_bodies[id]!.wx, _bodies[id]!.wz);
/// 마지막으로 그린 간략 카드 크기(논리 px).
@visibleForTesting
Size get compactSize => _compactSize;
/// 행성의 현재 궤도 반지름(월드 단위). 없으면 null. /// 행성의 현재 궤도 반지름(월드 단위). 없으면 null.
@visibleForTesting @visibleForTesting
double? orbitOf(String id) => _bodies[id]?.rad; double? orbitOf(String id) => _bodies[id]?.rad;
@@ -607,13 +636,14 @@ class GalaxyMiniMapPainter extends CustomPainter {
..shader = ui.Gradient.radial( ..shader = ui.Gradient.radial(
center, center,
r, r,
const [Color(0x0A10B981), Color(0x033B82F6), Color(0x00000000)], const [Color(0x0410B981), Color(0x013B82F6), Color(0x00000000)],
const [0, 0.4, 0.8], const [0, 0.4, 0.8],
), ),
); );
canvas.restore(); canvas.restore();
} }
// 프로토타입 별 8000개. 화면 넓이당 밀도는 390×844 화면 기준으로 맞춘다. // 프로토타입 별 8000개. 미니맵은 휴대폰 전체 화면 장면의 축소판으로 보이게, 화면 넓이당
// 밀도를 390×844 기준으로 맞춘다(덜어내지 않으면 140pt 에 별이 5배 빽빽하다).
final keep = (size.width * size.height / (390 * 844)).clamp(0.0, 1.0); final keep = (size.width * size.height / (390 * 844)).clamp(0.0, 1.0);
final pts = <double>[]; final pts = <double>[];
for (var i = 0; i < 8000; i++) { for (var i = 0; i < 8000; i++) {
@@ -632,7 +662,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
ui.PointMode.points, ui.PointMode.points,
Float32List.fromList(pts), Float32List.fromList(pts),
Paint() Paint()
..strokeWidth = 0 ..strokeWidth = _dot
..color = const Color(0xCCBFBFFF), ..color = const Color(0xCCBFBFFF),
); );
_bg = rec.endRecording(); _bg = rec.endRecording();
@@ -708,11 +738,9 @@ class GalaxyMiniMapPainter extends CustomPainter {
} }
if (out.isNotEmpty) { if (out.isNotEmpty) {
_fill _fill
..strokeWidth = 0 ..strokeWidth = _dot
..blendMode = BlendMode.plus ..blendMode = BlendMode.plus
..color = b.color.withValues( ..color = b.color.withValues(alpha: 0.9);
alpha: 0.9 * (2 * _scale * focal / d).clamp(0.2, 1.0),
);
canvas.drawRawPoints(ui.PointMode.points, out, _fill); canvas.drawRawPoints(ui.PointMode.points, out, _fill);
_fill.blendMode = BlendMode.srcOver; _fill.blendMode = BlendMode.srcOver;
} }
@@ -5,6 +5,7 @@ import '../../core/data/invite_code.dart';
import '../../core/data/server_clock.dart'; import '../../core/data/server_clock.dart';
import 'home_data.dart'; import 'home_data.dart';
import '../quest/quest_data.dart' show QuestType; import '../quest/quest_data.dart' show QuestType;
import '../quest/quest_repository_firestore.dart' show questToday;
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
/// 홈의 실 서버 구현 — 화면 대조 원장 F1. /// 홈의 실 서버 구현 — 화면 대조 원장 F1.
@@ -137,10 +138,13 @@ class FirestoreHomeRepository implements HomeRepository {
.collection('heroGiftProducts') .collection('heroGiftProducts')
.where('active', isEqualTo: true) .where('active', isEqualTo: true)
.get(); .get();
// 인기 K-INSIDE — 기능명세서 v1.4 §2.114-7 「최대 3개 소팅(미해결 + 고액 포인트 순)」.
// 미해결(`open`) 먼저, 그 안에서 현상금 큰 순. 정렬은 아래 [popularInsides].
// ponytail: 현상금 상위 20건 안에서만 고른다. (status, bountyPop) 색인을 배포하면 질의로 옮긴다.
final insidesF = db final insidesF = db
.collection('questions') .collection('questions')
.orderBy('createdAt', descending: true) .orderBy('bountyPop', descending: true)
.limit(3) .limit(20)
.get(); .get();
final wallet = (await walletF).data() ?? const <String, dynamic>{}; final wallet = (await walletF).data() ?? const <String, dynamic>{};
@@ -167,7 +171,9 @@ class FirestoreHomeRepository implements HomeRepository {
final campaigns = (await campaignF).docs final campaigns = (await campaignF).docs
.where((d) => !blocked.contains(d.data()['creatorUid'])) .where((d) => !blocked.contains(d.data()['creatorUid']))
.toList(); .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 progressById = {for (final doc in progress) doc.id: doc.data()};
final policy = (await policyF).data() ?? const <String, dynamic>{}; 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())], briefings: [for (final doc in briefings) ?_briefing(doc.data())],
campaign: campaigns.isEmpty ? null : _campaign(campaigns.first.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())], 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']), members: validInt(fandom['memberCount']),
requiredMembers: targets?.members, requiredMembers: targets?.members,
// Q-PN-01 A안 — 탄생은 창립자만. // Q-PN-01 A안 — 탄생은 창립자만.
isFounder: fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid, isFounder:
fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
requiredSfpi: validNumber(next?['requiredSfpi']), requiredSfpi: validNumber(next?['requiredSfpi']),
nebulaGoalPassed: goalPassed, nebulaGoalPassed: goalPassed,
); );
@@ -296,9 +303,10 @@ class FirestoreHomeRepository implements HomeRepository {
Map<String, dynamic>? progress, Map<String, dynamic>? progress,
Map<String, dynamic> policy, Map<String, dynamic> policy,
) { ) {
final done = // 퀘스트 목록 · 상세와 같은 판정(`questToday`) — 오늘(KST) 행만 센다.
(progress?.intOr('count') ?? 0) >= quest.intOr('dailyLimit', 1); final (:count, :done) =
final started = (progress?.intOr('count') ?? 0) > 0; questToday(QuestType.parse(quest.strOr('type')), quest, progress, policy);
final started = count > 0;
return QuestSummary( return QuestSummary(
title: quest.strOr('title'), title: quest.strOr('title'),
rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy), rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
@@ -354,6 +362,15 @@ String _timeAgo(DateTime at) {
return appL10n.timeDaysAgo('${gap.inDays}'); 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` 와 같은 규약이다. /// 오늘 날짜(KST `YYYY-MM-DD`) — 서버 `briefings.dayKey` 와 같은 규약이다.
/// ///
/// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이 /// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이
+69 -5
View File
@@ -31,6 +31,7 @@ import 'widgets/item_sheet.dart';
import 'widgets/inside_card.dart'; import 'widgets/inside_card.dart';
import 'widgets/live_ticker.dart'; import 'widgets/live_ticker.dart';
import '../campaign/campaign_data.dart' show CampaignRepository; import '../campaign/campaign_data.dart' show CampaignRepository;
import '../campaign/campaign_create_screen.dart';
import '../campaign/campaign_list_screen.dart'; import '../campaign/campaign_list_screen.dart';
import '../notification/notification_screen.dart'; import '../notification/notification_screen.dart';
import '../quest/quest_data.dart' show QuestRepository; import '../quest/quest_data.dart' show QuestRepository;
@@ -150,7 +151,9 @@ class _HomeScreenState extends State<HomeScreen> {
debugPrint('[HOME] load failed: ${snap.error}'); debugPrint('[HOME] load failed: ${snap.error}');
return SafeArea(child: _LoadFailed(onRetry: _refresh)); return SafeArea(child: _LoadFailed(onRetry: _refresh));
} }
return _HomeBody( return _StatusBarScrim(
height: MediaQuery.paddingOf(context).top,
child: _HomeBody(
data: snap.data!, data: snap.data!,
galaxy: _galaxy, galaxy: _galaxy,
zap: _zap, zap: _zap,
@@ -163,6 +166,7 @@ class _HomeScreenState extends State<HomeScreen> {
campaignRepository: widget.campaignRepository, campaignRepository: widget.campaignRepository,
eclipse: widget.eclipse, eclipse: widget.eclipse,
notifications: widget.notifications, notifications: widget.notifications,
),
); );
}, },
), ),
@@ -352,7 +356,7 @@ class _HomeBody extends StatelessWidget {
zap == null zap == null
? null ? null
: GalaxyMiniMap( : GalaxyMiniMap(
size: 140, size: HomeHero.miniMapSize,
data: galaxyState.data!, data: galaxyState.data!,
onOpen: () => onOpen: () =>
Navigator.of(context, rootNavigator: true).push( Navigator.of(context, rootNavigator: true).push(
@@ -531,7 +535,9 @@ class _HomeBody extends StatelessWidget {
const SizedBox(height: _sectionGap), const SizedBox(height: _sectionGap),
], ],
if (data.campaign case final c?) ...[ // HOT ARCHIVE 는 모금 중 캠페인이 없어도 둔다 — 기능명세서 v1.4 §2.114-6
// (`2097:16415`) 「더보기 버튼 - HOT ARCHIVE 화면 이동」에 숨김 규칙이 없고,
// 캠페인 목록·개설로 가는 길이 이 구획뿐이다(공통 기준 5항).
Padding( Padding(
padding: _h, padding: _h,
child: SectionHeader( child: SectionHeader(
@@ -542,13 +548,26 @@ class _HomeBody extends StatelessWidget {
const SizedBox(height: _headerGap), const SizedBox(height: _headerGap),
Padding( Padding(
padding: _h, padding: _h,
child: CampaignCard( child: switch (data.campaign) {
final c? => CampaignCard(
campaign: c, campaign: c,
onTap: () => _openCampaigns(context, campaignRepository), 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), const SizedBox(height: _sectionGap),
],
Padding( Padding(
padding: _h, 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 — 화면 표기는 「활동/지원」이다. /// 상단 활동 POP 잔액. 정책 설계서 §P-02 — 화면 표기는 「활동/지원」이다.
class _PopBalance extends StatelessWidget { class _PopBalance extends StatelessWidget {
const _PopBalance({this.value}); const _PopBalance({this.value});
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart'; import '../../core/ui/app_icons.dart';
import '../../core/data/backend.dart' show BackendException; import '../../core/data/backend.dart' show BackendException;
import 'home_data.dart'; 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/data/backend.dart' show BackendException;
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart'; import '../../core/ui/app_icons.dart';
import '../../core/ui/planet_25d.dart' show planetColors; import '../../core/ui/planet_25d.dart' show planetColors;
import 'home_data.dart'; import 'home_data.dart';
@@ -245,12 +246,12 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
); );
Future<void> _confirm() async { Future<void> _confirm() async {
final ok = await showDialog<bool>( final ok = await showConfirmDialog(
context: context, context,
barrierColor: const Color(0xB3000000), context.l10n.planetCreateConfirm,
builder: (context) => const _ConfirmDialog(), confirmLabel: context.l10n.commonCreate,
); );
if (ok != true || !mounted || _busy) return; if (!ok || !mounted || _busy) return;
_busy = true; _busy = true;
try { try {
await widget.repository.createPlanet( 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`). /// ZAP 옵션 「아이템」 — 사본 「아이템 선택」 시트(`343:204868`).
final VoidCallback? onItem; final VoidCallback? onItem;
/// GALAXY 미니맵 — 행성 영역 우측 상단(해석 확인 요청서 Q-DS-06 권고안 A · 회신 전 임시). /// GALAXY 미니맵 — 히어로 우측 상단 [miniMapTop] · 오른쪽 끝(여백 0).
/// 사본 홈에는 없는 요소라 행성 칸 모서리에만 얹고 문구·카드와 겹치지 않게 한다.
final Widget? miniMap; 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 포함). /// 사본 기준 배경 높이(상태 표시줄 48 포함).
static const double designHeight = 680; static const double designHeight = 680;
@@ -191,8 +202,7 @@ class _HomeHeroState extends State<HomeHero> {
glow: AppColors.neonViolet, 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?) if (widget.ticker case final ticker?)
Positioned(top: top + 102, left: 0, right: 0, child: ticker), Positioned(top: top + 102, left: 0, right: 0, child: ticker),
+7
View File
@@ -198,6 +198,9 @@ abstract interface class InsideRepository {
/// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다. /// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다.
Future<void> accept(String questionId, String answerId); Future<void> accept(String questionId, String answerId);
/// 내가 쓴 질문 — MY > 활동 > 인사이드. 최신순.
Future<List<InsideQuestion>> mine();
} }
/// 현상금 Q&A 실패를 화면 문구로 — 서버 카탈로그 코드(`E-QNA-*` 등)로 가른다. /// 현상금 Q&A 실패를 화면 문구로 — 서버 카탈로그 코드(`E-QNA-*` 등)로 가른다.
@@ -312,6 +315,10 @@ class SampleInsideRepository implements InsideRepository {
@override @override
Future<int> balance() async => 12_400; Future<int> balance() async => 12_400;
@override
Future<List<InsideQuestion>> mine() async =>
_sorted(InsideSort.latest).where((q) => q.isMine).toList();
@override @override
Future<int> minBounty() async => 100; Future<int> minBounty() async => 100;
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart'; import '../../core/ui/app_icons.dart';
@@ -105,25 +107,12 @@ class _InsideDetailScreenState extends State<InsideDetailScreen> {
a.id.isNotEmpty; a.id.isNotEmpty;
Future<void> _accept(InsideAnswer a) async { Future<void> _accept(InsideAnswer a) async {
final ok = await showDialog<bool>( final ok = await showConfirmDialog(
context: context, context,
builder: (dialog) => AlertDialog(
content: Text(
context.l10n.insideAcceptConfirm('${question.bountyPop}'), context.l10n.insideAcceptConfirm('${question.bountyPop}'),
), confirmLabel: context.l10n.insideAccept,
actions: [
TextButton(
onPressed: () => Navigator.of(dialog).pop(false),
child: Text(context.l10n.commonCancel),
),
TextButton(
onPressed: () => Navigator.of(dialog).pop(true),
child: Text(context.l10n.insideAccept),
),
],
),
); );
if (ok != true || _busy || !mounted) return; if (!ok || _busy || !mounted) return;
setState(() => _busy = true); setState(() => _busy = true);
try { try {
await widget.repository!.accept(question.id, a.id); await widget.repository!.accept(question.id, a.id);
@@ -95,6 +95,18 @@ class FirestoreInsideRepository implements InsideRepository {
); );
} }
/// 팬덤과 상관없이 내가 쓴 질문 전부(`authorUid` 등호 하나라 복합 색인이 필요 없다).
// ponytail: 정렬은 앱에서 한다. 한 사람의 질문이 수백 건을 넘으면 orderBy + 복합 색인 + 페이지로.
@override
Future<List<InsideQuestion>> mine() async {
final snap = await _backend.db
.collection('questions')
.where('authorUid', isEqualTo: _backend.requireUid)
.get();
return [for (final doc in snap.docs) _question(doc)]
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
@override @override
Future<int> balance() async { Future<int> balance() async {
final wallet = final wallet =
+2 -2
View File
@@ -115,8 +115,8 @@ class _AccountLinkScreenState extends State<AccountLinkScreen> {
switch (outcome) { switch (outcome) {
case AuthFailed(:final code, :final message): case AuthFailed(:final code, :final message):
_toast('[$code] $message'); _toast('[$code] $message');
case AuthNotConfigured(:final missing): case AuthNotConfigured():
_toast(context.l10n.settingsLinkNotReady(_name, missing.join(' · '))); _toast(context.l10n.settingsLinkNotReady(_name));
case AuthSucceeded() || AuthCancelled(): case AuthSucceeded() || AuthCancelled():
break; break;
} }
+29 -45
View File
@@ -33,10 +33,18 @@ enum MyListKind {
enum _MySort { latest, pop, solved, achieved } enum _MySort { latest, pop, solved, achieved }
class MyListsScreen extends StatefulWidget { class MyListsScreen extends StatefulWidget {
const MyListsScreen({super.key, required this.kind, this.campaigns}); const MyListsScreen({
super.key,
required this.kind,
this.campaigns,
this.insides,
});
final MyListKind kind; final MyListKind kind;
/// 내 인사이드의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
final InsideRepository? insides;
/// 내 캠페인의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart. /// 내 캠페인의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
final CampaignRepository? campaigns; final CampaignRepository? campaigns;
@@ -47,44 +55,6 @@ class MyListsScreen extends StatefulWidget {
class _MyListsScreenState extends State<MyListsScreen> { class _MyListsScreenState extends State<MyListsScreen> {
_MySort _sort = _MySort.latest; _MySort _sort = _MySort.latest;
/// 표본 — 시안 `370:228289` 문구. 서버가 붙으면 INSIDE 저장소의 내 글로 옮긴다.
static final _insides = () {
final now = DateTime.now();
return <InsideQuestion>[
InsideQuestion(
id: 'i1',
title: 'BTS 컴백 알려주세요!',
body: 'BTS 한국 컴백 시기가 궁급합니다!\n아시는 분들 댓글 부탁드려요 ㅠㅠ',
authorName: 'BTS_Hong',
createdAt: now,
bountyPop: 100,
isMine: true,
),
InsideQuestion(
id: 'i2',
title: '화양연화 세계관 해석해주실 분?',
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(minutes: 10)),
bountyPop: 500,
viewCount: 3,
isMine: true,
),
InsideQuestion(
id: 'i3',
title: 'BTS 이 포즈 뭔가요?',
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(days: 30)),
bountyPop: 800,
answerCount: 24,
viewCount: 82,
isMine: true,
isSolved: true,
),
];
}();
List<_MySort> get _sorts => switch (widget.kind) { List<_MySort> get _sorts => switch (widget.kind) {
MyListKind.inside => const [_MySort.latest, _MySort.pop, _MySort.solved], MyListKind.inside => const [_MySort.latest, _MySort.pop, _MySort.solved],
MyListKind.campaign => const [_MySort.latest, _MySort.achieved], MyListKind.campaign => const [_MySort.latest, _MySort.achieved],
@@ -132,13 +102,27 @@ class _MyListsScreenState extends State<MyListsScreen> {
}, },
); );
Widget _insideList(BuildContext context) { /// 내가 쓴 질문 — 화면을 열 때 한 번 읽고, 정렬 칩은 그 목록을 다시 거른다.
late final Future<List<InsideQuestion>> _mine =
(widget.insides ?? const SampleInsideRepository()).mine();
Widget _insideList(BuildContext context) =>
FutureBuilder<List<InsideQuestion>>(
future: _mine,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
return _insideItems(context, snap.data ?? const []);
},
);
Widget _insideItems(BuildContext context, List<InsideQuestion> mine) {
final items = switch (_sort) { final items = switch (_sort) {
_MySort.pop => [ _MySort.pop => [...mine]
..._insides, ..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
]..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)), _MySort.solved => mine.where((q) => q.isSolved).toList(),
_MySort.solved => _insides.where((q) => q.isSolved).toList(), _ => mine,
_ => _insides,
}; };
if (items.isEmpty) { if (items.isEmpty) {
return Center( return Center(
+19 -4
View File
@@ -9,6 +9,7 @@ import '../../core/ui/app_icons.dart';
import '../auth/auth.dart'; import '../auth/auth.dart';
import '../auth/auth_repository.dart'; import '../auth/auth_repository.dart';
import '../campaign/campaign_data.dart' show CampaignRepository; import '../campaign/campaign_data.dart' show CampaignRepository;
import '../inside/inside_data.dart' show InsideRepository;
import 'my_data.dart'; import 'my_data.dart';
import 'my_lists_screen.dart'; import 'my_lists_screen.dart';
import 'profile_edit_screen.dart'; import 'profile_edit_screen.dart';
@@ -29,6 +30,7 @@ class MyScreen extends StatefulWidget {
this.push, this.push,
this.auth, this.auth,
this.campaigns, this.campaigns,
this.insides,
}); });
final MyRepository repository; final MyRepository repository;
@@ -36,6 +38,9 @@ class MyScreen extends StatefulWidget {
/// 「내 캠페인」이 읽는 저장소. null 이면 표본. /// 「내 캠페인」이 읽는 저장소. null 이면 표본.
final CampaignRepository? campaigns; final CampaignRepository? campaigns;
/// 「내 인사이드」가 읽는 저장소. null 이면 표본.
final InsideRepository? insides;
/// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다. /// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다.
final AccountLinker? linker; final AccountLinker? linker;
@@ -251,7 +256,10 @@ class _MyScreenState extends State<MyScreen> {
_Row( _Row(
label: context.l10n.myInside, label: context.l10n.myInside,
onTap: () => _push( onTap: () => _push(
const MyListsScreen(kind: MyListKind.inside), MyListsScreen(
kind: MyListKind.inside,
insides: widget.insides,
),
), ),
), ),
_Row( _Row(
@@ -314,16 +322,23 @@ class _MyScreenState extends State<MyScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: ProfileCard( child: ProfileCard(
profile: profile, profile: profile,
onShare: (target) { onShare: (target) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(this.context);
Navigator.of(context).pop(); Navigator.of(context).pop();
ScaffoldMessenger.of(this.context) try {
if (!await shareProfile(profile, target)) return;
messenger
..hideCurrentSnackBar() ..hideCurrentSnackBar()
..showSnackBar( ..showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.myShareLater(target)), content: Text(l10n.shareCopied),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
} catch (_) {
messenger.showSnackBar(SnackBar(content: Text(l10n.commonRequestFailed)));
}
}, },
), ),
), ),
+9 -132
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/push/push_messaging.dart'; import '../../core/push/push_messaging.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart'; import '../../core/ui/app_icons.dart';
import '../auth/auth.dart'; import '../auth/auth.dart';
import '../auth/auth_repository.dart'; import '../auth/auth_repository.dart';
@@ -281,13 +282,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
await _notice(context.l10n.settingsSameEmailOnly); await _notice(context.l10n.settingsSameEmailOnly);
case AuthFailed(:final code, :final message): case AuthFailed(:final code, :final message):
_toast('[$code] $message'); _toast('[$code] $message');
case AuthNotConfigured(:final missing): case AuthNotConfigured():
_toast( _toast(context.l10n.settingsLinkNotReady(_snsName(context, provider)));
context.l10n.settingsLinkNotReady(
_snsName(context, provider),
missing.join(' · '),
),
);
case AuthSucceeded() || AuthCancelled(): case AuthSucceeded() || AuthCancelled():
break; break;
} }
@@ -344,19 +340,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
/// 안내 팝업(확인 하나) — 시안 `554:23103`. /// 안내 팝업(확인 하나) — 시안 `554:23103`.
Future<void> _notice(String message) => showDialog<void>( Future<void> _notice(String message) => showAlertDialog(context, message);
context: context,
builder: (context) => _MyDialog(
title: message,
actions: [
_DialogButton(
label: context.l10n.commonConfirm,
filled: true,
onTap: () => Navigator.of(context).pop(),
),
],
),
);
/// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`. /// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`.
Future<bool> _confirm({ Future<bool> _confirm({
@@ -366,30 +350,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool withdraw = false, bool withdraw = false,
Future<void> Function()? onConfirm, Future<void> Function()? onConfirm,
}) async { }) async {
final ok = await showDialog<bool>( final ok = await showConfirmDialog(
context: context, context,
builder: (context) => _MyDialog( title,
title: title,
body: body, body: body,
actions: [ confirmLabel: action,
_DialogButton(
label: context.l10n.commonCancel,
onTap: () => Navigator.of(context).pop(false),
),
_DialogButton(
label: action,
filled: true,
onTap: () => Navigator.of(context).pop(true),
),
],
),
); );
if (ok == true && onConfirm != null) { if (ok == true && onConfirm != null) {
await onConfirm(); await onConfirm();
} else if (ok == true && withdraw && mounted) { } else if (ok == true && withdraw && mounted) {
_toast(context.l10n.settingsWithdrawLater); _toast(context.l10n.settingsWithdrawLater);
} }
return ok ?? false; return ok;
} }
} }
@@ -522,101 +494,6 @@ class _SnsRow extends StatelessWidget {
} }
/// 팝업 — 바탕 #2d2d2d · 좌우 31.5 · 안쪽 24/16 · 제목 16/600 가운데 · 버튼 높이 48 간격 8. /// 팝업 — 바탕 #2d2d2d · 좌우 31.5 · 안쪽 24/16 · 제목 16/600 가운데 · 버튼 높이 48 간격 8.
class _MyDialog extends StatelessWidget {
const _MyDialog({required this.title, this.body, required this.actions});
final String title;
final String? body;
final List<Widget> actions;
@override
Widget build(BuildContext context) => Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 31.5),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: myText(16, FontWeight.w600),
),
if (body case final b?) ...[
const SizedBox(height: 16),
Text(
b,
textAlign: TextAlign.center,
style: myText(16, FontWeight.w500),
),
],
const SizedBox(height: 24),
Row(
children: [
for (final (i, a) in actions.indexed) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: a),
],
],
),
],
),
),
);
}
class _DialogButton extends StatelessWidget {
const _DialogButton({
required this.label,
required this.onTap,
this.filled = false,
});
final String label;
final VoidCallback onTap;
final bool filled;
@override
Widget build(BuildContext context) {
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
);
final text = Text(
label,
style: myText(
16,
FontWeight.w600,
height: 1,
color: filled ? AppColors.onCosmicAccent : AppColors.onCosmic,
),
);
return SizedBox(
height: 48,
child: filled
? FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: EdgeInsets.zero,
backgroundColor: AppColors.cosmicAccent,
shape: shape,
),
child: text,
)
: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: EdgeInsets.zero,
side: const BorderSide(color: AppColors.onCosmic),
shape: shape,
),
child: text,
),
);
}
}
/// 언어 설정 시트 — 시안 `357:224379`: 제목 + 닫기 · 언어 목록(고른 것에 분홍 체크) · 「선택」. /// 언어 설정 시트 — 시안 `357:224379`: 제목 + 닫기 · 언어 목록(고른 것에 분홍 체크) · 「선택」.
class _LanguageSheet extends StatefulWidget { class _LanguageSheet extends StatefulWidget {
const _LanguageSheet({required this.current}); const _LanguageSheet({required this.current});
+32 -1
View File
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/format.dart';
import '../../../core/theme/tokens.dart'; import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart'; import '../../../core/ui/app_icons.dart';
import '../my_data.dart'; import '../my_data.dart';
import 'my_cards.dart'; import 'my_cards.dart';
import '../../../core/i18n/app_locale.dart'; import '../../../core/i18n/app_locale.dart';
import '../../campaign/campaign_share.dart' show shareHashtags;
/// 프로필 카드 오버레이 — 시안 `374:15738`(명세 `549:22058`). /// 프로필 카드 오버레이 — 시안 `374:15738`(명세 `549:22058`).
/// ///
@@ -17,11 +21,12 @@ class ProfileCard extends StatelessWidget {
final void Function(String target) onShare; final void Function(String target) onShare;
/// 공유 대상과 시안 아이콘. 명세가 적은 순서 그대로다. /// 공유 대상과 시안 아이콘. 명세가 적은 순서 그대로다.
/// 프로필 웹 주소가 아직 없어 마지막 칸은 「링크 복사」가 아니라 「문구 복사」다.
static List<String> get shareTargets => [ static List<String> get shareTargets => [
appL10n.shareInstagram, appL10n.shareInstagram,
appL10n.shareTiktok, appL10n.shareTiktok,
'X', 'X',
appL10n.shareCopyLink, appL10n.shareCopyText,
]; ];
static const _shareIcons = [ static const _shareIcons = [
'assets/figma/share-instagram.png', 'assets/figma/share-instagram.png',
@@ -106,3 +111,29 @@ class ProfileCard extends StatelessWidget {
), ),
); );
} }
/// 프로필 공유 문구 — 공유 시트와 복사가 같은 문구를 쓴다.
String profileShareText(MyProfile profile, [AppLocalizations? l10n]) => [
(l10n ?? appL10n).myShareText(
profile.nickname,
'${profile.userLevel}',
formatPop(profile.contributedPop),
),
shareHashtags().join(' '),
].join('\n');
/// 프로필 카드 공유. 앱별 전용 SDK 없이 OS 공유 시트로 보내고(사용자가 그 안에서
/// 인스타 · 틱톡 · X 를 고른다), 마지막 칸은 문구를 복사한다. 복사했으면 true.
Future<bool> shareProfile(
MyProfile profile,
String target, {
Future<ShareResult> Function(ShareParams)? share,
}) async {
final text = profileShareText(profile);
if (target == ProfileCard.shareTargets.last) {
await Clipboard.setData(ClipboardData(text: text));
return true;
}
await (share ?? SharePlus.instance.share)(ShareParams(text: text));
return false;
}
@@ -192,11 +192,6 @@ class _RankingBody extends StatelessWidget {
return ListView( return ListView(
padding: const EdgeInsets.all(AppSpace.s4), padding: const EdgeInsets.all(AppSpace.s4),
children: [ children: [
// 실데이터가 아니면 숨기지 않고 그대로 알린다.
if (data.mockNote case final note?) ...[
MockBadge(note: note),
const SizedBox(height: AppSpace.s3),
],
if (data.mine case final me?) ...[ if (data.mine case final me?) ...[
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)), Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
const SizedBox(height: AppSpace.s4), const SizedBox(height: AppSpace.s4),
@@ -461,29 +456,3 @@ class _Podium extends StatelessWidget {
], ],
); );
} }
/// 실데이터가 아닌 자리를 숨기지 않고 그대로 알리는 배지 — 대조표 3-5.
/// 랭킹 화면과 행성 상세 FPI 탭이 같은 배지를 쓴다.
class MockBadge extends StatelessWidget {
const MockBadge({super.key, required this.note});
final String note;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(AppSpace.s4, AppSpace.s3, AppSpace.s4, 0),
padding: const EdgeInsets.all(AppSpace.s3),
decoration: BoxDecoration(
color: AppColors.cosmicVoid,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.neonViolet),
),
child: Text(
'MOCK · $note',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: AppColors.neonViolet),
),
);
}
+3 -3
View File
@@ -130,7 +130,7 @@ class FpiRanking {
final FpiRankRow? mine; final FpiRankRow? mine;
/// 실데이터가 아닌 경우의 사유. null 이면 실연동이다. /// 실데이터가 아닌 경우의 사유. null 이면 실연동이다.
/// 화면이 이 문구를 그대로 배지로 띄운다 — 숨기지 않는다. /// 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
final String? mockNote; final String? mockNote;
bool get isMock => mockNote != null; bool get isMock => mockNote != null;
@@ -279,8 +279,8 @@ class PlanetDetail {
final int members; final int members;
final List<LoungePost> posts; final List<LoungePost> posts;
/// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유. 화면이 그대로 배지로 /// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유.
/// 띄운다 — 숨기지 않는다(대조표 3-5). /// 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
final String? mockNote; final String? mockNote;
/// 육각형 그래프 정규화 기준. /// 육각형 그래프 정규화 기준.
@@ -383,11 +383,6 @@ class _FpiTab extends StatelessWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
sliver: SliverList.list( sliver: SliverList.list(
children: [ children: [
if (detail.mockNote case final note?)
Padding(
padding: const EdgeInsets.only(bottom: AppSpace.s3),
child: MockBadge(note: note),
),
const _NebulaFpi(), const _NebulaFpi(),
], ],
), ),
@@ -400,12 +395,6 @@ class _FpiTab extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
sliver: SliverList.list( sliver: SliverList.list(
children: [ children: [
// 실데이터가 아닌 자리가 남아 있으면 숨기지 않고 그대로 알린다(대조표 3-5).
if (detail.mockNote case final note?)
Padding(
padding: const EdgeInsets.only(bottom: AppSpace.s3),
child: MockBadge(note: note),
),
// 침식·방어 진행 — `fn-eclipse-aggregate` 의 집계 문서와 샤드를 구독한다. // 침식·방어 진행 — `fn-eclipse-aggregate` 의 집계 문서와 샤드를 구독한다.
// TOTAL FPI 카드 위에 두는 이유는 침식이 곧 이 카드의 값을 깎기 때문이다 // TOTAL FPI 카드 위에 두는 이유는 침식이 곧 이 카드의 값을 깎기 때문이다
// (원장 B-55 — FPI 감소는 Eclipse 가 담당한다). // (원장 B-55 — FPI 감소는 Eclipse 가 담당한다).
@@ -705,12 +694,17 @@ class _ScoreCard extends StatelessWidget {
), ),
Row( Row(
children: [ children: [
// 순위가 없으면(집계 모집단 밖 · 0) 「전체 0위」 대신 「순위 집계 전」.
Text( Text(
context.l10n.planetOverallRank('${score.rank}'), score.rank > 0
? context.l10n.planetOverallRank('${score.rank}')
: context.l10n.planetRankNone,
style: _t(12, color), style: _t(12, color),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
if (trend == 0) if (score.rank <= 0)
const SizedBox.shrink()
else if (trend == 0)
Text( Text(
'-', '-',
style: _t( style: _t(
@@ -765,7 +759,7 @@ class _ScoreCard extends StatelessWidget {
// 서버 산출값이 없으면 숫자를 짓지 않는다 — 「상위 100%」는 // 서버 산출값이 없으면 숫자를 짓지 않는다 — 「상위 100%」는
// 최하위라는 사실 주장이 되어 버린다(대조표 3-5). // 최하위라는 사실 주장이 되어 버린다(대조표 3-5).
score.myTopPercent == null score.myTopPercent == null
? context.l10n.planetTallying ? '—'
: context.l10n.planetTopPercent('${score.myTopPercent}'), : context.l10n.planetTopPercent('${score.myTopPercent}'),
style: _t(10, AppColors.onCosmic, height: 1), style: _t(10, AppColors.onCosmic, height: 1),
), ),
@@ -176,8 +176,10 @@ class FirestorePlanetRepository implements PlanetRepository {
trend: daily.isEmpty trend: daily.isEmpty
? 0 ? 0
: axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)), : axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)),
// 없으면 null — 화면이 숫자 대신 「집계 준비 중」을 띄운다. // 없으면 null — 화면이 숫자 대신 「—」를 띄운다. 행성 값이 0 이면
myTopPercent: myTopOf(metric), // 기여를 나눌 모수가 없으니 남아 있는 값(시드 등)도 쓰지 않는다.
myTopPercent:
axes.intOr(_axisKey(metric)) > 0 ? myTopOf(metric) : null,
), ),
], ],
posts: posts, posts: posts,
+4 -2
View File
@@ -95,7 +95,8 @@ class _AdBonusDialog extends StatelessWidget {
child: Text( child: Text(
bonusPop == null bonusPop == null
? context.l10n.adBonusPlaceholder ? context.l10n.adBonusPlaceholder
: '+${formatPop(bonusPop!)} POP', // 원장 C-116 — 운영값 기반 「+N 기여 POP」(증분은 기여 POP · C-122).
: '+${formatPop(bonusPop!)} ${context.l10n.popKindContribution}',
style: TextStyle( style: TextStyle(
fontSize: 36, fontSize: 36,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -285,7 +286,8 @@ class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
void initState() { void initState() {
super.initState(); super.initState();
// 실패해도 닫는다 — 오류는 [until] 을 만든 쪽이 따로 받는다. // 실패해도 닫는다 — 오류는 [until] 을 만든 쪽이 따로 받는다.
unawaited(widget.until?.catchError((_) => null).whenComplete(_close)); // `catchError((_) => null)` 은 `Future<int>` 처럼 null 을 못 받는 타입에서 던진다.
unawaited(widget.until?.then<void>((_) {}, onError: (_) {}).whenComplete(_close));
} }
/// 맨 위가 아니어도 이 화면만 걷어 낸다. 지급이 끝나 보상 화면이 먼저 올라와도 /// 맨 위가 아니어도 이 화면만 걷어 낸다. 지급이 끝나 보상 화면이 먼저 올라와도
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/data/backend.dart'; import '../../core/data/backend.dart';
import '../../core/data/server_clock.dart'; import '../../core/data/server_clock.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
@@ -401,12 +403,12 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
Future<void> _confirm() async { Future<void> _confirm() async {
final pool = _paid ? _pool : 0; final pool = _paid ? _pool : 0;
final fee = _catalog?.feeOf(pool) ?? 0; final fee = _catalog?.feeOf(pool) ?? 0;
final go = await showDialog<bool>( final go = await showConfirmDialog(
context: context, context,
builder: (context) => AlertDialog( context.l10n.questCreateConfirm,
title: Text(context.l10n.questCreateConfirm), cancelLabel: context.l10n.commonKeepEditing,
content: Text( confirmLabel: context.l10n.questCreate,
context.l10n.questCreateCost( body: context.l10n.questCreateCost(
_preview.title, _preview.title,
formatPop(_intOf(_reward)), formatPop(_intOf(_reward)),
formatPop(_intOf(_participants)), formatPop(_intOf(_participants)),
@@ -414,20 +416,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
formatPop(fee), formatPop(fee),
formatPop(pool + fee), formatPop(pool + fee),
), ),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonKeepEditing),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.questCreate),
),
],
),
); );
if (go != true || !mounted) return; if (!go || !mounted) return;
setState(() => _saving = true); setState(() => _saving = true);
final String id; final String id;
+10 -6
View File
@@ -119,6 +119,7 @@ enum QuestType {
/// ///
/// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다 /// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다
/// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다. /// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다.
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
bool get verifiable => switch (this) { bool get verifiable => switch (this) {
QuestType.attend || QuestType.attend ||
QuestType.steps || QuestType.steps ||
@@ -131,12 +132,8 @@ enum QuestType {
}; };
/// 참여를 열지 않는 사유. 열려 있으면 null 이다. /// 참여를 열지 않는 사유. 열려 있으면 null 이다.
String? get blockedReason => switch (this) { String? get blockedReason =>
QuestType.musicShake || QuestType.streaming => verifiable ? null : appL10n.questTypeUnverifiable;
'발주사 확인 중입니다 — 스밍·Music Shake 유형은 회신 대기 항목입니다(Q-QC-04).',
QuestType.unknown => appL10n.questTypeUnverifiable,
_ => null,
};
} }
/// 서버가 판정하고 지급한 결과 — `fn-quest-*` 응답. /// 서버가 판정하고 지급한 결과 — `fn-quest-*` 응답.
@@ -507,6 +504,10 @@ abstract interface class QuestRepository {
Future<String> create(QuestDraft draft); Future<String> create(QuestDraft draft);
/// 보유 일반 POP(`wallet/main.activityBalance`) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다.
/// 모르면 null(헤더는 받은 값을 유지한다).
Future<int?> popBalance();
/// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다. /// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다.
/// ///
/// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률 /// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률
@@ -730,6 +731,9 @@ class SampleQuestRepository implements QuestRepository {
@override @override
Future<String> create(QuestDraft draft) async => 'q-new'; Future<String> create(QuestDraft draft) async => 'q-new';
@override
Future<int?> popBalance() async => null;
/// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌 /// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌
/// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다. /// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다.
@override @override
+59 -105
View File
@@ -2,13 +2,14 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/data/backend.dart'; import '../../core/data/backend.dart';
import '../../core/data/server_clock.dart'; import '../../core/data/server_clock.dart';
import '../../core/monetization/rewarded_ad.dart'; import '../../core/monetization/rewarded_ad.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart'; import '../../core/ui/app_icons.dart';
import '../home/home_screen.dart' show formatPop; import '../home/home_screen.dart' show formatPop;
import '../planet/fpi_ranking_screen.dart' show MockBadge;
import '../planet/moderation.dart'; import '../planet/moderation.dart';
import 'quest_ad_screens.dart'; import 'quest_ad_screens.dart';
import 'quest_create_screen.dart' show QuestLinkCard; import 'quest_create_screen.dart' show QuestLinkCard;
@@ -135,9 +136,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
color: Color(0xFF2E2E2E), color: Color(0xFF2E2E2E),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: image == null child: Image.asset(image, width: 200, height: 200),
? null
: Image.asset(image, width: 200, height: 200),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -232,8 +231,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
), ),
), ),
// 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위. // 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위.
// 실데이터가 아닌 표시는 기존 관례대로 MockBadge 로 알린다. if (blocked != null)
if (blocked != null) MockBadge(note: blocked), Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(blocked, style: _strong),
),
if (submission != null) ..._submissionStatus(submission), if (submission != null) ..._submissionStatus(submission),
if (detail.reviews.isNotEmpty) ..._reviewQueue(detail), if (detail.reviews.isNotEmpty) ..._reviewQueue(detail),
], ],
@@ -290,7 +292,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
), ),
child: Text( child: Text(
blocked != null blocked != null
? '발주사 확인 중' ? context.l10n.commonComingSoon
: !open : !open
? context.l10n.questTimeAttackClosed ? context.l10n.questTimeAttackClosed
: _busy : _busy
@@ -388,12 +390,12 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
var resubmit = false; var resubmit = false;
if (!approve) { if (!approve) {
final input = TextEditingController(); final input = TextEditingController();
final ok = await showDialog<bool>( final ok = await showConfirmDialog(
context: context, context,
builder: (context) => StatefulBuilder( context.l10n.questReject,
builder: (context, setDialog) => AlertDialog( confirmLabel: context.l10n.questReject,
title: Text(context.l10n.questReject), content: StatefulBuilder(
content: Column( builder: (context, setDialog) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( TextField(
@@ -408,22 +410,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
), ),
], ],
), ),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.questReject),
),
],
),
), ),
); );
reason = input.text.trim(); reason = input.text.trim();
input.dispose(); input.dispose();
if (ok != true || reason.isEmpty) return; if (!ok || reason.isEmpty) return;
} }
await _run(() => widget.repository.review( await _run(() => widget.repository.review(
s.id, s.id,
@@ -437,10 +428,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
Future<void> _submitProof(QuestDetail detail) async { Future<void> _submitProof(QuestDetail detail) async {
final url = TextEditingController(); final url = TextEditingController();
final text = TextEditingController(); final text = TextEditingController();
final ok = await showDialog<bool>( final ok = await showConfirmDialog(
context: context, context,
builder: (context) => AlertDialog( context.l10n.questSubmit,
title: Text(context.l10n.questSubmit), confirmLabel: context.l10n.commonSubmit,
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -457,23 +448,12 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
), ),
], ],
), ),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.commonSubmit),
),
],
),
); );
final proofUrl = url.text.trim(); final proofUrl = url.text.trim();
final proofText = text.text.trim(); final proofText = text.text.trim();
url.dispose(); url.dispose();
text.dispose(); text.dispose();
if (ok != true || (proofUrl.isEmpty && proofText.isEmpty)) return; if (!ok || (proofUrl.isEmpty && proofText.isEmpty)) return;
final done = await _run( final done = await _run(
() => widget.repository.submit(detail.card.id, url: proofUrl, text: proofText), () => widget.repository.submit(detail.card.id, url: proofUrl, text: proofText),
); );
@@ -484,10 +464,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
Future<void> _edit(QuestDetail detail) async { Future<void> _edit(QuestDetail detail) async {
final title = TextEditingController(text: detail.card.title); final title = TextEditingController(text: detail.card.title);
final description = TextEditingController(text: detail.description); final description = TextEditingController(text: detail.description);
final ok = await showDialog<bool>( final ok = await showConfirmDialog(
context: context, context,
builder: (context) => AlertDialog( context.l10n.questEdit,
title: Text(context.l10n.questEdit), confirmLabel: context.l10n.commonEdit,
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -504,17 +484,6 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
), ),
], ],
), ),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.commonEdit),
),
],
),
); );
final newTitle = title.text.trim(); final newTitle = title.text.trim();
final newDescription = description.text.trim(); final newDescription = description.text.trim();
@@ -619,24 +588,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·* /// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·*
/// *중단 버튼 퀘스트 진행 상태값 시작전으로 업데이트* /// *중단 버튼 퀘스트 진행 상태값 시작전으로 업데이트*
Future<void> _confirmStop(QuestDetail detail) async { Future<void> _confirmStop(QuestDetail detail) async {
final stop = await showDialog<bool>( final stop = await showConfirmDialog(
context: context, context,
builder: (context) => AlertDialog( context.l10n.questStopConfirm,
title: Text(context.l10n.questStopConfirm), body: context.l10n.questStopBody,
content: Text(context.l10n.questStopBody), confirmLabel: context.l10n.questStop,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.questStop),
),
],
),
); );
if (stop != true) return; if (!stop) return;
_stopWatching(); _stopWatching();
final next = final next =
await widget.repository.advance(detail.card.id, QuestStage.notStarted); await widget.repository.advance(detail.card.id, QuestStage.notStarted);
@@ -650,46 +608,21 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 보상받기. /// 보상받기.
/// ///
/// 순서는 **참여 조건 판정 → 기본 보상 → 광고 선택**이다. 위치 · 권한 · 걸음 · 일일 한도는
/// 서버가 [QuestRepository.verify] 에서 판정하므로, 그 판정이 통과한 뒤에만 광고를 권한다.
/// 광고를 먼저 보이면 끝까지 보고도 위치 실패로 아무것도 못 받는 경로가 생긴다(2026-10-04 실기기 4-3).
///
/// 명세는 *관리자 퀘스트만 추가 보상 모달 · 사용자 퀘스트는 일반 보상 완료* 로 나눴지만 /// 명세는 *관리자 퀘스트만 추가 보상 모달 · 사용자 퀘스트는 일반 보상 완료* 로 나눴지만
/// 별첨 B **B-28** 이 사용자 생성 Quest 에도 고정형 기여 POP Reward Ad 를 확정했다 /// 별첨 B **B-28** 이 사용자 생성 Quest 에도 고정형 기여 POP Reward Ad 를 확정했다
/// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다. /// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다.
Future<void> _claim(QuestDetail detail) async { Future<void> _claim(QuestDetail detail) async {
if (_busy) return; if (_busy) return;
final bonus =
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false;
if (!mounted) return;
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
String? impression;
if (bonus) {
impression = await _watchAd();
if (!mounted) return;
}
// 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 — // 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 —
// 별첨 1 ② 「클라이언트 단독 지급 0건」. // 별첨 1 ② 「클라이언트 단독 지급 0건」.
// 기본 보상을 먼저 받고 광고 증분을 뒤에 청구한다(원장 B-03 데이터 관계) —
// 광고 쪽이 실패해도 기본 보상은 이미 지급되어 있다.
setState(() => _busy = true); setState(() => _busy = true);
final QuestReward reward; final QuestReward reward;
int? adPop;
BackendException? adError;
try { try {
final pending = () async { reward = await widget.repository.verify(detail.card);
final base = await widget.repository.verify(detail.card);
if (impression != null && !base.duplicate) {
try {
adPop = await widget.repository.claimAdReward(impression, base);
} on BackendException catch (error) {
adError = error;
}
}
return base;
}();
// 광고 시청 후 대기(`482:15084`) — 서버가 판정·지급하는 동안 띄운다.
if (impression != null) {
unawaited(_warp(QuestAdWarpScreen.after(until: pending)));
}
reward = await pending;
} catch (error) { } catch (error) {
if (!mounted) return; if (!mounted) return;
setState(() => _busy = false); setState(() => _busy = false);
@@ -714,9 +647,30 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
await _load(); await _load();
return; return;
} }
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) —
// 광고가 실패해도 기본 보상은 그대로다.
final bonus =
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false;
if (!mounted) return;
int? adPop;
if (bonus) {
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
final impression = await _watchAd();
if (!mounted) return;
if (impression != null) {
final claim = widget.repository.claimAdReward(impression, reward);
// 광고 시청 후 대기(`482:15084`) — 서버가 증분을 지급하는 동안 띄운다.
unawaited(_warp(QuestAdWarpScreen.after(until: claim)));
try {
adPop = await claim;
} on BackendException catch (error) {
// 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다. // 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
final failed = adError; if (mounted) _toast(error.message);
if (failed != null) _toast(failed.message); }
if (!mounted) return;
}
}
await Navigator.of(context, rootNavigator: true).push<bool>( await Navigator.of(context, rootNavigator: true).push<bool>(
MaterialPageRoute( MaterialPageRoute(
+19 -2
View File
@@ -63,13 +63,30 @@ class _QuestListScreenState extends State<QuestListScreen> {
super.dispose(); super.dispose();
} }
/// 헤더 보유 POP. 생성(예치 · 수수료) · 보상 뒤 [_load] 가 지갑을 다시 읽어 맞춘다.
late int _pop = widget.myPop;
Future<void> _load() async { Future<void> _load() async {
final cards = await widget.repository.load(); final List<QuestCard> cards;
try {
cards = await widget.repository.load();
} on Exception {
// 실패해도 로딩을 끝낸다 — 끝내지 않으면 탭 전체가 무한 로딩으로 남는다(2026-10-04 실기기).
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(context.l10n.commonRequestFailed),
));
return;
}
final pop = await widget.repository.popBalance().catchError((Object _) => null);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_cards = cards; _cards = cards;
_loading = false; _loading = false;
_end = cards.length < 10; _end = cards.length < 10;
if (pop != null) _pop = pop;
}); });
} }
@@ -259,7 +276,7 @@ class _QuestListScreenState extends State<QuestListScreen> {
Image.asset('assets/figma/icon-bolt.png', width: 10, height: 10.4), Image.asset('assets/figma/icon-bolt.png', width: 10, height: 10.4),
const SizedBox(width: 5), const SizedBox(width: 5),
Text( Text(
formatPop(widget.myPop), formatPop(_pop),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -93,10 +93,17 @@ class FirestoreQuestRepository implements QuestRepository {
for (final doc in docs) { for (final doc in docs) {
final quest = doc.data(); final quest = doc.data();
if (!_open(quest, now, uid)) continue; if (!_open(quest, now, uid)) continue;
final progress = await db // 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다.
// UGC 퀘스트 하나의 제출 건 조회가 거부되어 **모든 사용자의** 목록이 무한 로딩이었다(2026-10-04).
Map<String, dynamic> progress;
try {
progress = await db
.doc('users/$uid/questProgress/${doc.id}') .doc('users/$uid/questProgress/${doc.id}')
.get() .get()
.then((s) => s.data() ?? const <String, dynamic>{}); .then((s) => s.data() ?? const <String, dynamic>{});
} on Exception {
progress = const {};
}
cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest))); cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)));
} }
return cards; return cards;
@@ -145,17 +152,9 @@ class FirestoreQuestRepository implements QuestRepository {
final type = QuestType.parse(quest.strOr('type')); final type = QuestType.parse(quest.strOr('type'));
final authorUid = quest.strOr('authorUid'); final authorUid = quest.strOr('authorUid');
final dailyLimit = quest.intOr('dailyLimit', 1); final dailyLimit = quest.intOr('dailyLimit', 1);
// 타임어택은 1인 1회라 날짜로 끊지 않는다 — 창이 자정을 넘어도 「보상 완료」로 남는다. final (:count, :done) = questToday(type, quest, progress, policy);
final sameDay = type == QuestType.timeAttack ||
progress.strOr('dayKey') == _dayKey(DateTime.now());
final count = sameDay ? progress.intOr('count') : 0;
// 걸음은 **구간표**가 목표다 — 정책 원장 **B-26**(1 천·5 천·1 만·1.5 만보). 오늘
// 받은 구간 수가 `count` 이므로 다음 문턱이 지금 목표다. 나머지 유형은 일일 한도가
// 곧 목표 횟수다 — 설계서 §2.8 이 `quests` 에 목표 수치 필드를 두지 않았다.
final tiers = _stepTiers(policy); final tiers = _stepTiers(policy);
final steps = type == QuestType.steps; final steps = type == QuestType.steps;
final done = steps ? count >= tiers.length : count >= dailyLimit;
final goal = steps final goal = steps
? tiers[count.clamp(0, tiers.length - 1)] ? tiers[count.clamp(0, tiers.length - 1)]
: dailyLimit; : dailyLimit;
@@ -191,23 +190,6 @@ class FirestoreQuestRepository implements QuestRepository {
); );
} }
/// 걸음 구간 문턱들 — `config/policy.stepTiers`(운영값).
///
/// **화면 표시용이다. 판정은 서버가 다시 한다.** 문서가 아직 없을 때만 예전 단일
/// 목표(`stepsGoal`)로 떨어뜨린다 — 앱에 구간값을 박으면 운영값이 두 벌이 된다.
List<int> _stepTiers(Map<String, dynamic> policy) {
final raw = policy['stepTiers'];
if (raw is List && raw.isNotEmpty) {
final steps = <int>[];
for (final row in raw) {
if (row is Map && row['steps'] is num) {
steps.add((row['steps'] as num).round());
}
}
if (steps.isNotEmpty) return steps..sort();
}
return [policy.intOr('stepsGoal', 10000)];
}
/// 다국어 제목 — 설계서 §2.8 은 `title`(다국어)이라고만 적었다. /// 다국어 제목 — 설계서 §2.8 은 `title`(다국어)이라고만 적었다.
/// 문자열과 지도(map) 둘 다 들어올 수 있어 둘 다 받는다. /// 문자열과 지도(map) 둘 다 들어올 수 있어 둘 다 받는다.
@@ -282,9 +264,13 @@ class FirestoreQuestRepository implements QuestRepository {
Future<QuestSubmission?> _submission(String questId, Map<String, dynamic> quest) async { Future<QuestSubmission?> _submission(String questId, Map<String, dynamic> quest) async {
if (quest.strOr('authorUid').isEmpty) return null; if (quest.strOr('authorUid').isEmpty) return null;
final id = '${questId}_${_backend.requireUid}'; final id = '${questId}_${_backend.requireUid}';
final snap = await _backend.db.doc('questSubmissions/$id').get(); try {
final data = snap.data(); final data = (await _backend.db.doc('questSubmissions/$id').get()).data();
return data == null ? null : _submissionOf(id, data); return data == null ? null : _submissionOf(id, data);
} on Exception {
// 규칙 배포 전(없는 문서 get 이 permission-denied) 등 — 제출 없음으로 본다.
return null;
}
} }
/// 생성자의 검토 대기열. 규칙이 생성자 조건을 증명하도록 `questAuthorUid` 를 함께 건다. /// 생성자의 검토 대기열. 규칙이 생성자 조건을 증명하도록 `questAuthorUid` 를 함께 건다.
@@ -441,6 +427,12 @@ class FirestoreQuestRepository implements QuestRepository {
return result.strOr('questId'); return result.strOr('questId');
} }
@override
Future<int?> popBalance() async {
final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get();
return wallet.data()?.intOr('activityBalance');
}
@override @override
Stream<int> progressStream(QuestCard card) { Stream<int> progressStream(QuestCard card) {
if (card.type != QuestType.steps) return const Stream<int>.empty(); if (card.type != QuestType.steps) return const Stream<int>.empty();
@@ -562,3 +554,47 @@ class FirestoreQuestRepository implements QuestRepository {
/// 단말 시간대로 끊으면 해외 사용자의 화면과 서버 판정이 하루 어긋난다. /// 단말 시간대로 끊으면 해외 사용자의 화면과 서버 판정이 하루 어긋난다.
String _dayKey(DateTime at) => String _dayKey(DateTime at) =>
at.toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10); at.toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
/// 걸음 구간 문턱들 — `config/policy.stepTiers`(운영값).
///
/// **화면 표시용이다. 판정은 서버가 다시 한다.** 문서가 아직 없을 때만 예전 단일
/// 목표(`stepsGoal`)로 떨어뜨린다 — 앱에 구간값을 박으면 운영값이 두 벌이 된다.
List<int> _stepTiers(Map<String, dynamic> policy) {
final raw = policy['stepTiers'];
if (raw is List && raw.isNotEmpty) {
final steps = <int>[];
for (final row in raw) {
if (row is Map && row['steps'] is num) {
steps.add((row['steps'] as num).round());
}
}
if (steps.isNotEmpty) return steps..sort();
}
return [policy.intOr('stepsGoal', 10000)];
}
/// 오늘(KST) 수행 횟수와 완료 여부 — **HOME 과 퀘스트 목록 · 상세가 같이 쓰는 단일 판정.**
///
/// `users/{uid}/questProgress/{questId}.count` 는 `dayKey` 가 오늘일 때만 오늘 값이다. 날짜를
/// 보지 않으면 9/21 출석의 `count: 1` 이 매일 「보상 완료」로 남는다(2026-10-04 실기기 4-1).
/// 타임어택은 1인 1회라 날짜로 끊지 않는다 — 창이 자정을 넘어도 「보상 완료」로 남는다.
///
/// 걸음은 **구간표**가 목표다 — 정책 원장 **B-26**(1 천·5 천·1 만·1.5 만보). 오늘 받은 구간
/// 수가 `count` 라 구간을 다 받아야 완료다. 나머지 유형은 일일 한도가 곧 목표 횟수다 —
/// 설계서 §2.8 이 `quests` 에 목표 수치 필드를 두지 않았다.
({int count, bool done}) questToday(
QuestType type,
Map<String, dynamic> quest,
Map<String, dynamic>? progress,
Map<String, dynamic> policy, [
DateTime? now,
]) {
final p = progress ?? const <String, dynamic>{};
final sameDay = type == QuestType.timeAttack ||
p.strOr('dayKey') == _dayKey(now ?? DateTime.now());
final count = sameDay ? p.intOr('count') : 0;
final done = type == QuestType.steps
? count >= _stepTiers(policy).length
: count >= quest.intOr('dailyLimit', 1);
return (count: count, done: done);
}
@@ -9,8 +9,8 @@ import '../../../core/i18n/app_locale.dart';
/// 퀘스트 대표 이미지 — Figma 사본의 3D 아이콘(`264:46867` 목록 · `264:48835` 상세). /// 퀘스트 대표 이미지 — Figma 사본의 3D 아이콘(`264:46867` 목록 · `264:48835` 상세).
/// ///
/// ponytail: `quests` 문서(설계서 §2.8)에 이미지 필드가 없어 유형과 제목으로 고른다. /// ponytail: `quests` 문서(설계서 §2.8)에 이미지 필드가 없어 유형과 제목으로 고른다.
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 빈 칸이다. /// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 기본 아이콘이다.
String? questImage(QuestCard card) { String questImage(QuestCard card) {
switch (card.type) { switch (card.type) {
case QuestType.attend: case QuestType.attend:
return 'assets/figma/quest-img-stamp.png'; return 'assets/figma/quest-img-stamp.png';
@@ -27,8 +27,8 @@ String? questImage(QuestCard card) {
if (title.contains('만보기') || title.contains('걷기')) { if (title.contains('만보기') || title.contains('걷기')) {
return 'assets/figma/quest-img-shoe.png'; return 'assets/figma/quest-img-shoe.png';
} }
if (title.contains('POP')) return 'assets/figma/quest-img-bolt.png'; // 유형 · 제목으로 못 고르면 기본 아이콘(번개) — 빈 칸은 고장처럼 보인다(2026-10-04 실기기 4-9).
return null; return 'assets/figma/quest-img-bolt.png';
} }
/// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지. /// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지.
@@ -39,7 +39,6 @@ class QuestIconBox extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final image = questImage(card);
return Container( return Container(
width: 50, width: 50,
height: 50, height: 50,
@@ -48,7 +47,7 @@ class QuestIconBox extends StatelessWidget {
color: const Color(0xFF2E2E2E), color: const Color(0xFF2E2E2E),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: image == null ? null : Image.asset(image, width: 42, height: 42), child: Image.asset(questImage(card), width: 42, height: 42),
); );
} }
} }
+8 -7
View File
@@ -9,7 +9,7 @@
"commonPrev": "Back", "commonPrev": "Back",
"commonDone": "Done", "commonDone": "Done",
"commonStart": "Start", "commonStart": "Start",
"commonNotYet": "The {where} screen arrives in the second build review (2026-09-10).", "commonNotYet": "The {where} screen is coming soon.",
"languageSettingsTitle": "Language", "languageSettingsTitle": "Language",
"authLogin": "Log in", "authLogin": "Log in",
"authSignup": "Sign up", "authSignup": "Sign up",
@@ -20,8 +20,8 @@
"authProviderLogin": "{provider, select, apple{Log in with Apple} google{Log in with Google} kakao{Log in with Kakao} line{Log in with LINE} other{Log in with email}}", "authProviderLogin": "{provider, select, apple{Log in with Apple} google{Log in with Google} kakao{Log in with Kakao} line{Log in with LINE} other{Log in with email}}",
"authProviderSignup": "{provider, select, apple{Sign up with Apple} google{Sign up with Google} kakao{Sign up with Kakao} line{Sign up with LINE} other{Sign up with email}}", "authProviderSignup": "{provider, select, apple{Sign up with Apple} google{Sign up with Google} kakao{Sign up with Kakao} line{Sign up with LINE} other{Sign up with email}}",
"authNotConfiguredTitle": "{name} login coming soon", "authNotConfiguredTitle": "{name} login coming soon",
"authNotConfiguredBody": "{name} login opens once the client keys are provided.\n\nRequired\n· {items}", "authNotConfiguredBody": "{name} login isn't available on this device yet.\nPlease use another login method.",
"authNotConfiguredNotice": "{name} login is not ready yet.\nRequired: {items}", "authNotConfiguredNotice": "{name} login isn't available on this device yet.",
"authResetNeedEmail": "Enter the email address for the reset link first.", "authResetNeedEmail": "Enter the email address for the reset link first.",
"authResetSent": "We sent reset instructions to that address.", "authResetSent": "We sent reset instructions to that address.",
"termsTitle": "Terms of use", "termsTitle": "Terms of use",
@@ -46,7 +46,7 @@
"myTabActivity": "Activity", "myTabActivity": "Activity",
"myInside": "Inside", "myInside": "Inside",
"myCampaign": "Campaigns", "myCampaign": "Campaigns",
"myShareLater": "{target} sharing will be connected in the 09-10 build.", "myShareText": "{nickname} · Lv.{level} · {pop} POP contributed",
"myEditPhoto": "Edit profile photo", "myEditPhoto": "Edit profile photo",
"myEditProfile": "Edit profile", "myEditProfile": "Edit profile",
"settingsTitle": "Settings", "settingsTitle": "Settings",
@@ -70,12 +70,12 @@
"settingsWithdraw": "Delete account", "settingsWithdraw": "Delete account",
"settingsWithdrawConfirm": "Do you want to delete your account?", "settingsWithdrawConfirm": "Do you want to delete your account?",
"settingsWithdrawBody": "Your POP will be lost and this cannot be undone.", "settingsWithdrawBody": "Your POP will be lost and this cannot be undone.",
"settingsWithdrawLater": "Account deletion will be connected in the 09-10 build.", "settingsWithdrawLater": "Account deletion is coming soon.",
"settingsUnlinkConfirm": "Unlink this account?", "settingsUnlinkConfirm": "Unlink this account?",
"settingsUnlinkBody": "Only this social login method is removed. Your account data is not deleted.", "settingsUnlinkBody": "Only this social login method is removed. Your account data is not deleted.",
"settingsUnlink": "Unlink", "settingsUnlink": "Unlink",
"settingsSameEmailOnly": "Only social accounts with the same\nemail as your account can be linked.", "settingsSameEmailOnly": "Only social accounts with the same\nemail as your account can be linked.",
"settingsLinkNotReady": "{name} linking is not ready yet. Required: {items}", "settingsLinkNotReady": "{name} linking isn't available on this device yet.",
"settingsConnect": "Link", "settingsConnect": "Link",
"settingsDisconnect": "Unlink", "settingsDisconnect": "Unlink",
"settingsEmailProvider": "Email", "settingsEmailProvider": "Email",
@@ -549,6 +549,7 @@
"planetNoContributors": "No contributors yet", "planetNoContributors": "No contributors yet",
"planetContributeCta": "Contribute and climb the ranking!", "planetContributeCta": "Contribute and climb the ranking!",
"planetOverallRank": "Overall #{rank}", "planetOverallRank": "Overall #{rank}",
"planetRankNone": "Not ranked yet",
"planetMyContribution": "My share", "planetMyContribution": "My share",
"planetTallying": "Tallying soon", "planetTallying": "Tallying soon",
"planetTopPercent": "Top {percent}%", "planetTopPercent": "Top {percent}%",
@@ -567,7 +568,7 @@
"questTypeSteps": "Walking", "questTypeSteps": "Walking",
"questTypePlace": "Location", "questTypePlace": "Location",
"questTypeStreaming": "Streaming", "questTypeStreaming": "Streaming",
"questTypeUnverifiable": "The verification method for this quest type isn't set yet.", "questTypeUnverifiable": "This quest isn't open for participation yet.",
"questTabMine": "My quests", "questTabMine": "My quests",
"placePickPrompt": "Choose where to\ncomplete the quest.", "placePickPrompt": "Choose where to\ncomplete the quest.",
"placeSearch": "Search places", "placeSearch": "Search places",
+8 -7
View File
@@ -9,7 +9,7 @@
"commonPrev": "Atrás", "commonPrev": "Atrás",
"commonDone": "Listo", "commonDone": "Listo",
"commonStart": "Empezar", "commonStart": "Empezar",
"commonNotYet": "La pantalla {where} llega en la segunda revisión (2026-09-10).", "commonNotYet": "La pantalla {where} llegará pronto.",
"languageSettingsTitle": "Idioma", "languageSettingsTitle": "Idioma",
"authLogin": "Iniciar sesión", "authLogin": "Iniciar sesión",
"authSignup": "Registrarse", "authSignup": "Registrarse",
@@ -20,8 +20,8 @@
"authProviderLogin": "{provider, select, apple{Iniciar sesión con Apple} google{Iniciar sesión con Google} kakao{Iniciar sesión con Kakao} line{Iniciar sesión con LINE} other{Iniciar sesión con correo}}", "authProviderLogin": "{provider, select, apple{Iniciar sesión con Apple} google{Iniciar sesión con Google} kakao{Iniciar sesión con Kakao} line{Iniciar sesión con LINE} other{Iniciar sesión con correo}}",
"authProviderSignup": "{provider, select, apple{Registrarse con Apple} google{Registrarse con Google} kakao{Registrarse con Kakao} line{Registrarse con LINE} other{Registrarse con correo}}", "authProviderSignup": "{provider, select, apple{Registrarse con Apple} google{Registrarse con Google} kakao{Registrarse con Kakao} line{Registrarse con LINE} other{Registrarse con correo}}",
"authNotConfiguredTitle": "Inicio con {name} próximamente", "authNotConfiguredTitle": "Inicio con {name} próximamente",
"authNotConfiguredBody": "El inicio con {name} se habilitará cuando se entreguen las claves.\n\nRequerido\n· {items}", "authNotConfiguredBody": "El inicio con {name} aún no está disponible en este dispositivo.\nUsa otro método de inicio de sesión.",
"authNotConfiguredNotice": "El inicio con {name} aún no está listo.\nRequerido: {items}", "authNotConfiguredNotice": "El inicio con {name} aún no está disponible en este dispositivo.",
"authResetNeedEmail": "Primero introduce el correo para recibir el enlace.", "authResetNeedEmail": "Primero introduce el correo para recibir el enlace.",
"authResetSent": "Enviamos las instrucciones a esa dirección.", "authResetSent": "Enviamos las instrucciones a esa dirección.",
"termsTitle": "Términos de uso", "termsTitle": "Términos de uso",
@@ -46,7 +46,7 @@
"myTabActivity": "Actividad", "myTabActivity": "Actividad",
"myInside": "Inside", "myInside": "Inside",
"myCampaign": "Campañas", "myCampaign": "Campañas",
"myShareLater": "Compartir en {target} se conectará en la versión 09-10.", "myShareText": "{nickname} · Lv.{level} · {pop} POP aportados",
"myEditPhoto": "Editar foto de perfil", "myEditPhoto": "Editar foto de perfil",
"myEditProfile": "Editar perfil", "myEditProfile": "Editar perfil",
"settingsTitle": "Ajustes", "settingsTitle": "Ajustes",
@@ -70,12 +70,12 @@
"settingsWithdraw": "Eliminar cuenta", "settingsWithdraw": "Eliminar cuenta",
"settingsWithdrawConfirm": "¿Quieres eliminar tu cuenta?", "settingsWithdrawConfirm": "¿Quieres eliminar tu cuenta?",
"settingsWithdrawBody": "Perderás tus POP y no se puede deshacer.", "settingsWithdrawBody": "Perderás tus POP y no se puede deshacer.",
"settingsWithdrawLater": "La eliminación de cuenta se conectará en la versión 09-10.", "settingsWithdrawLater": "La eliminación de cuenta llegará pronto.",
"settingsUnlinkConfirm": "¿Desvincular esta cuenta?", "settingsUnlinkConfirm": "¿Desvincular esta cuenta?",
"settingsUnlinkBody": "Solo se quita este método de inicio social; los datos de tu cuenta no se eliminan.", "settingsUnlinkBody": "Solo se quita este método de inicio social; los datos de tu cuenta no se eliminan.",
"settingsUnlink": "Desvincular", "settingsUnlink": "Desvincular",
"settingsSameEmailOnly": "Solo se pueden vincular cuentas sociales\ncon el mismo correo.", "settingsSameEmailOnly": "Solo se pueden vincular cuentas sociales\ncon el mismo correo.",
"settingsLinkNotReady": "La vinculación con {name} aún no está lista. Requerido: {items}", "settingsLinkNotReady": "La vinculación con {name} aún no está disponible en este dispositivo.",
"settingsConnect": "Vincular", "settingsConnect": "Vincular",
"settingsDisconnect": "Desvincular", "settingsDisconnect": "Desvincular",
"settingsEmailProvider": "Correo", "settingsEmailProvider": "Correo",
@@ -549,6 +549,7 @@
"planetNoContributors": "Aún no hay contribuyentes", "planetNoContributors": "Aún no hay contribuyentes",
"planetContributeCta": "¡Contribuye y entra en el ranking!", "planetContributeCta": "¡Contribuye y entra en el ranking!",
"planetOverallRank": "Puesto general {rank}", "planetOverallRank": "Puesto general {rank}",
"planetRankNone": "Sin clasificar aún",
"planetMyContribution": "Mi aporte", "planetMyContribution": "Mi aporte",
"planetTallying": "Cálculo pendiente", "planetTallying": "Cálculo pendiente",
"planetTopPercent": "Top {percent}%", "planetTopPercent": "Top {percent}%",
@@ -567,7 +568,7 @@
"questTypeSteps": "Caminar", "questTypeSteps": "Caminar",
"questTypePlace": "Ubicación", "questTypePlace": "Ubicación",
"questTypeStreaming": "Streaming", "questTypeStreaming": "Streaming",
"questTypeUnverifiable": "Aún no se ha definido cómo verificar este tipo de misión.", "questTypeUnverifiable": "Aún no puedes participar en esta misión.",
"questTabMine": "Mis misiones", "questTabMine": "Mis misiones",
"placePickPrompt": "Elige dónde\ncompletar la misión.", "placePickPrompt": "Elige dónde\ncompletar la misión.",
"placeSearch": "Buscar lugar", "placeSearch": "Buscar lugar",
+8 -7
View File
@@ -9,7 +9,7 @@
"commonPrev": "戻る", "commonPrev": "戻る",
"commonDone": "完了", "commonDone": "完了",
"commonStart": "はじめる", "commonStart": "はじめる",
"commonNotYet": "{where}画面は第2次実装確認(2026-09-10)で追加されます。", "commonNotYet": "{where}画面は準備中です。",
"languageSettingsTitle": "言語設定", "languageSettingsTitle": "言語設定",
"authLogin": "ログイン", "authLogin": "ログイン",
"authSignup": "新規登録", "authSignup": "新規登録",
@@ -20,8 +20,8 @@
"authProviderLogin": "{provider, select, apple{Appleでログイン} google{Googleでログイン} kakao{Kakaoでログイン} line{LINEでログイン} other{メールでログイン}}", "authProviderLogin": "{provider, select, apple{Appleでログイン} google{Googleでログイン} kakao{Kakaoでログイン} line{LINEでログイン} other{メールでログイン}}",
"authProviderSignup": "{provider, select, apple{Appleで登録} google{Googleで登録} kakao{Kakaoで登録} line{LINEで登録} other{メールで登録}}", "authProviderSignup": "{provider, select, apple{Appleで登録} google{Googleで登録} kakao{Kakaoで登録} line{LINEで登録} other{メールで登録}}",
"authNotConfiguredTitle": "{name}ログイン準備中", "authNotConfiguredTitle": "{name}ログイン準備中",
"authNotConfiguredBody": "{name}ログインは発注元のキー提供後に利用できます。\n\n必要な項目\n· {items}", "authNotConfiguredBody": "この端末では{name}ログインをまだご利用いただけません。\n他のログイン方法をご利用ください。",
"authNotConfiguredNotice": "{name}ログインはまだ準備中です。\n必要な項目: {items}", "authNotConfiguredNotice": "この端末では{name}ログインをまだご利用いただけません。",
"authResetNeedEmail": "再設定メールを受け取るメールアドレスを先に入力してください。", "authResetNeedEmail": "再設定メールを受け取るメールアドレスを先に入力してください。",
"authResetSent": "入力されたアドレスに再設定の案内を送信しました。", "authResetSent": "入力されたアドレスに再設定の案内を送信しました。",
"termsTitle": "利用規約", "termsTitle": "利用規約",
@@ -46,7 +46,7 @@
"myTabActivity": "アクティビティ", "myTabActivity": "アクティビティ",
"myInside": "インサイド", "myInside": "インサイド",
"myCampaign": "キャンペーン", "myCampaign": "キャンペーン",
"myShareLater": "{target}への共有は09-10回で接続します。", "myShareText": "{nickname} · Lv.{level} · 累計貢献 {pop} POP",
"myEditPhoto": "プロフィール写真を編集", "myEditPhoto": "プロフィール写真を編集",
"myEditProfile": "プロフィールを編集", "myEditProfile": "プロフィールを編集",
"settingsTitle": "設定", "settingsTitle": "設定",
@@ -70,12 +70,12 @@
"settingsWithdraw": "退会", "settingsWithdraw": "退会",
"settingsWithdrawConfirm": "退会しますか?", "settingsWithdrawConfirm": "退会しますか?",
"settingsWithdrawBody": "保有しているPOPは消滅し、元に戻せません。", "settingsWithdrawBody": "保有しているPOPは消滅し、元に戻せません。",
"settingsWithdrawLater": "退会処理は09-10回で接続します。", "settingsWithdrawLater": "退会機能は準備中です。",
"settingsUnlinkConfirm": "連携中のアカウントを解除しますか?", "settingsUnlinkConfirm": "連携中のアカウントを解除しますか?",
"settingsUnlinkBody": "連携したSNSのログイン手段のみ削除され、アカウント情報は削除されません。", "settingsUnlinkBody": "連携したSNSのログイン手段のみ削除され、アカウント情報は削除されません。",
"settingsUnlink": "連携解除", "settingsUnlink": "連携解除",
"settingsSameEmailOnly": "アカウントのメールと同じ\nソーシャルアカウントのみ連携できます。", "settingsSameEmailOnly": "アカウントのメールと同じ\nソーシャルアカウントのみ連携できます。",
"settingsLinkNotReady": "{name}連携はまだ準備中です。必要な項目: {items}", "settingsLinkNotReady": "この端末では{name}連携をまだご利用いただけません。",
"settingsConnect": "連携", "settingsConnect": "連携",
"settingsDisconnect": "解除", "settingsDisconnect": "解除",
"settingsEmailProvider": "メール", "settingsEmailProvider": "メール",
@@ -549,6 +549,7 @@
"planetNoContributors": "まだ貢献者がいません", "planetNoContributors": "まだ貢献者がいません",
"planetContributeCta": "貢献してランキングに入ろう!", "planetContributeCta": "貢献してランキングに入ろう!",
"planetOverallRank": "全体{rank}位", "planetOverallRank": "全体{rank}位",
"planetRankNone": "順位集計前",
"planetMyContribution": "自分の貢献度", "planetMyContribution": "自分の貢献度",
"planetTallying": "集計準備中", "planetTallying": "集計準備中",
"planetTopPercent": "上位{percent}%", "planetTopPercent": "上位{percent}%",
@@ -567,7 +568,7 @@
"questTypeSteps": "ウォーキング", "questTypeSteps": "ウォーキング",
"questTypePlace": "特定の場所", "questTypePlace": "特定の場所",
"questTypeStreaming": "ストリーミング", "questTypeStreaming": "ストリーミング",
"questTypeUnverifiable": "このクエストタイプの検証方法はまだ決まっていません。", "questTypeUnverifiable": "このクエストにはまだ参加できません。",
"questTabMine": "マイクエスト", "questTabMine": "マイクエスト",
"placePickPrompt": "クエストを完了する\n場所を選択してください。", "placePickPrompt": "クエストを完了する\n場所を選択してください。",
"placeSearch": "場所を検索", "placeSearch": "場所を検索",
+18 -20
View File
@@ -9,7 +9,7 @@
"commonPrev": "이전", "commonPrev": "이전",
"commonDone": "완료", "commonDone": "완료",
"commonStart": "시작", "commonStart": "시작",
"commonNotYet": "{where} 화면은 구현 확인물 2차(2026-09-10)에 들어갑니다.", "commonNotYet": "{where} 화면은 준비 중입니다.",
"@commonNotYet": { "@commonNotYet": {
"placeholders": { "placeholders": {
"where": { "where": {
@@ -48,25 +48,19 @@
} }
} }
}, },
"authNotConfiguredBody": "{name} 로그인은 발주사 키가 들어오면 열립니다.\n\n필요한 항목\n· {items}", "authNotConfiguredBody": "이 기기에서는 {name} 로그인을 아직 이용할 수 없습니다.\n다른 로그인 방법을 이용해 주세요.",
"@authNotConfiguredBody": { "@authNotConfiguredBody": {
"placeholders": { "placeholders": {
"name": { "name": {
"type": "String" "type": "String"
},
"items": {
"type": "String"
} }
} }
}, },
"authNotConfiguredNotice": "{name} 로그인은 아직 준비 중입니다.\n필요한 항목: {items}", "authNotConfiguredNotice": "이 기기에서는 {name} 로그인을 아직 이용할 수 없습니다.",
"@authNotConfiguredNotice": { "@authNotConfiguredNotice": {
"placeholders": { "placeholders": {
"name": { "name": {
"type": "String" "type": "String"
},
"items": {
"type": "String"
} }
} }
}, },
@@ -94,10 +88,16 @@
"myTabActivity": "활동", "myTabActivity": "활동",
"myInside": "인사이드", "myInside": "인사이드",
"myCampaign": "캠페인", "myCampaign": "캠페인",
"myShareLater": "{target} 공유는 09-10 회차에 연결합니다.", "myShareText": "{nickname} · Lv.{level} · 누적 기여 {pop} POP",
"@myShareLater": { "@myShareText": {
"placeholders": { "placeholders": {
"target": { "nickname": {
"type": "String"
},
"level": {
"type": "String"
},
"pop": {
"type": "String" "type": "String"
} }
} }
@@ -125,19 +125,16 @@
"settingsWithdraw": "서비스 탈퇴", "settingsWithdraw": "서비스 탈퇴",
"settingsWithdrawConfirm": "서비스 탈퇴를 하시겠습니까?", "settingsWithdrawConfirm": "서비스 탈퇴를 하시겠습니까?",
"settingsWithdrawBody": "보유하신 POP 이 사라지고 되돌릴 수 없습니다.", "settingsWithdrawBody": "보유하신 POP 이 사라지고 되돌릴 수 없습니다.",
"settingsWithdrawLater": "서비스 탈퇴 처리는 09-10 회차에 연결합니다.", "settingsWithdrawLater": "서비스 탈퇴 기능은 준비 중입니다.",
"settingsUnlinkConfirm": "연결된 계정을 끊으시겠습니까?", "settingsUnlinkConfirm": "연결된 계정을 끊으시겠습니까?",
"settingsUnlinkBody": "연결한 SNS 계정 로그인 수단만 제거되며, 계정 정보는 삭제되지 않습니다.", "settingsUnlinkBody": "연결한 SNS 계정 로그인 수단만 제거되며, 계정 정보는 삭제되지 않습니다.",
"settingsUnlink": "연결 해제", "settingsUnlink": "연결 해제",
"settingsSameEmailOnly": "계정 이메일과 동일한 소셜 계정만\n연결 할 수 있습니다.", "settingsSameEmailOnly": "계정 이메일과 동일한 소셜 계정만\n연결 할 수 있습니다.",
"settingsLinkNotReady": "{name} 연동은 아직 준비 중입니다. 필요한 항목: {items}", "settingsLinkNotReady": "이 기기에서는 {name} 연동을 아직 이용할 수 없습니다.",
"@settingsLinkNotReady": { "@settingsLinkNotReady": {
"placeholders": { "placeholders": {
"name": { "name": {
"type": "String" "type": "String"
},
"items": {
"type": "String"
} }
} }
}, },
@@ -563,7 +560,7 @@
} }
}, },
"nebulaHeadline": "우리 팬덤 행성 이름을 만들어요.", "nebulaHeadline": "우리 팬덤 행성 이름을 만들어요.",
"nebulaBody": "창립맴버가 목표 POP을 {days}일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 맴버들에게는 기념 배지가 지급됩니다.", "nebulaBody": "창립멤버가 목표 POP을 {days}일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 멤버들에게는 기념 배지가 지급됩니다.",
"@nebulaBody": { "@nebulaBody": {
"placeholders": { "placeholders": {
"days": { "days": {
@@ -574,7 +571,7 @@
"nebulaNameTaken": "이미 사용중인 이름입니다.", "nebulaNameTaken": "이미 사용중인 이름입니다.",
"nebulaNameHint": "팬덤 이름", "nebulaNameHint": "팬덤 이름",
"nebulaGoal": "목표 ", "nebulaGoal": "목표 ",
"nebulaFounders": " / 창립맴버", "nebulaFounders": " / 창립멤버",
"nebulaGoalSemantics": "목표 {pop} POP · 창립 멤버 {members}명 · {days}일 고정", "nebulaGoalSemantics": "목표 {pop} POP · 창립 멤버 {members}명 · {days}일 고정",
"@nebulaGoalSemantics": { "@nebulaGoalSemantics": {
"placeholders": { "placeholders": {
@@ -1098,6 +1095,7 @@
} }
} }
}, },
"planetRankNone": "순위 집계 전",
"planetMyContribution": "내 기여도", "planetMyContribution": "내 기여도",
"planetTallying": "집계 준비 중", "planetTallying": "집계 준비 중",
"planetTopPercent": "상위 {percent}%", "planetTopPercent": "상위 {percent}%",
@@ -1130,7 +1128,7 @@
"questTypeSteps": "걷기", "questTypeSteps": "걷기",
"questTypePlace": "특정 위치", "questTypePlace": "특정 위치",
"questTypeStreaming": "스트리밍", "questTypeStreaming": "스트리밍",
"questTypeUnverifiable": "이 퀘스트 유형의 검증 방식이 아직 정해지지 않았습니다.", "questTypeUnverifiable": "이 퀘스트는 아직 참여할 수 없습니다.",
"questTabMine": "내 퀘스트", "questTabMine": "내 퀘스트",
"placePickPrompt": "퀘스트를 완료할\n장소를 선택해주세요.", "placePickPrompt": "퀘스트를 완료할\n장소를 선택해주세요.",
"placeSearch": "장소 검색", "placeSearch": "장소 검색",
+20 -14
View File
@@ -161,7 +161,7 @@ abstract class AppLocalizations {
/// No description provided for @commonNotYet. /// No description provided for @commonNotYet.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'{where} 화면은 구현 확인물 2차(2026-09-10)에 들어갑니다.'** /// **'{where} 화면은 준비 중입니다.'**
String commonNotYet(String where); String commonNotYet(String where);
/// No description provided for @languageSettingsTitle. /// No description provided for @languageSettingsTitle.
@@ -227,14 +227,14 @@ abstract class AppLocalizations {
/// No description provided for @authNotConfiguredBody. /// No description provided for @authNotConfiguredBody.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'{name} 로그인은 발주사 키가 들어오면 열립니다.\n\n필요한 항목\n· {items}'** /// **'이 기기에서는 {name} 로그인을 아직 이용할 수 없습니다.\n다른 로그인 방법을 이용해 주세요.'**
String authNotConfiguredBody(String name, String items); String authNotConfiguredBody(String name);
/// No description provided for @authNotConfiguredNotice. /// No description provided for @authNotConfiguredNotice.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'{name} 로그인은 아직 준비 중입니다.\n필요한 항목: {items}'** /// **'이 기기에서는 {name} 로그인을 아직 이용할 수 없습니다.'**
String authNotConfiguredNotice(String name, String items); String authNotConfiguredNotice(String name);
/// No description provided for @authResetNeedEmail. /// No description provided for @authResetNeedEmail.
/// ///
@@ -380,11 +380,11 @@ abstract class AppLocalizations {
/// **'캠페인'** /// **'캠페인'**
String get myCampaign; String get myCampaign;
/// No description provided for @myShareLater. /// No description provided for @myShareText.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'{target} 공유는 09-10 회차에 연결합니다.'** /// **'{nickname} · Lv.{level} · 누적 기여 {pop} POP'**
String myShareLater(String target); String myShareText(String nickname, String level, String pop);
/// No description provided for @myEditPhoto. /// No description provided for @myEditPhoto.
/// ///
@@ -527,7 +527,7 @@ abstract class AppLocalizations {
/// No description provided for @settingsWithdrawLater. /// No description provided for @settingsWithdrawLater.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'서비스 탈퇴 처리는 09-10 회차에 연결합니다.'** /// **'서비스 탈퇴 기능은 준비 중입니다.'**
String get settingsWithdrawLater; String get settingsWithdrawLater;
/// No description provided for @settingsUnlinkConfirm. /// No description provided for @settingsUnlinkConfirm.
@@ -557,8 +557,8 @@ abstract class AppLocalizations {
/// No description provided for @settingsLinkNotReady. /// No description provided for @settingsLinkNotReady.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'{name} 연동은 아직 준비 중입니다. 필요한 항목: {items}'** /// **'이 기기에서는 {name} 연동을 아직 이용할 수 없습니다.'**
String settingsLinkNotReady(String name, String items); String settingsLinkNotReady(String name);
/// No description provided for @settingsConnect. /// No description provided for @settingsConnect.
/// ///
@@ -1565,7 +1565,7 @@ abstract class AppLocalizations {
/// No description provided for @nebulaBody. /// No description provided for @nebulaBody.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'창립맴버가 목표 POP을 {days}일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 맴버들에게는 기념 배지가 지급됩니다.'** /// **'창립멤버가 목표 POP을 {days}일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 멤버들에게는 기념 배지가 지급됩니다.'**
String nebulaBody(String days); String nebulaBody(String days);
/// No description provided for @nebulaNameTaken. /// No description provided for @nebulaNameTaken.
@@ -1589,7 +1589,7 @@ abstract class AppLocalizations {
/// No description provided for @nebulaFounders. /// No description provided for @nebulaFounders.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **' / 창립맴버'** /// **' / 창립멤버'**
String get nebulaFounders; String get nebulaFounders;
/// No description provided for @nebulaGoalSemantics. /// No description provided for @nebulaGoalSemantics.
@@ -3398,6 +3398,12 @@ abstract class AppLocalizations {
/// **'전체 {rank}위'** /// **'전체 {rank}위'**
String planetOverallRank(String rank); String planetOverallRank(String rank);
/// No description provided for @planetRankNone.
///
/// In ko, this message translates to:
/// **'순위 집계 전'**
String get planetRankNone;
/// No description provided for @planetMyContribution. /// No description provided for @planetMyContribution.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
@@ -3509,7 +3515,7 @@ abstract class AppLocalizations {
/// No description provided for @questTypeUnverifiable. /// No description provided for @questTypeUnverifiable.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'이 퀘스트 유형의 검증 방식이 아직 정해지지 않았습니다.'** /// **'이 퀘스트는 아직 참여할 수 없습니다.'**
String get questTypeUnverifiable; String get questTypeUnverifiable;
/// No description provided for @questTabMine. /// No description provided for @questTabMine.
+14 -12
View File
@@ -37,7 +37,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String commonNotYet(String where) { String commonNotYet(String where) {
return 'The $where screen arrives in the second build review (2026-09-10).'; return 'The $where screen is coming soon.';
} }
@override @override
@@ -91,13 +91,13 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @override
String authNotConfiguredBody(String name, String items) { String authNotConfiguredBody(String name) {
return '$name login opens once the client keys are provided.\n\nRequired\n· $items'; return '$name login isn\'t available on this device yet.\nPlease use another login method.';
} }
@override @override
String authNotConfiguredNotice(String name, String items) { String authNotConfiguredNotice(String name) {
return '$name login is not ready yet.\nRequired: $items'; return '$name login isn\'t available on this device yet.';
} }
@override @override
@@ -176,8 +176,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get myCampaign => 'Campaigns'; String get myCampaign => 'Campaigns';
@override @override
String myShareLater(String target) { String myShareText(String nickname, String level, String pop) {
return '$target sharing will be connected in the 09-10 build.'; return '$nickname · Lv.$level · $pop POP contributed';
} }
@override @override
@@ -251,8 +251,7 @@ class AppLocalizationsEn extends AppLocalizations {
'Your POP will be lost and this cannot be undone.'; 'Your POP will be lost and this cannot be undone.';
@override @override
String get settingsWithdrawLater => String get settingsWithdrawLater => 'Account deletion is coming soon.';
'Account deletion will be connected in the 09-10 build.';
@override @override
String get settingsUnlinkConfirm => 'Unlink this account?'; String get settingsUnlinkConfirm => 'Unlink this account?';
@@ -269,8 +268,8 @@ class AppLocalizationsEn extends AppLocalizations {
'Only social accounts with the same\nemail as your account can be linked.'; 'Only social accounts with the same\nemail as your account can be linked.';
@override @override
String settingsLinkNotReady(String name, String items) { String settingsLinkNotReady(String name) {
return '$name linking is not ready yet. Required: $items'; return '$name linking isn\'t available on this device yet.';
} }
@override @override
@@ -1916,6 +1915,9 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Overall #$rank'; return 'Overall #$rank';
} }
@override
String get planetRankNone => 'Not ranked yet';
@override @override
String get planetMyContribution => 'My share'; String get planetMyContribution => 'My share';
@@ -1976,7 +1978,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get questTypeUnverifiable => String get questTypeUnverifiable =>
'The verification method for this quest type isn\'t set yet.'; 'This quest isn\'t open for participation yet.';
@override @override
String get questTabMine => 'My quests'; String get questTabMine => 'My quests';
+14 -11
View File
@@ -37,7 +37,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String commonNotYet(String where) { String commonNotYet(String where) {
return 'La pantalla $where llega en la segunda revisión (2026-09-10).'; return 'La pantalla $where llegará pronto.';
} }
@override @override
@@ -92,13 +92,13 @@ class AppLocalizationsEs extends AppLocalizations {
} }
@override @override
String authNotConfiguredBody(String name, String items) { String authNotConfiguredBody(String name) {
return 'El inicio con $name se habilitará cuando se entreguen las claves.\n\nRequerido\n· $items'; return 'El inicio con $name aún no está disponible en este dispositivo.\nUsa otro método de inicio de sesión.';
} }
@override @override
String authNotConfiguredNotice(String name, String items) { String authNotConfiguredNotice(String name) {
return 'El inicio con $name aún no está listo.\nRequerido: $items'; return 'El inicio con $name aún no está disponible en este dispositivo.';
} }
@override @override
@@ -178,8 +178,8 @@ class AppLocalizationsEs extends AppLocalizations {
String get myCampaign => 'Campañas'; String get myCampaign => 'Campañas';
@override @override
String myShareLater(String target) { String myShareText(String nickname, String level, String pop) {
return 'Compartir en $target se conectará en la versión 09-10.'; return '$nickname · Lv.$level · $pop POP aportados';
} }
@override @override
@@ -253,7 +253,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get settingsWithdrawLater => String get settingsWithdrawLater =>
'La eliminación de cuenta se conectará en la versión 09-10.'; 'La eliminación de cuenta llegará pronto.';
@override @override
String get settingsUnlinkConfirm => '¿Desvincular esta cuenta?'; String get settingsUnlinkConfirm => '¿Desvincular esta cuenta?';
@@ -270,8 +270,8 @@ class AppLocalizationsEs extends AppLocalizations {
'Solo se pueden vincular cuentas sociales\ncon el mismo correo.'; 'Solo se pueden vincular cuentas sociales\ncon el mismo correo.';
@override @override
String settingsLinkNotReady(String name, String items) { String settingsLinkNotReady(String name) {
return 'La vinculación con $name aún no está lista. Requerido: $items'; return 'La vinculación con $name aún no está disponible en este dispositivo.';
} }
@override @override
@@ -1922,6 +1922,9 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Puesto general $rank'; return 'Puesto general $rank';
} }
@override
String get planetRankNone => 'Sin clasificar aún';
@override @override
String get planetMyContribution => 'Mi aporte'; String get planetMyContribution => 'Mi aporte';
@@ -1983,7 +1986,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get questTypeUnverifiable => String get questTypeUnverifiable =>
'Aún no se ha definido cómo verificar este tipo de misión.'; 'Aún no puedes participar en esta misión.';
@override @override
String get questTabMine => 'Mis misiones'; String get questTabMine => 'Mis misiones';
+14 -11
View File
@@ -37,7 +37,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String commonNotYet(String where) { String commonNotYet(String where) {
return '$where画面は第2次実装確認(2026-09-10)で追加されます。'; return '$where画面は準備中です。';
} }
@override @override
@@ -91,13 +91,13 @@ class AppLocalizationsJa extends AppLocalizations {
} }
@override @override
String authNotConfiguredBody(String name, String items) { String authNotConfiguredBody(String name) {
return '$nameログインは発注元のキー提供後に利用できます。\n\n必要な項目\n· $items'; return 'この端末では$nameログインをまだご利用いただけません。\n他のログイン方法をご利用ください。';
} }
@override @override
String authNotConfiguredNotice(String name, String items) { String authNotConfiguredNotice(String name) {
return '$nameログインはまだ準備中です。\n必要な項目: $items'; return 'この端末では$nameログインをまだご利用いただけません。';
} }
@override @override
@@ -173,8 +173,8 @@ class AppLocalizationsJa extends AppLocalizations {
String get myCampaign => 'キャンペーン'; String get myCampaign => 'キャンペーン';
@override @override
String myShareLater(String target) { String myShareText(String nickname, String level, String pop) {
return '$targetへの共有は09-10回で接続します。'; return '$nickname · Lv.$level · 累計貢献 $pop POP';
} }
@override @override
@@ -247,7 +247,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get settingsWithdrawBody => '保有しているPOPは消滅し、元に戻せません。'; String get settingsWithdrawBody => '保有しているPOPは消滅し、元に戻せません。';
@override @override
String get settingsWithdrawLater => '退会処理は09-10回で接続します。'; String get settingsWithdrawLater => '退会機能は準備中です。';
@override @override
String get settingsUnlinkConfirm => '連携中のアカウントを解除しますか?'; String get settingsUnlinkConfirm => '連携中のアカウントを解除しますか?';
@@ -262,8 +262,8 @@ class AppLocalizationsJa extends AppLocalizations {
String get settingsSameEmailOnly => 'アカウントのメールと同じ\nソーシャルアカウントのみ連携できます。'; String get settingsSameEmailOnly => 'アカウントのメールと同じ\nソーシャルアカウントのみ連携できます。';
@override @override
String settingsLinkNotReady(String name, String items) { String settingsLinkNotReady(String name) {
return '$name連携はまだ準備中です。必要な項目: $items'; return 'この端末では$name連携をまだご利用いただけません。';
} }
@override @override
@@ -1854,6 +1854,9 @@ class AppLocalizationsJa extends AppLocalizations {
return '全体$rank位'; return '全体$rank位';
} }
@override
String get planetRankNone => '順位集計前';
@override @override
String get planetMyContribution => '自分の貢献度'; String get planetMyContribution => '自分の貢献度';
@@ -1913,7 +1916,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get questTypeStreaming => 'ストリーミング'; String get questTypeStreaming => 'ストリーミング';
@override @override
String get questTypeUnverifiable => 'このクエストタイプの検証方法はまだ決まっていません。'; String get questTypeUnverifiable => 'このクエストにはまだ参加できません。';
@override @override
String get questTabMine => 'マイクエスト'; String get questTabMine => 'マイクエスト';
+16 -13
View File
@@ -37,7 +37,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String commonNotYet(String where) { String commonNotYet(String where) {
return '$where 화면은 구현 확인물 2차(2026-09-10)에 들어갑니다.'; return '$where 화면은 준비 중입니다.';
} }
@override @override
@@ -91,13 +91,13 @@ class AppLocalizationsKo extends AppLocalizations {
} }
@override @override
String authNotConfiguredBody(String name, String items) { String authNotConfiguredBody(String name) {
return '$name 로그인은 발주사 키가 들어오면 열립니다.\n\n필요한 항목\n· $items'; return '이 기기에서는 $name 로그인을 아직 이용할 수 없습니다.\n다른 로그인 방법을 이용해 주세요.';
} }
@override @override
String authNotConfiguredNotice(String name, String items) { String authNotConfiguredNotice(String name) {
return '$name 로그인은 아직 준비 중입니다.\n필요한 항목: $items'; return '이 기기에서는 $name 로그인을 아직 이용할 수 없습니다.';
} }
@override @override
@@ -173,8 +173,8 @@ class AppLocalizationsKo extends AppLocalizations {
String get myCampaign => '캠페인'; String get myCampaign => '캠페인';
@override @override
String myShareLater(String target) { String myShareText(String nickname, String level, String pop) {
return '$target 공유는 09-10 회차에 연결합니다.'; return '$nickname · Lv.$level · 누적 기여 $pop POP';
} }
@override @override
@@ -247,7 +247,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get settingsWithdrawBody => '보유하신 POP 이 사라지고 되돌릴 수 없습니다.'; String get settingsWithdrawBody => '보유하신 POP 이 사라지고 되돌릴 수 없습니다.';
@override @override
String get settingsWithdrawLater => '서비스 탈퇴 처리는 09-10 회차에 연결합니다.'; String get settingsWithdrawLater => '서비스 탈퇴 기능은 준비 중입니다.';
@override @override
String get settingsUnlinkConfirm => '연결된 계정을 끊으시겠습니까?'; String get settingsUnlinkConfirm => '연결된 계정을 끊으시겠습니까?';
@@ -262,8 +262,8 @@ class AppLocalizationsKo extends AppLocalizations {
String get settingsSameEmailOnly => '계정 이메일과 동일한 소셜 계정만\n연결 할 수 있습니다.'; String get settingsSameEmailOnly => '계정 이메일과 동일한 소셜 계정만\n연결 할 수 있습니다.';
@override @override
String settingsLinkNotReady(String name, String items) { String settingsLinkNotReady(String name) {
return '$name 연동은 아직 준비 중입니다. 필요한 항목: $items'; return '이 기기에서는 $name 연동을 아직 이용할 수 없습니다.';
} }
@override @override
@@ -849,7 +849,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String nebulaBody(String days) { String nebulaBody(String days) {
return '창립맴버가 목표 POP을 $days일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 맴버들에게는 기념 배지가 지급됩니다.'; return '창립멤버가 목표 POP을 $days일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 멤버들에게는 기념 배지가 지급됩니다.';
} }
@override @override
@@ -862,7 +862,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get nebulaGoal => '목표 '; String get nebulaGoal => '목표 ';
@override @override
String get nebulaFounders => ' / 창립맴버'; String get nebulaFounders => ' / 창립멤버';
@override @override
String nebulaGoalSemantics(String pop, String members, String days) { String nebulaGoalSemantics(String pop, String members, String days) {
@@ -1855,6 +1855,9 @@ class AppLocalizationsKo extends AppLocalizations {
return '전체 $rank위'; return '전체 $rank위';
} }
@override
String get planetRankNone => '순위 집계 전';
@override @override
String get planetMyContribution => '내 기여도'; String get planetMyContribution => '내 기여도';
@@ -1914,7 +1917,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get questTypeStreaming => '스트리밍'; String get questTypeStreaming => '스트리밍';
@override @override
String get questTypeUnverifiable => '이 퀘스트 유형의 검증 방식이 아직 정해지지 않았습니다.'; String get questTypeUnverifiable => '이 퀘스트는 아직 참여할 수 없습니다.';
@override @override
String get questTabMine => '내 퀘스트'; String get questTabMine => '내 퀘스트';
+14 -11
View File
@@ -37,7 +37,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String commonNotYet(String where) { String commonNotYet(String where) {
return '$where页面将在第二次实现确认(2026-09-10)中加入。'; return '$where页面准备中。';
} }
@override @override
@@ -91,13 +91,13 @@ class AppLocalizationsZh extends AppLocalizations {
} }
@override @override
String authNotConfiguredBody(String name, String items) { String authNotConfiguredBody(String name) {
return '$name登录将在客户提供密钥后开放。\n\n所需项目\n· $items'; return '此设备暂时无法使用$name登录。\n请使用其他登录方式。';
} }
@override @override
String authNotConfiguredNotice(String name, String items) { String authNotConfiguredNotice(String name) {
return '$name登录尚在准备中。\n所需项目: $items'; return '此设备暂时无法使用$name登录。';
} }
@override @override
@@ -173,8 +173,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get myCampaign => '应援活动'; String get myCampaign => '应援活动';
@override @override
String myShareLater(String target) { String myShareText(String nickname, String level, String pop) {
return '$target分享将在09-10版本中接入。'; return '$nickname · Lv.$level · 累计贡献 $pop POP';
} }
@override @override
@@ -247,7 +247,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get settingsWithdrawBody => '您持有的POP将会清零且无法恢复。'; String get settingsWithdrawBody => '您持有的POP将会清零且无法恢复。';
@override @override
String get settingsWithdrawLater => '注销处理将在09-10版本中接入。'; String get settingsWithdrawLater => '注销功能准备中。';
@override @override
String get settingsUnlinkConfirm => '确定要解除关联账号吗?'; String get settingsUnlinkConfirm => '确定要解除关联账号吗?';
@@ -262,8 +262,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get settingsSameEmailOnly => '只能关联与账号邮箱\n相同的社交账号。'; String get settingsSameEmailOnly => '只能关联与账号邮箱\n相同的社交账号。';
@override @override
String settingsLinkNotReady(String name, String items) { String settingsLinkNotReady(String name) {
return '$name关联尚在准备中。所需项目: $items'; return '此设备暂时无法使用$name关联。';
} }
@override @override
@@ -1853,6 +1853,9 @@ class AppLocalizationsZh extends AppLocalizations {
return '总排名第 $rank'; return '总排名第 $rank';
} }
@override
String get planetRankNone => '尚未排名';
@override @override
String get planetMyContribution => '我的贡献度'; String get planetMyContribution => '我的贡献度';
@@ -1912,7 +1915,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get questTypeStreaming => '流媒体'; String get questTypeStreaming => '流媒体';
@override @override
String get questTypeUnverifiable => '该任务类型的验证方式尚未确定。'; String get questTypeUnverifiable => '该任务暂时无法参与。';
@override @override
String get questTabMine => '我的任务'; String get questTabMine => '我的任务';
+8 -7
View File
@@ -9,7 +9,7 @@
"commonPrev": "上一步", "commonPrev": "上一步",
"commonDone": "完成", "commonDone": "完成",
"commonStart": "开始", "commonStart": "开始",
"commonNotYet": "{where}页面将在第二次实现确认(2026-09-10)中加入。", "commonNotYet": "{where}页面准备中。",
"languageSettingsTitle": "语言设置", "languageSettingsTitle": "语言设置",
"authLogin": "登录", "authLogin": "登录",
"authSignup": "注册", "authSignup": "注册",
@@ -20,8 +20,8 @@
"authProviderLogin": "{provider, select, apple{通过Apple登录} google{通过Google登录} kakao{通过Kakao登录} line{通过LINE登录} other{通过邮箱登录}}", "authProviderLogin": "{provider, select, apple{通过Apple登录} google{通过Google登录} kakao{通过Kakao登录} line{通过LINE登录} other{通过邮箱登录}}",
"authProviderSignup": "{provider, select, apple{通过Apple注册} google{通过Google注册} kakao{通过Kakao注册} line{通过LINE注册} other{通过邮箱注册}}", "authProviderSignup": "{provider, select, apple{通过Apple注册} google{通过Google注册} kakao{通过Kakao注册} line{通过LINE注册} other{通过邮箱注册}}",
"authNotConfiguredTitle": "{name}登录准备中", "authNotConfiguredTitle": "{name}登录准备中",
"authNotConfiguredBody": "{name}登录将在客户提供密钥后开放。\n\n所需项目\n· {items}", "authNotConfiguredBody": "此设备暂时无法使用{name}登录。\n请使用其他登录方式。",
"authNotConfiguredNotice": "{name}登录尚在准备中。\n所需项目: {items}", "authNotConfiguredNotice": "此设备暂时无法使用{name}登录。",
"authResetNeedEmail": "请先输入接收重置邮件的邮箱。", "authResetNeedEmail": "请先输入接收重置邮件的邮箱。",
"authResetSent": "已向该地址发送重置说明。", "authResetSent": "已向该地址发送重置说明。",
"termsTitle": "使用条款", "termsTitle": "使用条款",
@@ -46,7 +46,7 @@
"myTabActivity": "活动", "myTabActivity": "活动",
"myInside": "Inside", "myInside": "Inside",
"myCampaign": "应援活动", "myCampaign": "应援活动",
"myShareLater": "{target}分享将在09-10版本中接入。", "myShareText": "{nickname} · Lv.{level} · 累计贡献 {pop} POP",
"myEditPhoto": "修改头像", "myEditPhoto": "修改头像",
"myEditProfile": "编辑资料", "myEditProfile": "编辑资料",
"settingsTitle": "设置", "settingsTitle": "设置",
@@ -70,12 +70,12 @@
"settingsWithdraw": "注销账号", "settingsWithdraw": "注销账号",
"settingsWithdrawConfirm": "确定要注销账号吗?", "settingsWithdrawConfirm": "确定要注销账号吗?",
"settingsWithdrawBody": "您持有的POP将会清零且无法恢复。", "settingsWithdrawBody": "您持有的POP将会清零且无法恢复。",
"settingsWithdrawLater": "注销处理将在09-10版本中接入。", "settingsWithdrawLater": "注销功能准备中。",
"settingsUnlinkConfirm": "确定要解除关联账号吗?", "settingsUnlinkConfirm": "确定要解除关联账号吗?",
"settingsUnlinkBody": "仅移除该社交登录方式,账号信息不会被删除。", "settingsUnlinkBody": "仅移除该社交登录方式,账号信息不会被删除。",
"settingsUnlink": "解除关联", "settingsUnlink": "解除关联",
"settingsSameEmailOnly": "只能关联与账号邮箱\n相同的社交账号。", "settingsSameEmailOnly": "只能关联与账号邮箱\n相同的社交账号。",
"settingsLinkNotReady": "{name}关联尚在准备中。所需项目: {items}", "settingsLinkNotReady": "此设备暂时无法使用{name}关联。",
"settingsConnect": "关联", "settingsConnect": "关联",
"settingsDisconnect": "解除", "settingsDisconnect": "解除",
"settingsEmailProvider": "邮箱", "settingsEmailProvider": "邮箱",
@@ -549,6 +549,7 @@
"planetNoContributors": "还没有贡献者", "planetNoContributors": "还没有贡献者",
"planetContributeCta": "贡献一下,登上排行榜吧!", "planetContributeCta": "贡献一下,登上排行榜吧!",
"planetOverallRank": "总排名第 {rank}", "planetOverallRank": "总排名第 {rank}",
"planetRankNone": "尚未排名",
"planetMyContribution": "我的贡献度", "planetMyContribution": "我的贡献度",
"planetTallying": "统计准备中", "planetTallying": "统计准备中",
"planetTopPercent": "前 {percent}%", "planetTopPercent": "前 {percent}%",
@@ -567,7 +568,7 @@
"questTypeSteps": "步行", "questTypeSteps": "步行",
"questTypePlace": "指定地点", "questTypePlace": "指定地点",
"questTypeStreaming": "流媒体", "questTypeStreaming": "流媒体",
"questTypeUnverifiable": "该任务类型的验证方式尚未确定。", "questTypeUnverifiable": "该任务暂时无法参与。",
"questTabMine": "我的任务", "questTabMine": "我的任务",
"placePickPrompt": "请选择\n完成任务的地点。", "placePickPrompt": "请选择\n完成任务的地点。",
"placeSearch": "搜索地点", "placeSearch": "搜索地点",
+7 -4
View File
@@ -107,10 +107,10 @@ class RemoteAppLocalizations extends AppLocalizations {
String get authMethodUnavailable => _remote['authMethodUnavailable'] ?? _base.authMethodUnavailable; String get authMethodUnavailable => _remote['authMethodUnavailable'] ?? _base.authMethodUnavailable;
@override @override
String authNotConfiguredBody(String name, String items) => _fill('authNotConfiguredBody', {'name': name, 'items': items}) ?? _base.authNotConfiguredBody(name, items); String authNotConfiguredBody(String name) => _fill('authNotConfiguredBody', {'name': name}) ?? _base.authNotConfiguredBody(name);
@override @override
String authNotConfiguredNotice(String name, String items) => _fill('authNotConfiguredNotice', {'name': name, 'items': items}) ?? _base.authNotConfiguredNotice(name, items); String authNotConfiguredNotice(String name) => _fill('authNotConfiguredNotice', {'name': name}) ?? _base.authNotConfiguredNotice(name);
@override @override
String authNotConfiguredTitle(String name) => _fill('authNotConfiguredTitle', {'name': name}) ?? _base.authNotConfiguredTitle(name); String authNotConfiguredTitle(String name) => _fill('authNotConfiguredTitle', {'name': name}) ?? _base.authNotConfiguredTitle(name);
@@ -1163,7 +1163,7 @@ class RemoteAppLocalizations extends AppLocalizations {
String get myProfileCardTooltip => _remote['myProfileCardTooltip'] ?? _base.myProfileCardTooltip; String get myProfileCardTooltip => _remote['myProfileCardTooltip'] ?? _base.myProfileCardTooltip;
@override @override
String myShareLater(String target) => _fill('myShareLater', {'target': target}) ?? _base.myShareLater(target); String myShareText(String nickname, String level, String pop) => _fill('myShareText', {'nickname': nickname, 'level': level, 'pop': pop}) ?? _base.myShareText(nickname, level, pop);
@override @override
String get myTabActivity => _remote['myTabActivity'] ?? _base.myTabActivity; String get myTabActivity => _remote['myTabActivity'] ?? _base.myTabActivity;
@@ -1429,6 +1429,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String planetPerHead(String value) => _fill('planetPerHead', {'value': value}) ?? _base.planetPerHead(value); String planetPerHead(String value) => _fill('planetPerHead', {'value': value}) ?? _base.planetPerHead(value);
@override
String get planetRankNone => _remote['planetRankNone'] ?? _base.planetRankNone;
@override @override
String planetShape(String n) => _fill('planetShape', {'n': n}) ?? _base.planetShape(n); String planetShape(String n) => _fill('planetShape', {'n': n}) ?? _base.planetShape(n);
@@ -2075,7 +2078,7 @@ class RemoteAppLocalizations extends AppLocalizations {
String get settingsLatest => _remote['settingsLatest'] ?? _base.settingsLatest; String get settingsLatest => _remote['settingsLatest'] ?? _base.settingsLatest;
@override @override
String settingsLinkNotReady(String name, String items) => _fill('settingsLinkNotReady', {'name': name, 'items': items}) ?? _base.settingsLinkNotReady(name, items); String settingsLinkNotReady(String name) => _fill('settingsLinkNotReady', {'name': name}) ?? _base.settingsLinkNotReady(name);
@override @override
String get settingsLogout => _remote['settingsLogout'] ?? _base.settingsLogout; String get settingsLogout => _remote['settingsLogout'] ?? _base.settingsLogout;
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/core/ui/app_dialog.dart';
/// 공통 확인 창 — Figma `2066:17799` · `554:24669`: 취소(테두리)·실행(분홍 채움)이
/// 한 줄에 반씩. 실기기(v0.5.0+7)에서 실행 버튼이 전체 폭 테마 때문에 아래로 밀리고
/// 취소가 오른쪽 위 글자 버튼으로 남던 배치를 막는다.
void main() {
Future<List<Object?>> open(
WidgetTester tester,
Future<Object?> Function(BuildContext) show,
) async {
final results = <Object?>[];
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => TextButton(
onPressed: () async => results.add(await show(context)),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return results;
}
testWidgets('취소 · 실행이 같은 줄에 왼쪽 · 오른쪽 같은 폭으로 놓인다', (tester) async {
final results = await open(
tester,
(c) => showConfirmDialog(c, '기본 행성을 변경할까요?'),
);
final cancel = find.widgetWithText(OutlinedButton, '취소');
final change = find.widgetWithText(FilledButton, '변경');
expect(cancel, findsOneWidget);
expect(change, findsOneWidget);
final a = tester.getRect(cancel);
final b = tester.getRect(change);
expect(a.top, b.top);
expect(a.height, 48);
expect(a.width, b.width);
expect(a.right, lessThan(b.left));
// 버튼 줄은 문구 아래.
expect(tester.getRect(find.text('기본 행성을 변경할까요?')).bottom,
lessThan(a.top));
await tester.tap(change);
await tester.pumpAndSettle();
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(cancel);
await tester.pumpAndSettle();
expect(results, [true, false]);
});
testWidgets('단일 알림은 확인 하나가 카드 폭을 채운다', (tester) async {
await open(tester, (c) => showAlertDialog(c, '저장했어요'));
expect(find.byType(OutlinedButton), findsNothing);
final ok = tester.getRect(find.widgetWithText(FilledButton, '확인'));
final card = tester.getRect(
find.descendant(of: find.byType(AppDialog), matching: find.byType(Material)).first,
);
expect(ok.width, card.width - 32);
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(find.byType(AppDialog), findsNothing);
});
}
+12
View File
@@ -1,3 +1,5 @@
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/login_screen.dart' show authNotice;
import 'package:fancake/features/auth/social_providers.dart'; import 'package:fancake/features/auth/social_providers.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -92,6 +94,16 @@ void main() {
} }
}); });
}); });
// 10/4 실기기 2-3 — Android Apple 로그인 다이얼로그에 「발주사 키」 · 설정 항목이 떴다.
test('설정 전 제공자 안내는 내부 설정 항목을 내보내지 않는다', () {
final notice = authNotice(
const AuthNotConfigured(AuthProviderKind.apple, ['Apple Android 웹 인증 설정']),
)!;
expect(notice, contains('Apple'));
expect(notice, isNot(contains('웹 인증 설정')));
expect(notice, isNot(contains('발주사')));
});
} }
/// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면 /// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면
+28 -1
View File
@@ -207,13 +207,25 @@ void main() {
'active': true, 'title': '오늘의 출석체크', 'rewardPop': 50, 'dailyLimit': 1, 'active': true, 'title': '오늘의 출석체크', 'rewardPop': 50, 'dailyLimit': 1,
}); });
await db.doc('users/$_uid/questProgress/attend') await db.doc('users/$_uid/questProgress/attend')
.set({'count': 1, 'dayKey': '2026-09-01'}); .set({'count': 1, 'dayKey': kstDayKey()});
final snapshot = await FirestoreHomeRepository(_backend(db)).load(); final snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.quests.single.state, QuestState.done); expect(snapshot.quests.single.state, QuestState.done);
expect(snapshot.quests.single.rewardPop, 50); expect(snapshot.quests.single.rewardPop, 50);
}); });
test('지난 날의 진행 행은 완료로 보지 않는다 — 목록 · 상세와 같은 판정(실기기 4-1)', () async {
final db = await _seed();
await db.doc('quests/attend').set({
'active': true, 'type': 'attend', 'title': '출석 체크', 'dailyLimit': 1,
});
await db.doc('users/$_uid/questProgress/attend')
.set({'count': 1, 'dayKey': '2026-09-21'});
final snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.quests.single.state, isNot(QuestState.done));
});
test('출석 보상은 서버 지급과 같은 config/policy.attendPop 을 쓴다', () async { test('출석 보상은 서버 지급과 같은 config/policy.attendPop 을 쓴다', () async {
final db = await _seed(); final db = await _seed();
await db.doc('quests/attend').set({ await db.doc('quests/attend').set({
@@ -464,6 +476,21 @@ void main() {
expect(detail.scores.every((s) => s.myTopPercent == null), isTrue); expect(detail.scores.every((s) => s.myTopPercent == null), isTrue);
}); });
test('행성 값이 0 인 축은 남아 있는 내 기여 상위 값을 쓰지 않는다', () async {
// 10/4 실기기 — FPI 0 행성에 시드의 「상위 43%」가 그대로 떴다.
final db = await seedPlanet();
await db.doc('fandoms/bts').set({
'fpiAxes': {'bpm': 0},
}, SetOptions(merge: true));
final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts')
.load(nebula: false);
final bpm = detail.scores.firstWhere((s) => s.metric == FpiMetric.bpm);
expect(bpm.myTopPercent, isNull);
// 값이 있는 축은 그대로 읽는다.
final health = detail.scores.firstWhere((s) => s.metric == FpiMetric.health);
expect(health.myTopPercent, 12);
});
test('스냅샷이 없으면 동향은 0 이다', () async { test('스냅샷이 없으면 동향은 0 이다', () async {
final db = await seedPlanet(); final db = await seedPlanet();
await db.doc('fandoms/bts/fpiSnapshots/2026-09-01-00').delete(); await db.doc('fandoms/bts/fpiSnapshots/2026-09-01-00').delete();
+59 -7
View File
@@ -172,9 +172,15 @@ void main() {
// 검토회신서 3절 — *「선택 행성 정보가 행성과 연계된 HUD 가 아니라 화면 // 검토회신서 3절 — *「선택 행성 정보가 행성과 연계된 HUD 가 아니라 화면
// 최상단 고정 패널 형태」*. 고정 패널로 되돌아가면 여기서 깨진다. // 최상단 고정 패널 형태」*. 고정 패널로 되돌아가면 여기서 깨진다.
testWidgets('HUD 는 화면이 아니라 행성에 붙어 움직인다', (tester) async { testWidgets('HUD 는 화면이 아니라 행성에 붙어 움직인다', (tester) async {
tester.view.physicalSize = const Size(1200, 2000);
tester.view.devicePixelRatio = 2.0;
addTearDown(tester.view.reset);
await _pump(tester, _galaxy()); await _pump(tester, _galaxy());
// 가운데 내 행성은 궤도를 돌지 않는다 — 궤도 위 행성을 골라 돌린다.
await tester.tap(find.byKey(const ValueKey('newjeans')));
await _settle(tester);
final before = tester.getCenter(find.text('RANK')); final before = tester.getCenter(find.text('RANK'));
await tester.drag(find.byType(InteractiveViewer), const Offset(-90, -70)); await tester.drag(find.byType(InteractiveViewer), const Offset(-200, 0));
await _settle(tester); await _settle(tester);
final after = tester.getCenter(find.text('RANK')); final after = tester.getCenter(find.text('RANK'));
expect( expect(
@@ -275,17 +281,63 @@ void main() {
await _pump(tester, _galaxy()); await _pump(tester, _galaxy());
expect(find.text('RANK'), findsOneWidget); expect(find.text('RANK'), findsOneWidget);
// 맵을 끝까지 밀어 내 행성을 화면 밖으로 보낸다. // 끝까지 확대하며 두 손가락을 오른쪽 아래로 끌어 내 행성을 화면 밖으로 보낸다
for (var i = 0; i < 6; i++) { // (한 손가락 팬은 없다 — 확대 제스처의 초점 이동만 맵을 옮긴다).
await tester.drag( final viewer = find.byType(InteractiveViewer);
find.byType(InteractiveViewer), final origin = tester.getTopLeft(viewer);
const Offset(-400, -400), final a = await tester.startGesture(origin + const Offset(4, 4));
final b = await tester.startGesture(
origin + const Offset(24, 24),
pointer: 7,
); );
await _settle(tester); for (var i = 1; i <= 10; i++) {
await b.moveTo(origin + Offset(24.0 + i * 32, 24.0 + i * 32));
await a.moveTo(origin + Offset(4.0 + i * 20, 4.0 + i * 20));
await tester.pump();
} }
await a.up();
await b.up();
await _settle(tester);
expect(find.text('RANK'), findsNothing); expect(find.text('RANK'), findsNothing);
}); });
// 10/4 실기기 — 가로로 끌면 궤도가 돌지 않고 장면이 통째로 밀려 빈 화면이 됐다.
testWidgets('가로로 끌면 장면은 제자리에서 궤도가 돌고, 손을 떼도 관성으로 더 돈다', (tester) async {
await _pump(tester, _galaxy());
final viewer = tester.widget<InteractiveViewer>(
find.byType(InteractiveViewer),
);
final view = viewer.transformationController!;
final before = view.value.clone();
final mine = tester.getCenter(find.text('RANK'));
for (var i = 0; i < 4; i++) {
await tester.fling(
find.byType(InteractiveViewer),
const Offset(-300, 0),
1500,
);
await tester.pump();
}
expect(view.value, before, reason: '끌기는 맵을 옮기지 않는다(팬 없음)');
expect(
(tester.getCenter(find.text('RANK')) - mine).distance,
lessThan(1),
reason: '가운데 내 행성은 화면 밖으로 밀려나지 않는다',
);
// 관성 — 손을 뗀 뒤에도 기본 표류보다 빠르게 돌다가 감속한다.
final state = tester.state(find.byType(GalaxyScreen));
final clock = (state as dynamic).clockForTest as GalaxyClock;
final v0 = clock.velocity;
expect(v0.abs(), greaterThan(GalaxyMotion.idleSpin));
await _settle(tester);
expect(
(clock.velocity - GalaxyMotion.idleSpin).abs(),
lessThan((v0 - GalaxyMotion.idleSpin).abs()),
);
});
testWidgets('GALAXY 는 좌측 하단, 돋보기는 우측 하단', (tester) async { testWidgets('GALAXY 는 좌측 하단, 돋보기는 우측 하단', (tester) async {
await _pump(tester, _galaxy()); await _pump(tester, _galaxy());
expect(find.text('GALAXY'), findsOneWidget); expect(find.text('GALAXY'), findsOneWidget);
+10 -5
View File
@@ -61,7 +61,8 @@ class _NoTopPercentRepository extends SamplePlanetRepository {
FpiScore( FpiScore(
metric: s.metric, metric: s.metric,
value: s.value, value: s.value,
rank: s.rank, // 집계 모집단 밖(서버 fpiRanks 없음)이면 순위가 0 으로 온다.
rank: 0,
trend: s.trend, trend: s.trend,
myTopPercent: null, myTopPercent: null,
), ),
@@ -166,7 +167,7 @@ void main() {
expect(find.text('상위 17%'), findsWidgets); expect(find.text('상위 17%'), findsWidgets);
}); });
testWidgets('내 기여 상위 집계 전이면 숫자 대신 사유를 띄운다 — 대조표 3-5', (tester) async { testWidgets('집계 값이 없으면 숫자를 짓지 않고 내부 사유도 띄우지 않는다 — 대조표 3-5', (tester) async {
await _pumpTall( await _pumpTall(
tester, tester,
const PlanetDetailScreen( const PlanetDetailScreen(
@@ -177,9 +178,13 @@ void main() {
); );
// 없는 값을 100%(최하위)로 지어내지 않는다. // 없는 값을 100%(최하위)로 지어내지 않는다.
expect(find.text('상위 100%'), findsNothing); expect(find.text('상위 100%'), findsNothing);
expect(find.text('집계 준비 중'), findsWidgets); expect(find.text('—'), findsWidgets);
// 사유는 배지로도 뜬다 — 숨기지 않는다. // 개발용 사유(MOCK · 집계 함수)는 사용자 화면에 내보내지 않는다(10/4 실기기 3-1).
expect(find.textContaining('MOCK · '), findsOneWidget); expect(find.textContaining('MOCK'), findsNothing);
expect(find.textContaining('집계 함수'), findsNothing);
// 순위가 없으면 「전체 0위」 대신 「순위 집계 전」.
expect(find.text('전체 0위'), findsNothing);
expect(find.text('순위 집계 전'), findsWidgets);
}); });
testWidgets('이클립스가 발동 중이면 침식·방어 진행을 띄운다', (tester) async { testWidgets('이클립스가 발동 중이면 침식·방어 진행을 띄운다', (tester) async {
+70 -5
View File
@@ -90,6 +90,29 @@ void main() {
expect(find.textContaining('광고 추가 보상'), findsNothing); expect(find.textContaining('광고 추가 보상'), findsNothing);
}); });
testWidgets('참여 조건 판정이 실패하면 광고 모달을 띄우지 않는다 (실기기 4-3)', (tester) async {
final repo = _AdRepo(
verifyError: const BackendException('E-QST-03', '기기의 위치 기능이 꺼져 있습니다.'),
);
await pumpWide(tester, QuestDetailScreen(questId: 'q2', repository: repo));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
await tester.pumpAndSettle();
expect(find.text('광고 보고 추가 보상 받기'), findsNothing);
expect(find.text('기기의 위치 기능이 꺼져 있습니다.'), findsOneWidget);
expect(repo.claimed, isEmpty);
});
testWidgets('판정을 통과한 뒤에야 광고 모달을 띄운다', (tester) async {
final repo = _AdRepo();
await pumpWide(tester, QuestDetailScreen(questId: 'q2', repository: repo));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
await tester.pumpAndSettle();
expect(repo.verified, 1);
expect(find.text('광고 보고 추가 보상 받기'), findsOneWidget);
});
testWidgets('일일 한도(E-AD-02)를 알리고 기본 보상은 그대로 띄운다', (tester) async { testWidgets('일일 한도(E-AD-02)를 알리고 기본 보상은 그대로 띄운다', (tester) async {
final repo = _AdRepo( final repo = _AdRepo(
claimError: const BackendException('E-AD-02', '오늘 광고 보상 한도를 모두 사용했습니다.'), claimError: const BackendException('E-AD-02', '오늘 광고 보상 한도를 모두 사용했습니다.'),
@@ -101,6 +124,21 @@ void main() {
}); });
}); });
group('목록 헤더 · 실패 (2026-10-04 실기기)', () {
testWidgets('헤더 보유 POP 은 목록을 읽을 때 지갑 값으로 다시 맞춘다', (tester) async {
await pumpWide(tester, QuestListScreen(repository: _BalanceRepo(), myPop: 12290));
await tester.pumpAndSettle();
expect(find.text('12,260'), findsOneWidget);
expect(find.text('12,290'), findsNothing);
});
testWidgets('목록 조회가 실패해도 로딩이 끝난다', (tester) async {
await pumpWide(tester, QuestListScreen(repository: _FailingList()));
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
});
});
group('진행 상태 (F4 · 518:88441)', () { group('진행 상태 (F4 · 518:88441)', () {
test('명세가 정한 다섯 상태의 버튼 문구를 그대로 쓴다', () { test('명세가 정한 다섯 상태의 버튼 문구를 그대로 쓴다', () {
expect(QuestStage.mine.buttonLabel, '자세히'); expect(QuestStage.mine.buttonLabel, '자세히');
@@ -233,7 +271,8 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, '보상받기')); await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// 별첨 B B-28 — 배수형 철회. 추가분은 고정형 운영값(C-02 초기 10 기여 POP). // 별첨 B B-28 — 배수형 철회. 추가분은 고정형 운영값(C-02 초기 10 기여 POP).
expect(find.text('+10 POP'), findsOneWidget); // 원장 C-116 — 「+N 기여 POP」 표기.
expect(find.text('+10 기여 POP'), findsOneWidget);
expect(find.textContaining('배'), findsNothing); expect(find.textContaining('배'), findsNothing);
// 별첨1 ⑥ No-Force — 보지 않아도 기본 보상을 받는다. // 별첨1 ⑥ No-Force — 보지 않아도 기본 보상을 받는다.
expect(find.text('기본 보상 받기'), findsOneWidget); expect(find.text('기본 보상 받기'), findsOneWidget);
@@ -270,14 +309,18 @@ void main() {
expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget); expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget);
}); });
testWidgets('회신 대기 유형은 참여 버튼이 잠기고 MOCK 으로 알린다', (tester) async { testWidgets('회신 대기 유형은 참여 버튼이 잠기고 사용자 문구로만 알린다', (tester) async {
await pumpWide(tester, const QuestDetailScreen(questId: 'q6', repository: _sample)); await pumpWide(tester, const QuestDetailScreen(questId: 'q6', repository: _sample));
await tester.pump(); await tester.pump();
final button = tester.widget<FilledButton>( final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '발주사 확인 중'), find.widgetWithText(FilledButton, '준비 중입니다.'),
); );
expect(button.onPressed, isNull); expect(button.onPressed, isNull);
expect(find.textContaining('Q-QC-04'), findsOneWidget); expect(find.text('이 퀘스트는 아직 참여할 수 없습니다.'), findsOneWidget);
// 내부 질의 번호 · 「발주사」 · MOCK 은 화면에 내보내지 않는다.
expect(find.textContaining('Q-QC-'), findsNothing);
expect(find.textContaining('발주사'), findsNothing);
expect(find.textContaining('MOCK'), findsNothing);
}); });
testWidgets('보상을 받고 나오면 보상완료가 되고 더 못 누른다', (tester) async { testWidgets('보상을 받고 나오면 보상완료가 되고 더 못 누른다', (tester) async {
@@ -464,14 +507,36 @@ void main() {
}); });
} }
/// 지갑이 서버 값으로 바뀐 표본 — 생성 예치 · 수수료 뒤 헤더가 다시 맞는가.
class _BalanceRepo extends SampleQuestRepository {
@override
Future<int?> popBalance() async => 12260;
}
/// 목록이 실패하는 표본 — 로딩이 끝나지 않으면 탭 전체가 무한 로딩이다.
class _FailingList extends SampleQuestRepository {
@override
Future<List<QuestCard>> load({int limit = 10}) async =>
throw const BackendException('E-PERM-01', 'denied');
}
/// 광고 경계만 바꾼 표본 — B-03 「광고 실패 시 기본 보상 지급 · 추가 보상 없음」. /// 광고 경계만 바꾼 표본 — B-03 「광고 실패 시 기본 보상 지급 · 추가 보상 없음」.
class _AdRepo extends SampleQuestRepository { class _AdRepo extends SampleQuestRepository {
_AdRepo({this.loadError, this.impression = 'imp-1', this.claimError}); _AdRepo({this.loadError, this.impression = 'imp-1', this.claimError, this.verifyError});
final BackendException? loadError; final BackendException? loadError;
final String? impression; final String? impression;
final BackendException? claimError; final BackendException? claimError;
final BackendException? verifyError;
final claimed = <String>[]; final claimed = <String>[];
var verified = 0;
@override
Future<QuestReward> verify(QuestCard card) async {
verified += 1;
if (verifyError case final error?) throw error;
return super.verify(card);
}
@override @override
Future<RewardedAdHandle> loadRewardedAd() async { Future<RewardedAdHandle> loadRewardedAd() async {
+5 -6
View File
@@ -7,6 +7,7 @@ import 'package:fancake/features/campaign/campaign_ranking_screen.dart';
import 'package:fancake/features/campaign/campaign_search_screen.dart'; import 'package:fancake/features/campaign/campaign_search_screen.dart';
import 'package:fancake/features/campaign/widgets/campaign_period_sheet.dart'; import 'package:fancake/features/campaign/widgets/campaign_period_sheet.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fancake/core/ui/app_dialog.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
/// F5 캠페인 — 화면 대조 원장 8화면. /// F5 캠페인 — 화면 대조 원장 8화면.
@@ -418,12 +419,10 @@ void main() {
expect(find.text('2027년 4월'), findsOneWidget); expect(find.text('2027년 4월'), findsOneWidget);
}); });
testWidgets('Catalog 표본 가격은 앱바 MOCK 알약으로 알린다(G-08)', (tester) async { testWidgets('앱바에 개발용 MOCK 표시를 띄우지 않는다', (tester) async {
await pumpWide(tester, CampaignCreateScreen()); await pumpWide(tester, CampaignCreateScreen());
expect( expect(find.textContaining('MOCK'), findsNothing);
find.bySemanticsLabel('MOCK · 상품 목록은 Catalog 연결 전 표본입니다'), expect(find.bySemanticsLabel(RegExp('MOCK')), findsNothing);
findsOneWidget,
);
}); });
testWidgets('값이 다 차기 전까지 완료가 잠겨 있다', (tester) async { testWidgets('값이 다 차기 전까지 완료가 잠겨 있다', (tester) async {
@@ -465,7 +464,7 @@ void main() {
expect(find.text('이 내용으로 올릴까요?'), findsOneWidget); expect(find.text('이 내용으로 올릴까요?'), findsOneWidget);
expect( expect(
find.descendant( find.descendant(
of: find.byType(AlertDialog), of: find.byType(AppDialog),
matching: find.textContaining('500,000 POP · 14일'), matching: find.textContaining('500,000 POP · 14일'),
), ),
findsOneWidget, findsOneWidget,
+68 -7
View File
@@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/inside/inside_data.dart';
import 'package:fancake/features/auth/auth_repository.dart'; import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/my/my_data.dart'; import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/my_lists_screen.dart'; import 'package:fancake/features/my/my_lists_screen.dart';
@@ -15,8 +15,11 @@ import 'package:fancake/features/my/push_settings_screen.dart';
import 'package:fancake/features/my/settings_screen.dart'; import 'package:fancake/features/my/settings_screen.dart';
import 'package:fancake/features/my/support_screens.dart'; import 'package:fancake/features/my/support_screens.dart';
import 'package:fancake/features/my/tier_screens.dart'; import 'package:fancake/features/my/tier_screens.dart';
import 'package:fancake/features/my/widgets/profile_card.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:share_plus/share_plus.dart';
/// F6 MY — 화면 대조 원장 21화면. /// F6 MY — 화면 대조 원장 21화면.
@@ -178,11 +181,45 @@ void main() {
await tester.pump(); await tester.pump();
await tester.tap(find.byType(CircleAvatar).first); await tester.tap(find.byType(CircleAvatar).first);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
for (final target in const ['인스타그램', '틱톡', 'X', '링크 복사']) { // 프로필 웹 주소가 없어 마지막 칸은 「문구 복사」다(없는 링크를 약속하지 않는다).
for (final target in const ['인스타그램', '틱톡', 'X', '문구 복사']) {
expect(find.byTooltip(target), findsOneWidget); expect(find.byTooltip(target), findsOneWidget);
} }
}); });
testWidgets('공유는 OS 공유 시트로 프로필 문구를 보낸다 — 내부 일정 문구 없음', (tester) async {
final profile = await SampleMyRepository().profile();
ShareParams? sent;
final copied = await shareProfile(
profile,
'인스타그램',
share: (params) async {
sent = params;
return const ShareResult('ok', ShareResultStatus.success);
},
);
expect(copied, isFalse);
expect(sent?.text, contains(profile.nickname));
expect(sent?.text, contains('#FANUZU'));
expect(sent?.text, isNot(contains('회차')));
// 마지막 칸은 공유 시트 대신 같은 문구를 복사한다.
String? clip;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
clip = (call.arguments as Map)['text'] as String?;
}
return null;
},
);
addTearDown(() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null));
expect(await shareProfile(profile, '문구 복사'), isTrue);
expect(clip, profileShareText(profile));
});
testWidgets('티어 카드를 누르면 행성별 티어로 넘어간다', (tester) async { testWidgets('티어 카드를 누르면 행성별 티어로 넘어간다', (tester) async {
await pumpWide(tester, MyScreen(repository: SampleMyRepository())); await pumpWide(tester, MyScreen(repository: SampleMyRepository()));
await tester.pump(); await tester.pump();
@@ -844,11 +881,17 @@ void main() {
}); });
group('내 활동 (F6 · 370:228289 · 370:229665)', () { group('내 활동 (F6 · 370:228289 · 370:229665)', () {
testWidgets('내 인사이드에 내가 올린 글이 나온다', (tester) async { testWidgets('내 인사이드는 저장소의 내 질문을 보여 준다 — 고정 표본 없음', (tester) async {
await pumpWide(tester, const MyListsScreen(kind: MyListKind.inside)); await pumpWide(
await tester.pump(); tester,
expect(find.textContaining('화양연화'), findsOneWidget); const MyListsScreen(kind: MyListKind.inside, insides: _MyInsides()),
expect(find.text('500 P'), findsOneWidget); );
await tester.pumpAndSettle();
expect(find.text('device-check-question'), findsOneWidget);
expect(find.text('100 P'), findsOneWidget);
// 10/4 실기기 8-3 — 내 글 대신 뜨던 표본(실제 아티스트명 포함).
expect(find.textContaining('화양연화'), findsNothing);
expect(find.textContaining('BTS'), findsNothing);
}); });
testWidgets('내 캠페인에는 내가 만든 것만 나온다', (tester) async { testWidgets('내 캠페인에는 내가 만든 것만 나온다', (tester) async {
@@ -1000,3 +1043,21 @@ class _PagedZapRepository extends SampleMyRepository {
return [_rows[i + 1]]; return [_rows[i + 1]];
} }
} }
/// 내가 쓴 질문 한 건만 돌려주는 저장소 — 화면이 저장소를 읽는지 확인한다.
class _MyInsides extends SampleInsideRepository {
const _MyInsides();
@override
Future<List<InsideQuestion>> mine() async => [
InsideQuestion(
id: 'mine-1',
title: 'device-check-question',
body: '본문',
authorName: '팬',
createdAt: DateTime(2026, 10, 4),
bountyPop: 100,
isMine: true,
),
];
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_home_screen.dart';
/// 「내 행성」 칸 그림은 칸 순서가 아니라 팬덤 stage 로 고른다 — 실기기에서 둘째 칸의
/// FANUZU 데모 행성(stage 1)이 성운 그림으로 나왔다.
void main() {
PlanetNode node(int? stage) => PlanetNode(
id: 'x', name: 'x', rank: 1, fpi: 0, members: 0, online: 0, stage: stage,
);
test('정식 행성은 어느 칸이든 행성 그림, 성운만 성운 그림', () {
for (final i in [0, 1, 2]) {
expect(GalaxyHomeScreen.cardImageOf(node(1), i), isNot(contains('nebula')));
expect(GalaxyHomeScreen.cardImageOf(node(null), i), isNot(contains('nebula')));
expect(GalaxyHomeScreen.cardImageOf(node(0), i), contains('nebula'));
}
});
}
+128
View File
@@ -0,0 +1,128 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_mini_map.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// 프로토타입 v6.2 와 나란히 볼 미니맵 캡처. `MINIMAP_OUT=<폴더>` 일 때만 PNG 를 쓴다.
/// MINIMAP_OUT=build/minimap fvm flutter test test/galaxy_mini_map_capture_test.dart
/// 표본은 프로토타입 `FPI_DATA` 5행성 — 같은 데이터로 두 장면을 비교한다.
const protoPlanets = [
PlanetNode(
id: '1',
name: 'NewJeans',
rank: 1,
fpi: 3200,
members: 1,
online: 4820,
isMine: true,
),
PlanetNode(
id: '2',
name: 'BTS',
rank: 2,
fpi: 2800,
members: 1,
online: 12540,
),
PlanetNode(
id: '3',
name: 'aespa',
rank: 3,
fpi: 2100,
members: 1,
online: 3910,
),
PlanetNode(
id: '4',
name: 'IVE',
rank: 4,
fpi: 1700,
members: 1,
online: 2840,
),
PlanetNode(
id: '5',
name: 'PLAVE',
rank: 5,
fpi: 1300,
members: 1,
online: 5120,
),
];
/// 내 행성이 3위인 경우(C-103 — 1위가 아니어도 내 행성이 가운데).
List<PlanetNode> mineAt(int rank) => [
for (final p in protoPlanets)
PlanetNode(
id: p.id,
name: p.name,
rank: p.rank,
fpi: p.fpi,
members: p.members,
online: p.online,
isMine: p.rank == rank,
),
];
/// 테스트 기본 글꼴(Ahem 네모) 대신 앱 글꼴로 글자 폭을 잰다.
Future<void> loadAppFonts() async {
final manifest =
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
for (final font in manifest.cast<Map<String, dynamic>>()) {
final loader = FontLoader(font['family'] as String);
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
}
await loader.load();
}
}
void main() {
final out = Platform.environment['MINIMAP_OUT'];
if (out == null) return;
setUpAll(() async {
Directory(out).createSync(recursive: true);
await loadAppFonts();
});
for (final (name, planets, size) in [
('minimap-140-mine1', protoPlanets, 140.0),
('minimap-140-mine3', mineAt(3), 140.0),
('minimap-360-mine1', protoPlanets, 360.0),
// Figma 「홈」(2097:16428) 실측 박스 약 93pt — C-105 140pt 와 비교용.
('after_93', protoPlanets, 93.0),
('after_93-mine3', mineAt(3), 93.0),
]) {
testWidgets(name, (tester) async {
final key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Center(
child: RepaintBoundary(
key: key,
child: GalaxyMiniMap(
data: GalaxySnapshot(planets: planets, myPop: 0),
onOpen: () {},
size: size,
),
),
),
),
);
await tester.pump();
await tester.runAsync(() async {
final boundary =
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
File('$out/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List());
});
});
}
}
+69 -10
View File
@@ -11,9 +11,11 @@ import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_mini_map.dart'; import 'package:fancake/features/galaxy/widgets/galaxy_mini_map.dart';
import 'package:fancake/features/home/home_data.dart'; import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_screen.dart'; import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/home/widgets/home_hero.dart';
import 'package:fancake/features/planet/planet_data.dart'; import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/quest/quest_data.dart'; import 'package:fancake/features/quest/quest_data.dart';
import 'eclipse_stubs.dart'; import 'eclipse_stubs.dart';
import 'galaxy_mini_map_capture_test.dart' show loadAppFonts, mineAt;
import 'perf_paint_test.dart' show medianPaintMicros, paintBudgetMicros; import 'perf_paint_test.dart' show medianPaintMicros, paintBudgetMicros;
/// 프로토타입 궤도 간격 450 × Mass Volume 1.5 × Orbit Exp 1.0. /// 프로토타입 궤도 간격 450 × Mass Volume 1.5 × Orbit Exp 1.0.
@@ -187,24 +189,68 @@ void main() {
expect(painterOf(tester).orbitOf('c'), closeTo(2 * _spacing, 5)); expect(painterOf(tester).orbitOf('c'), closeTo(2 * _spacing, 5));
}); });
testWidgets('140 에서는 내 행성 간략 카드만 — 두 줄 모두 9pt 이상', (tester) async { // 10/4 실기기 — 140pt 에서 HUD 글상자가 미니맵 대부분을 덮어 궤도·행성이 안 보였다.
testWidgets('140 에서는 내 행성 간략 카드만(C-105) — 장면을 덮지 않는 크기', (tester) async {
await tester.runAsync(loadAppFonts);
await pumpMap(tester, _snap(_base)); await pumpMap(tester, _snap(_base));
final p = painterOf(tester); final p = painterOf(tester);
expect(p.compactText, [ expect(p.compactText, [
('ALPHA', GalaxyMiniMapPainter.compactNamePt), ('ALPHA', GalaxyMiniMapPainter.compactNamePt),
('#1 · FPI 5,000 · LIVE 1,204', GalaxyMiniMapPainter.compactLinePt), ('#1 · FPI 5,000', GalaxyMiniMapPainter.compactLinePt),
('LIVE 1,204', GalaxyMiniMapPainter.compactLinePt),
]); ]);
expect(p.compactText.first.$2, greaterThanOrEqualTo(10));
for (final (_, pt) in p.compactText) { for (final (_, pt) in p.compactText) {
expect(pt, greaterThanOrEqualTo(9)); expect(pt, greaterThanOrEqualTo(8));
} }
final w = p.hudWidthOf('a')!; final card = p.compactSize;
expect(w, greaterThan(0)); expect(card.width, lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor));
expect(w, lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor)); expect(
card.width * card.height,
lessThan(140 * 140 * 0.2),
reason: '카드는 미니맵 넓이의 1/5 미만',
);
expect(p.hudWidthOf('b'), 0, reason: '다른 행성 카드는 뺀다'); expect(p.hudWidthOf('b'), 0, reason: '다른 행성 카드는 뺀다');
expect(p.hudWidthOf('c'), 0); expect(p.hudWidthOf('c'), 0);
}); });
testWidgets('긴 행성 이름은 말줄임 — 순위·FPI·LIVE 숫자는 줄이지 않는다', (tester) async {
await tester.runAsync(loadAppFonts);
await pumpMap(
tester,
_snap(const [
PlanetNode(
id: 'a',
name: 'FANUZU 데모 행성 아주 긴 이름 #1',
rank: 1,
fpi: 123456,
members: 10,
online: 1204,
isMine: true,
),
]),
);
final p = painterOf(tester);
expect(p.compactText.skip(1).map((t) => t.$1), [
'#1 · FPI 123,456',
'LIVE 1,204',
]);
for (final (_, pt) in p.compactText.skip(1)) {
expect(pt, GalaxyMiniMapPainter.compactLinePt);
}
expect(
p.compactSize.width,
lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor + 0.01),
);
});
testWidgets('내 행성이 1위가 아니어도 첫 화면 가운데에 둔다(C-103)', (tester) async {
await pumpMap(tester, _snap(mineAt(3)));
final p = painterOf(tester);
expect(p.worldOf('3')!.distance, lessThan(1), reason: '내 행성 = 장면 원점');
expect(p.orbitOf('3'), 2 * _spacing, reason: '궤도는 FPI 순위 그대로');
expect(p.worldOf('1')!.distance, greaterThan(_spacing / 2));
});
testWidgets('320 에서는 모든 행성에 프로토타입 카드를 그린다', (tester) async { testWidgets('320 에서는 모든 행성에 프로토타입 카드를 그린다', (tester) async {
await pumpMap(tester, _snap(_base), size: 320); await pumpMap(tester, _snap(_base), size: 320);
final p = painterOf(tester); final p = painterOf(tester);
@@ -307,11 +353,24 @@ void main() {
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
final mini = find.byType(GalaxyMiniMap); final mini = find.byType(GalaxyMiniMap);
expect(mini, findsOneWidget); expect(mini, findsOneWidget);
// Q-DS-06 권고안 A(회신 전 임시) — 행성 영역 우측 상단 140 정사각. // C-105 약 140pt · C-113 자리 = Figma 「홈」(2097:16428) 히어로 우측 상단 · 오른쪽 여백 0.
expect(tester.getSize(mini), const Size(140, 140)); expect(
tester.getSize(mini),
const Size.square(HomeHero.miniMapSize),
);
expect( expect(
tester.getTopRight(mini).dx, tester.getTopRight(mini).dx,
closeTo(tester.getSize(find.byType(HomeScreen)).width - 16, 1), closeTo(tester.getSize(find.byType(HomeScreen)).width, 1),
);
expect(
tester.getTopLeft(mini).dy,
closeTo(
tester.getTopLeft(find.byType(HomeHero)).dy +
MediaQuery.paddingOf(tester.element(mini)).top -
48 + // 사본 y 는 상태 표시줄 48 을 포함한다(HomeHero 와 같은 환산).
HomeHero.miniMapTop,
1,
),
); );
await tester.tap(mini); await tester.tap(mini);
await tester.pump(); await tester.pump();
+204
View File
@@ -0,0 +1,204 @@
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/core/ui/section_header.dart';
import 'package:fancake/features/campaign/campaign_create_screen.dart';
import 'package:fancake/features/campaign/campaign_list_screen.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_repository_firestore.dart';
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'eclipse_stubs.dart';
/// 실기기 판정(2026-10-04) HOME 결함 — HOT ARCHIVE 진입 · 브리핑 · 인기 K-INSIDE · 상태 표시줄.
const _uid = 'u-home';
class _Backend extends Backend {
_Backend(FakeFirebaseFirestore db)
: super(
firestore: db,
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
);
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async => {'code': 'ABCDEFGH23'};
}
/// 모금 중 캠페인이 없는 HOME.
class _NoCampaignRepo extends SampleHomeRepository {
const _NoCampaignRepo();
@override
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
@override
Future<HomeSnapshot> load() async {
final base = await const SampleHomeRepository().load();
return HomeSnapshot(
popBalance: base.popBalance,
liveTicker: base.liveTicker,
growth: base.growth,
quests: base.quests,
briefings: base.briefings,
campaign: null,
insides: base.insides,
);
}
}
Future<void> _pump(WidgetTester tester, HomeRepository repo) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: MediaQuery(
// 노치 단말 — 상태 표시줄 47.
data: const MediaQueryData(
size: Size(540, 1200),
padding: EdgeInsets.only(top: 47),
),
child: HomeScreen(
repository: repo,
questRepository: const SampleQuestRepository(),
eclipse: const NoEclipseRepository(),
),
),
),
);
await tester.pumpAndSettle();
}
Finder get _hotArchive => find.byWidgetPredicate(
(w) => w is SectionHeader && w.title == 'HOT ARCHIVE',
);
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
group('HOT ARCHIVE — 모금 중 캠페인 0건(명세 §2.114-6)', () {
testWidgets('구획과 「더 보기」가 남아 캠페인 목록으로 간다', (tester) async {
await _pump(tester, const _NoCampaignRepo());
await tester.scrollUntilVisible(
_hotArchive,
220,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(
find.descendant(of: _hotArchive, matching: find.text('더 보기')),
);
await tester.pumpAndSettle();
expect(find.byType(CampaignListScreen), findsOneWidget);
});
testWidgets('카드 자리의 「새 캠페인」이 개설 화면을 연다', (tester) async {
await _pump(tester, const _NoCampaignRepo());
final create = find.text('새 캠페인');
await tester.scrollUntilVisible(
create,
220,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(create);
await tester.pumpAndSettle();
expect(find.byType(CampaignCreateScreen), findsOneWidget);
});
});
testWidgets('스크롤하면 상태 표시줄 자리를 가린다(머리글 겹침 방지)', (tester) async {
await _pump(tester, const _NoCampaignRepo());
final scrim = find.byKey(const ValueKey('home-status-scrim'));
// 맨 위에서는 히어로 배경이 상태 표시줄 뒤까지 보여야 한다.
expect(scrim, findsNothing);
await tester.drag(find.byType(Scrollable).first, const Offset(0, -400));
await tester.pumpAndSettle();
expect(scrim, findsOneWidget);
expect(tester.getSize(scrim).height, 47);
await tester.drag(find.byType(Scrollable).first, const Offset(0, 2000));
await tester.pumpAndSettle();
expect(scrim, findsNothing);
});
group('FirestoreHomeRepository', () {
late FakeFirebaseFirestore db;
setUp(() async {
db = FakeFirebaseFirestore();
await db.doc('users/$_uid').set({'fandomId': 'fanuzu-demo'});
});
Future<void> brief(String id, String section, String status) =>
db.doc('briefings/$id').set({
'fandomId': 'fanuzu-demo',
'dayKey': kstDayKey(),
'status': status,
'section': section,
'rank': 1,
'headline': id,
'sourceName': '출처',
'sourceUrl': 'https://example.com/s',
'articleUrl': 'https://example.com/a',
'aiSummary': '요약',
'sourceAt': DateTime(2026, 10, 4),
});
test('브리핑 section 은 서버 값(News·SNS)만 그린다 — 시연 시드 소문자 결함', () async {
await brief('news', 'News', 'published');
await brief('sns', 'SNS', 'published');
await brief('draft', 'News', 'draft');
// 수정 전 시드(`tools/demo/fs.mjs`)가 쓰던 값 — 앱은 모르는 구획이라 버린다.
await brief('lower', 'news', 'published');
final home = await FirestoreHomeRepository(_Backend(db)).load();
expect(
home.briefings.map((b) => b.headline),
unorderedEquals(['news', 'sns']),
);
});
test('인기 K-INSIDE 는 미해결 먼저, 그 안에서 현상금 큰 순(명세 §2.114-7)', () async {
final at = DateTime(2026, 10, 4);
final rows = {
'new-0-answers': ('open', 100, at.add(const Duration(hours: 1))),
'solved-big': ('accepted', 900, at),
'open-500': ('open', 500, at),
'open-300': ('open', 300, at),
'open-200': ('open', 200, at),
};
for (final MapEntry(key: id, value: (status, bounty, createdAt))
in rows.entries) {
await db.doc('questions/$id').set({
'title': id,
'status': status,
'bountyPop': bounty,
'createdAt': createdAt,
});
}
final home = await FirestoreHomeRepository(_Backend(db)).load();
expect(home.insides.map((i) => i.title), [
'open-500',
'open-300',
'open-200',
]);
});
test('미해결이 3건보다 적으면 해결된 고액 질문으로 채운다', () {
expect(
popularInsides([
{'title': 'a', 'status': 'accepted'},
{'title': 'b', 'status': 'open'},
{'title': 'c', 'status': 'expired'},
]).map((q) => q['title']),
['b', 'a', 'c'],
);
});
});
}
+4 -2
View File
@@ -155,7 +155,7 @@ void main() {
expect(find.text('500 P'), findsOneWidget); expect(find.text('500 P'), findsOneWidget);
}); });
testWidgets('아직 없는 화면으로 가려 하면 언제 열리는지 알린다', (tester) async { testWidgets('아직 없는 화면으로 가려 하면 준비 중이라고 알린다', (tester) async {
await _pump(tester); await _pump(tester);
// 첫 「더 보기」는 퀘스트라 실제로 이동한다. 아직 없는 구간까지 굴려서 누른다. // 첫 「더 보기」는 퀘스트라 실제로 이동한다. 아직 없는 구간까지 굴려서 누른다.
// 브리핑 헤더는 「공유」다(`266:90529`). 아직 없는 목록인 인기 K-INSIDE 로 본다. // 브리핑 헤더는 「공유」다(`266:90529`). 아직 없는 목록인 인기 K-INSIDE 로 본다.
@@ -169,7 +169,9 @@ void main() {
await tester.tap(find.descendant(of: briefing, matching: find.text('더 보기'))); await tester.tap(find.descendant(of: briefing, matching: find.text('더 보기')));
await tester.pump(); await tester.pump();
// 조용히 무시하면 검수 때 고장으로 보인다. // 조용히 무시하면 검수 때 고장으로 보인다.
expect(find.textContaining('구현 확인물 2차'), findsOneWidget); expect(find.textContaining('화면은 준비 중입니다.'), findsOneWidget);
// 내부 일정(구현 확인물 · 날짜)은 사용자 화면에 내보내지 않는다.
expect(find.textContaining('구현 확인물'), findsNothing);
}); });
testWidgets('오늘의 퀘스트에서 퀘스트 목록으로 넘어간다', (tester) async { testWidgets('오늘의 퀘스트에서 퀘스트 목록으로 넘어간다', (tester) async {
+17 -2
View File
@@ -172,11 +172,11 @@ void main() {
test('원격 문자열은 인자 값 안의 자리표시자를 다시 치환하지 않는다', () { test('원격 문자열은 인자 값 안의 자리표시자를 다시 치환하지 않는다', () {
final t = RemoteL10nDelegate(const { final t = RemoteL10nDelegate(const {
'linkTitle': '{name} 연동', 'linkTitle': '{name} 연동',
'settingsLinkNotReady': '{name}: {items}', 'myShareText': '{nickname}: {level}',
}); });
return t.load(const Locale('ko')).then((l10n) { return t.load(const Locale('ko')).then((l10n) {
expect(l10n.linkTitle('{name}'), '{name} 연동'); expect(l10n.linkTitle('{name}'), '{name} 연동');
expect(l10n.settingsLinkNotReady('{items}', 'A'), '{items}: A'); expect(l10n.myShareText('{level}', 'A', '1'), '{level}: A');
}); });
}); });
@@ -226,4 +226,19 @@ void main() {
); );
}); });
}); });
// 10/4 실기기 — 「발주사」 · 「09-10 회차에 연결」 · 오탈자 「맴버」가 사용자 화면에 떴다.
test('사용자 문구에 내부 표현 · 일정 · 오탈자가 없다', () {
const banned = ['발주사', '発注元', 'client key', 'MOCK', '회차에 연결', '09-10', '2026-', 'Q-QC-', '맴버', '구현 확인물'];
for (final file in Directory('lib/l10n').listSync().whereType<File>()) {
if (!file.path.endsWith('.arb')) continue;
final arb = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
for (final MapEntry(:key, :value) in arb.entries) {
if (key.startsWith('@') || value is! String) continue;
for (final word in banned) {
expect(value.contains(word), isFalse, reason: '${file.path} $key: $value');
}
}
}
});
} }
+20
View File
@@ -96,6 +96,26 @@ void main() {
}); });
group('퀘스트 목록 — Firestore 구현', () { group('퀘스트 목록 — Firestore 구현', () {
test('UGC 퀘스트의 제출 건 조회가 거부돼도 목록 전체는 뜬다 (2026-10-04 실기기)', () async {
// 제출 건 읽기를 막은 규칙 — 배포 전 규칙에서 없는 문서 get 이 permission-denied 였다.
final db = FakeFirebaseFirestore(securityRules: '''
service cloud.firestore {
match /databases/{database}/documents {
match /quests/{id} { allow read, write: if true; }
match /config/{id} { allow read, write: if true; }
match /users/{document=**} { allow read, write: if true; }
}
}''');
await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'dailyLimit': 1});
await db.doc('quests/ugc').set({
'type': 'community', 'title': '내가 만든 퀘스트', 'authorUid': 'someone',
'status': 'published', 'goal': 1,
});
final cards = await FirestoreQuestRepository(_RecordingBackend(db)).load();
expect(cards.map((c) => c.id).toSet(), {'attend', 'ugc'});
expect(_card(cards, 'ugc').stage, QuestStage.notStarted);
});
test('정의 문서를 카드로 옮기고 유형을 별칭까지 읽는다', () async { test('정의 문서를 카드로 옮기고 유형을 별칭까지 읽는다', () async {
final db = await _seed(); final db = await _seed();
final cards = await FirestoreQuestRepository( final cards = await FirestoreQuestRepository(
+53
View File
@@ -0,0 +1,53 @@
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_repository_firestore.dart' show questToday;
import 'package:fancake/features/quest/widgets/quest_card_tile.dart' show questImage;
import 'package:flutter_test/flutter_test.dart';
/// HOME 과 퀘스트 목록 · 상세가 같이 쓰는 오늘 판정(2026-10-04 실기기 4-1 · 4-9).
void main() {
// 2026-10-04 09:00 KST.
final now = DateTime.utc(2026, 10, 4);
const quest = {'dailyLimit': 1};
const policy = <String, dynamic>{};
test('지난 날의 진행 행은 오늘 0 회다 — 9/21 출석이 10/4 에 「보상 완료」로 남지 않는다', () {
final r = questToday(QuestType.attend, quest, {'dayKey': '2026-09-21', 'count': 1}, policy, now);
expect(r.count, 0);
expect(r.done, isFalse);
});
test('오늘(KST) 행이면 완료다', () {
final r = questToday(QuestType.attend, quest, {'dayKey': '2026-10-04', 'count': 1}, policy, now);
expect(r.done, isTrue);
});
test('날짜 경계는 KST — UTC 10/3 15:00 은 KST 10/4 0 시다', () {
final r = questToday(QuestType.attend, quest, {'dayKey': '2026-10-04', 'count': 1}, policy,
DateTime.utc(2026, 10, 3, 15));
expect(r.done, isTrue);
final before = questToday(QuestType.attend, quest, {'dayKey': '2026-10-04', 'count': 1}, policy,
DateTime.utc(2026, 10, 3, 14, 59));
expect(before.done, isFalse);
});
test('타임어택은 1인 1회라 날짜로 끊지 않는다', () {
final r = questToday(QuestType.timeAttack, quest, {'dayKey': '2026-09-21', 'count': 1}, policy, now);
expect(r.done, isTrue);
});
test('걸음은 구간을 다 받아야 완료다(B-26)', () {
const tiers = {'stepTiers': [{'steps': 1000}, {'steps': 5000}]};
final one = questToday(QuestType.steps, quest, {'dayKey': '2026-10-04', 'count': 1}, tiers, now);
expect(one.done, isFalse);
final two = questToday(QuestType.steps, quest, {'dayKey': '2026-10-04', 'count': 2}, tiers, now);
expect(two.done, isTrue);
});
test('유형 · 제목으로 못 고르는 퀘스트도 아이콘 칸이 비지 않는다', () {
const card = QuestCard(
id: 'daily-heart', title: '심박 기록 남기기', categoryName: 'c', author: QuestAuthor.admin,
stage: QuestStage.notStarted, goal: 1, progress: 0, rewardPop: 80, participants: 0,
);
expect(questImage(card), 'assets/figma/quest-img-bolt.png');
});
}