사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정
This commit is contained in:
@@ -26,6 +26,12 @@ service cloud.firestore {
|
||||
|
||||
// ───────────────────────── 공통 헬퍼 ─────────────────────────
|
||||
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 role() { return signedIn() ? request.auth.token.get('role', '') : ''; }
|
||||
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
|
||||
|| resource.data.questAuthorUid == request.auth.uid
|
||||
|| isOps());
|
||||
// 자기 제출 건은 문서 ID(`{questId}_{uid}` — fn-quest-submit)로 **없어도** 조회한다.
|
||||
// 없는 문서는 resource 가 null 이라 위 조건이 거부되고, 앱의 퀘스트 목록이 UGC 퀘스트
|
||||
// 하나 때문에 통째로 실패했다(2026-10-04 실기기).
|
||||
allow get: if signedIn() && isOwnSubmissionId(submissionId);
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1697,6 +1697,17 @@ describe('사용자 생성 Quest — 제출 · 검토 · 수정 이력 (C-07 ·
|
||||
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 () => {
|
||||
await assertFails(updateDoc(doc(dbFor('alice'), 'questSubmissions/q-ugc_alice'), { status: 'paid' }));
|
||||
await assertFails(updateDoc(doc(dbFor('creator'), 'questSubmissions/q-ugc_alice'), { status: 'approved' }));
|
||||
|
||||
@@ -179,6 +179,7 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
|
||||
push: repos.push,
|
||||
auth: auth,
|
||||
campaigns: repos.campaign,
|
||||
insides: repos.inside,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ final class AuthNotConfigured extends AuthOutcome {
|
||||
|
||||
final AuthProviderKind provider;
|
||||
|
||||
/// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다.
|
||||
/// 투입해야 하는 것들(파일명·키명). 개발자용이라 로그·README 에만 쓰고 화면에는 내보내지 않는다.
|
||||
final List<String> missing;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
|
||||
import '../../core/firebase/firebase_boot.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
@@ -232,23 +234,11 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
||||
|
||||
Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
|
||||
final name = outcome.provider.shortName;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.authNotConfiguredTitle(name)),
|
||||
content: Text(
|
||||
context.l10n.authNotConfiguredBody(
|
||||
name,
|
||||
outcome.missing.join('\n· '),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(context.l10n.commonConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
await showAlertDialog(
|
||||
context,
|
||||
context.l10n.authNotConfiguredTitle(name),
|
||||
// 설정 항목(outcome.missing)은 개발자용이라 화면에 내보내지 않는다.
|
||||
body: context.l10n.authNotConfiguredBody(name),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,11 +371,8 @@ String? authNotice(AuthOutcome outcome, [AppLocalizations? l10n]) =>
|
||||
AuthSucceeded() => null,
|
||||
AuthCancelled() => null,
|
||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||
AuthNotConfigured(:final provider, :final missing) =>
|
||||
(l10n ?? koL10n).authNotConfiguredNotice(
|
||||
provider.shortName,
|
||||
missing.join(' · '),
|
||||
),
|
||||
AuthNotConfigured(:final provider) =>
|
||||
(l10n ?? koL10n).authNotConfiguredNotice(provider.shortName),
|
||||
};
|
||||
|
||||
TextStyle _abz(double size, Color color) => TextStyle(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/ui/mock_tag.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import 'campaign_data.dart';
|
||||
import 'widgets/campaign_period_sheet.dart';
|
||||
@@ -123,8 +124,6 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
|
||||
),
|
||||
title: Text(context.l10n.campaignNew),
|
||||
actions: [
|
||||
// G-08 — 지원 종류 가격은 인스타 광고 300,000 외 자리표시다.
|
||||
const MockTag(note: '상품 목록은 Catalog 연결 전 표본입니다'),
|
||||
TextButton(
|
||||
onPressed: _ready && !_saving ? _confirm : null,
|
||||
// 시안 — 오른쪽 끝에서 16(x 335).
|
||||
@@ -457,31 +456,19 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
|
||||
);
|
||||
|
||||
Future<void> _confirm() async {
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.campaignSubmitTitle),
|
||||
content: Text(
|
||||
context.l10n.campaignSubmitBody(
|
||||
_title.text.trim(),
|
||||
formatPop(_goalPop),
|
||||
'$_days',
|
||||
formatPop(campaignRegistrationFeePop),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonKeepEditing),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.campaignSubmit),
|
||||
),
|
||||
],
|
||||
final go = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.campaignSubmitTitle,
|
||||
body: context.l10n.campaignSubmitBody(
|
||||
_title.text.trim(),
|
||||
formatPop(_goalPop),
|
||||
'$_days',
|
||||
formatPop(campaignRegistrationFeePop),
|
||||
),
|
||||
cancelLabel: context.l10n.commonKeepEditing,
|
||||
confirmLabel: context.l10n.campaignSubmit,
|
||||
);
|
||||
if (go != true || !mounted) return;
|
||||
if (!go || !mounted) return;
|
||||
|
||||
setState(() => _saving = true);
|
||||
final String id;
|
||||
@@ -511,18 +498,10 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
|
||||
}
|
||||
if (!mounted) return;
|
||||
// 올린 캠페인은 심사 전이라 목록에 보이지 않는다 — 어디 갔는지 알려 준다.
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.campaignPendingTitle),
|
||||
content: Text(context.l10n.campaignPendingBody),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(context.l10n.commonConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
await showAlertDialog(
|
||||
context,
|
||||
context.l10n.campaignPendingTitle,
|
||||
body: context.l10n.campaignPendingBody,
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
@@ -935,32 +937,19 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
|
||||
/// 철회 확인 — 등록비 반환 여부를 먼저 알린다(B-58 · 공개 전만 반환).
|
||||
Future<void> _withdraw(CampaignCardData card) async {
|
||||
final beforePublish = card.status == CampaignStatus.review;
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.campaignWithdrawTitle),
|
||||
content: Text(
|
||||
beforePublish
|
||||
final go = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.campaignWithdrawTitle,
|
||||
confirmLabel: context.l10n.campaignWithdrawConfirm,
|
||||
body: beforePublish
|
||||
? context.l10n.campaignWithdrawRefund(
|
||||
formatPop(campaignRegistrationFeePop),
|
||||
)
|
||||
: context.l10n.campaignWithdrawNoRefund(
|
||||
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 {
|
||||
await widget.repository.withdraw(card.id);
|
||||
} on Object catch (error) {
|
||||
|
||||
@@ -65,7 +65,7 @@ class EclipseState {
|
||||
/// 방어 마감. 없으면 남은 시간을 띄우지 않는다.
|
||||
final DateTime? endsAt;
|
||||
|
||||
/// 실데이터가 아닌 경우의 사유. 화면이 그대로 배지로 띄운다(대조표 3-5).
|
||||
/// 실데이터가 아닌 경우의 사유. 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
|
||||
final String? mockNote;
|
||||
|
||||
bool get isMock => mockNote != null;
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/live_stream.dart';
|
||||
import '../../home/home_screen.dart' show formatPop;
|
||||
import '../../planet/fpi_ranking_screen.dart' show MockBadge;
|
||||
import '../eclipse_data.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
@@ -136,9 +135,6 @@ class EclipseBannerCard extends StatelessWidget {
|
||||
),
|
||||
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/theme/tokens.dart';
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../notification/notification_screen.dart';
|
||||
import '../eclipse/eclipse_data.dart';
|
||||
@@ -25,6 +26,15 @@ import '../../core/i18n/app_locale.dart';
|
||||
/// 궤도 맵([GalaxyScreen])은 시안의 갤럭시맵 명세(HUD · 3D 갤럭시)를 구현한 화면이라
|
||||
/// 그대로 두고, 카드나 우측 상단 목록 아이콘에서 들어간다.
|
||||
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({
|
||||
super.key,
|
||||
this.watchPlanet = noPlanetWatch,
|
||||
@@ -247,7 +257,7 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
Expanded(
|
||||
child: _PlanetCard(
|
||||
planet: p,
|
||||
image: _imageFor(i),
|
||||
image: GalaxyHomeScreen.cardImageOf(p, i),
|
||||
selected: p.isMine,
|
||||
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 테두리와
|
||||
|
||||
@@ -31,7 +31,8 @@ import '../../core/i18n/app_locale.dart';
|
||||
/// 화면 대조 원장 F2 의 2번. **09-03 GALAXY 2.5D 1차 시연 대상**이다.
|
||||
///
|
||||
/// 명세대로 갖춘 것
|
||||
/// - 3d갤럭시 브라우징 줌인·줌아웃, 터치 이동 → [InteractiveViewer]
|
||||
/// - 3d갤럭시 브라우징 줌인·줌아웃 → [InteractiveViewer] 핀치. 터치 이동은 행성 탭 중심
|
||||
/// 전환이 맡고, 한 손가락 끌기는 궤도 회전이다(맵 팬 없음 — 장면이 화면 밖으로 밀리지 않게)
|
||||
/// - 내 행성 한가운데, 행성 터치 시 화면 중심 전환 이동
|
||||
/// - HUD 에 Rank · FPI 총점 · 접속자수, 터치 시 FPI 상세로
|
||||
/// - GALAXY 텍스트 좌측 하단 · 돋보기 우측 하단 → 검색 화면
|
||||
@@ -40,8 +41,7 @@ import '../../core/i18n/app_locale.dart';
|
||||
///
|
||||
/// 2026-09-09 검토회신서 3절이 지적한 것과 그 답
|
||||
/// - *드래그·회전에 관성과 감속이 없다* → 궤도 각속도를 [GalaxyClock] 이 물리로
|
||||
/// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다. 이동(팬)의
|
||||
/// 관성은 [InteractiveViewer] 가 이미 가진 플링을 그대로 쓴다.
|
||||
/// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다.
|
||||
/// - *행성 간 이동감이 없다* → 중심 전환이 순간이동이 아니라
|
||||
/// [GalaxyMotion.recenter] 동안 감속하며 날아간다. 목표 좌표는 매 프레임 다시
|
||||
/// 잡으므로 도는 행성도 정확히 잡는다.
|
||||
@@ -82,6 +82,10 @@ class _GalaxyScreenState extends State<GalaxyScreen>
|
||||
late Future<GalaxySnapshot> _snapshot;
|
||||
|
||||
final _clock = GalaxyClock();
|
||||
|
||||
@visibleForTesting
|
||||
GalaxyClock get clockForTest => _clock;
|
||||
|
||||
final _view = TransformationController();
|
||||
late final Ticker _ticker;
|
||||
|
||||
@@ -394,9 +398,12 @@ class _Map extends StatelessWidget {
|
||||
minScale: GalaxyMotion.minZoom,
|
||||
maxScale: GalaxyMotion.maxZoom,
|
||||
boundaryMargin: const EdgeInsets.all(240),
|
||||
// 이동(팬)의 관성은 InteractiveViewer 의 플링이 이미 준다.
|
||||
// 여기서 더하는 것은 **회전 관성**이다 — 가로로 끌면 궤도가 따라
|
||||
// 돌고, 손을 떼면 마찰이 기본 표류로 되돌린다.
|
||||
// 한 손가락 끌기는 맵을 옮기지 않고 **궤도를 돌린다**(프로토타입 View
|
||||
// Rotation · 별첨 1 ①-3 드래그 360° 회전). 팬을 켜 두면 가로로 끌 때
|
||||
// 장면이 통째로 밀려 화면 밖으로 사라진다(10/4 실기기). 손을 떼면
|
||||
// [GalaxyClock] 마찰이 기본 표류로 되돌린다(관성 · 체크리스트 3-2).
|
||||
// 핀치 줌과 행성 탭 중심 전환은 그대로다.
|
||||
panEnabled: false,
|
||||
onInteractionStart: (_) => onGrab(),
|
||||
// 세로 드래그·핀치 줌까지 넘기면 각속도를 덮어써 궤도가 선다.
|
||||
onInteractionUpdate: (d) => onDrag(
|
||||
|
||||
@@ -19,6 +19,8 @@ import '../../../core/i18n/app_locale.dart';
|
||||
/// - **게이지 조절 8개는 넣지 않는다(C-51).**
|
||||
/// - 색은 테마 결정 전까지 프로토타입 값을 쓴다(C-49).
|
||||
/// - 데이터는 받은 [GalaxySnapshot] 만 쓴다. 프로토타입의 표본 5행성은 쓰지 않는다.
|
||||
/// - 궤도는 FPI 순위, 첫 화면 시점은 내 행성 중앙(C-103). 140pt 에서는 내 행성 간략 카드
|
||||
/// 한 장만, 다른 행성 HUD 없음(C-105). 드래그 회전은 관성 없음(C-105).
|
||||
///
|
||||
/// 반응: 스냅샷이 바뀌면(순위·FPI) 행성이 새 궤도로 미끄러지고 HUD 숫자가 바뀐다(프로토타입
|
||||
/// `updateFPI`). 가로로 끌면 장면이 돈다(±180°, 프로토타입 View Rotation 범위). 누르면 [onOpen].
|
||||
@@ -33,7 +35,7 @@ class GalaxyMiniMap extends StatefulWidget {
|
||||
final GalaxySnapshot data;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
/// 정사각 한 변. HOME 행성 영역 우측 상단(Q-DS-06 권고안 A · 회신 전 임시).
|
||||
/// 정사각 한 변. HOME 은 약 140pt(C-105 · 자리는 C-113 → `HomeHero.miniMapAlignment`).
|
||||
final double size;
|
||||
|
||||
@override
|
||||
@@ -172,6 +174,10 @@ final _tanHalfFov = math.tan(35 * math.pi / 180);
|
||||
const _hudW = 512.0;
|
||||
const _hudH = 256.0;
|
||||
|
||||
/// 별·위성 점 크기(논리 px). 프로토타입 점은 원근으로 1px 아래로 줄어 GL 최소 크기(버퍼
|
||||
/// 1px, 픽셀비 2 → 0.5pt)로 찍힌다. 0 이면 1 물리 px 이라 3x 기기에서 1/3 로 옅어진다.
|
||||
const _dot = 0.4;
|
||||
|
||||
/// 1 논리 픽셀보다 가는 선은 머리카락 선(1 물리 픽셀)으로.
|
||||
double _px(double w) => w < 1 ? 0 : w;
|
||||
|
||||
@@ -293,18 +299,22 @@ ui.Picture _hudPicture(PlanetNode p, Color color, double share) {
|
||||
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) {
|
||||
final rec = ui.PictureRecorder();
|
||||
final canvas = Canvas(rec);
|
||||
canvas.drawCircle(
|
||||
Offset.zero,
|
||||
3,
|
||||
_auraR,
|
||||
Paint()
|
||||
..blendMode = BlendMode.plus
|
||||
..shader = ui.Gradient.radial(
|
||||
Offset.zero,
|
||||
3,
|
||||
_auraR,
|
||||
[
|
||||
c.withValues(alpha: 0.4),
|
||||
c.withValues(alpha: 0.15),
|
||||
@@ -360,19 +370,22 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
/// 그리는 행성 수(내 행성은 순위 밖이어도 더한다).
|
||||
static const maxPlanets = 10;
|
||||
|
||||
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비).
|
||||
static const hudFloor = 0.7;
|
||||
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비). 넘치면 카드를 통째로 줄인다(숫자는 자르지 않는다).
|
||||
static const hudFloor = 0.55;
|
||||
|
||||
/// 이 폭 이상이면 프로토타입 HUD 카드를 모든 행성에 그대로 그린다.
|
||||
static const fullHudMin = 280.0;
|
||||
|
||||
/// 간략 카드 글자 크기(pt) — 이름 · 둘째 줄.
|
||||
static const compactNamePt = 11.0;
|
||||
static const compactLinePt = 9.0;
|
||||
/// 간략 카드 글자 크기(pt) — 이름 · 숫자 줄. 프로토타입 텍스처의 줄 구성(이름 / RANK · FPI /
|
||||
/// LIVE)을 세 줄로 줄인 것이다. 140pt 에서 카드가 장면을 덮지 않게 한 줄을 짧게 둔다.
|
||||
static const compactNamePt = 9.0;
|
||||
static const compactLinePt = 8.0;
|
||||
|
||||
/// 간략 카드 둘째 줄 — `#순위 · FPI n · LIVE n`.
|
||||
static String compactLine(PlanetNode p) =>
|
||||
'#${p.rank} · FPI ${formatPop(p.fpi)} · LIVE ${formatPop(p.online)}';
|
||||
/// 간략 카드 숫자 줄 — `#순위 · FPI n` / `LIVE n`.
|
||||
static List<String> compactLines(PlanetNode p) => [
|
||||
'#${p.rank} · FPI ${formatPop(p.fpi)}',
|
||||
'LIVE ${formatPop(p.online)}',
|
||||
];
|
||||
|
||||
ui.Picture? _compact;
|
||||
Object? _compactKey;
|
||||
@@ -388,42 +401,49 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
if (key == _compactKey && _compact != null) return _compact!;
|
||||
_compact?.dispose();
|
||||
_compactKey = key;
|
||||
const pad = 6.0, barW = 3.0, gap = 4.0;
|
||||
final textMax = maxW - pad * 2 - barW - gap;
|
||||
TextPainter line(String text, double pt, FontWeight weight, Color c) =>
|
||||
TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: pt,
|
||||
fontWeight: weight,
|
||||
height: 1.2,
|
||||
color: c,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 2,
|
||||
ellipsis: '…',
|
||||
)..layout(maxWidth: textMax);
|
||||
final name = line(
|
||||
b.node.name.toUpperCase(),
|
||||
compactNamePt,
|
||||
FontWeight.w800,
|
||||
b.color,
|
||||
);
|
||||
// 좁으면 LIVE 앞에서 둘째 줄이 한 번 꺾인다(글자 크기는 줄이지 않는다).
|
||||
final stats = line(
|
||||
compactLine(b.node),
|
||||
compactLinePt,
|
||||
FontWeight.w700,
|
||||
const Color(0xE6FFFFFF),
|
||||
);
|
||||
final textW = math.max(name.width, stats.width);
|
||||
const pad = 4.0, barW = 2.0, gap = 3.0;
|
||||
TextPainter line(
|
||||
String text,
|
||||
double pt,
|
||||
FontWeight weight,
|
||||
Color c, [
|
||||
double maxWidth = double.infinity,
|
||||
]) => TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: pt,
|
||||
fontWeight: weight,
|
||||
height: 1.15,
|
||||
color: c,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
ellipsis: '…',
|
||||
)..layout(maxWidth: maxWidth);
|
||||
final texts = [
|
||||
(b.node.name.toUpperCase(), compactNamePt, FontWeight.w800, b.color),
|
||||
for (final t in compactLines(b.node))
|
||||
(t, compactLinePt, FontWeight.w700, const Color(0xE6FFFFFF)),
|
||||
];
|
||||
final stats = [
|
||||
for (final (t, pt, w, c) in texts.skip(1)) line(t, pt, w, c),
|
||||
];
|
||||
final statsW = stats.fold<double>(0, (m, l) => math.max(m, l.width));
|
||||
// 긴 이름은 말줄임 — 카드 폭은 숫자 줄(또는 최대 폭)이 정한다.
|
||||
final (nt, npt, nw, nc) = texts.first;
|
||||
final nameMax = math.max(statsW, maxW - pad * 2 - gap - barW);
|
||||
final lines = [line(nt, npt, nw, nc, nameMax), ...stats];
|
||||
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 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 canvas = Canvas(rec);
|
||||
final canvas = Canvas(rec)..scale(k);
|
||||
final box = Rect.fromLTWH(0, 0, w, h);
|
||||
canvas.drawRect(box, Paint()..color = const Color(0xE6000514));
|
||||
canvas.drawRect(
|
||||
@@ -433,8 +453,13 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
..strokeWidth = 1
|
||||
..color = b.color.withValues(alpha: 0.8),
|
||||
);
|
||||
name.paint(canvas, const Offset(pad, pad));
|
||||
stats.paint(canvas, Offset(pad, pad + name.height + 2));
|
||||
var y = pad;
|
||||
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;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(
|
||||
@@ -445,13 +470,8 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
),
|
||||
Paint()..color = _emerald,
|
||||
);
|
||||
compactText = [
|
||||
(b.node.name.toUpperCase(), compactNamePt),
|
||||
(compactLine(b.node), compactLinePt),
|
||||
];
|
||||
name.dispose();
|
||||
stats.dispose();
|
||||
_compactSize = Size(w, h);
|
||||
compactText = [for (final (t, pt, _, _) in texts) (t, pt * k)];
|
||||
_compactSize = Size(w * k, h * k);
|
||||
return _compact = rec.endRecording();
|
||||
}
|
||||
|
||||
@@ -466,6 +486,15 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
@visibleForTesting
|
||||
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.
|
||||
@visibleForTesting
|
||||
double? orbitOf(String id) => _bodies[id]?.rad;
|
||||
@@ -607,13 +636,14 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
..shader = ui.Gradient.radial(
|
||||
center,
|
||||
r,
|
||||
const [Color(0x0A10B981), Color(0x033B82F6), Color(0x00000000)],
|
||||
const [Color(0x0410B981), Color(0x013B82F6), Color(0x00000000)],
|
||||
const [0, 0.4, 0.8],
|
||||
),
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
// 프로토타입 별 8000개. 화면 넓이당 밀도는 390×844 화면 기준으로 맞춘다.
|
||||
// 프로토타입 별 8000개. 미니맵은 휴대폰 전체 화면 장면의 축소판으로 보이게, 화면 넓이당
|
||||
// 밀도를 390×844 기준으로 맞춘다(덜어내지 않으면 140pt 에 별이 5배 빽빽하다).
|
||||
final keep = (size.width * size.height / (390 * 844)).clamp(0.0, 1.0);
|
||||
final pts = <double>[];
|
||||
for (var i = 0; i < 8000; i++) {
|
||||
@@ -632,7 +662,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
ui.PointMode.points,
|
||||
Float32List.fromList(pts),
|
||||
Paint()
|
||||
..strokeWidth = 0
|
||||
..strokeWidth = _dot
|
||||
..color = const Color(0xCCBFBFFF),
|
||||
);
|
||||
_bg = rec.endRecording();
|
||||
@@ -708,11 +738,9 @@ class GalaxyMiniMapPainter extends CustomPainter {
|
||||
}
|
||||
if (out.isNotEmpty) {
|
||||
_fill
|
||||
..strokeWidth = 0
|
||||
..strokeWidth = _dot
|
||||
..blendMode = BlendMode.plus
|
||||
..color = b.color.withValues(
|
||||
alpha: 0.9 * (2 * _scale * focal / d).clamp(0.2, 1.0),
|
||||
);
|
||||
..color = b.color.withValues(alpha: 0.9);
|
||||
canvas.drawRawPoints(ui.PointMode.points, out, _fill);
|
||||
_fill.blendMode = BlendMode.srcOver;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../core/data/invite_code.dart';
|
||||
import '../../core/data/server_clock.dart';
|
||||
import 'home_data.dart';
|
||||
import '../quest/quest_data.dart' show QuestType;
|
||||
import '../quest/quest_repository_firestore.dart' show questToday;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 홈의 실 서버 구현 — 화면 대조 원장 F1.
|
||||
@@ -137,10 +138,13 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
.collection('heroGiftProducts')
|
||||
.where('active', isEqualTo: true)
|
||||
.get();
|
||||
// 인기 K-INSIDE — 기능명세서 v1.4 §2.114-7 「최대 3개 소팅(미해결 + 고액 포인트 순)」.
|
||||
// 미해결(`open`) 먼저, 그 안에서 현상금 큰 순. 정렬은 아래 [popularInsides].
|
||||
// ponytail: 현상금 상위 20건 안에서만 고른다. (status, bountyPop) 색인을 배포하면 질의로 옮긴다.
|
||||
final insidesF = db
|
||||
.collection('questions')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.limit(3)
|
||||
.orderBy('bountyPop', descending: true)
|
||||
.limit(20)
|
||||
.get();
|
||||
|
||||
final wallet = (await walletF).data() ?? const <String, dynamic>{};
|
||||
@@ -167,7 +171,9 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
final campaigns = (await campaignF).docs
|
||||
.where((d) => !blocked.contains(d.data()['creatorUid']))
|
||||
.toList();
|
||||
final insides = (await insidesF).docs;
|
||||
final insides = popularInsides([
|
||||
for (final doc in (await insidesF).docs) doc.data(),
|
||||
]);
|
||||
|
||||
final progressById = {for (final doc in progress) doc.id: doc.data()};
|
||||
final policy = (await policyF).data() ?? const <String, dynamic>{};
|
||||
@@ -185,7 +191,7 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
],
|
||||
briefings: [for (final doc in briefings) ?_briefing(doc.data())],
|
||||
campaign: campaigns.isEmpty ? null : _campaign(campaigns.first.data()),
|
||||
insides: [for (final doc in insides) _inside(doc.data())],
|
||||
insides: [for (final data in insides) _inside(data)],
|
||||
items: [for (final doc in (await itemsF).docs) _item(doc.id, doc.data())],
|
||||
);
|
||||
}
|
||||
@@ -284,7 +290,8 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
members: validInt(fandom['memberCount']),
|
||||
requiredMembers: targets?.members,
|
||||
// Q-PN-01 A안 — 탄생은 창립자만.
|
||||
isFounder: fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
|
||||
isFounder:
|
||||
fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
|
||||
requiredSfpi: validNumber(next?['requiredSfpi']),
|
||||
nebulaGoalPassed: goalPassed,
|
||||
);
|
||||
@@ -296,9 +303,10 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
Map<String, dynamic>? progress,
|
||||
Map<String, dynamic> policy,
|
||||
) {
|
||||
final done =
|
||||
(progress?.intOr('count') ?? 0) >= quest.intOr('dailyLimit', 1);
|
||||
final started = (progress?.intOr('count') ?? 0) > 0;
|
||||
// 퀘스트 목록 · 상세와 같은 판정(`questToday`) — 오늘(KST) 행만 센다.
|
||||
final (:count, :done) =
|
||||
questToday(QuestType.parse(quest.strOr('type')), quest, progress, policy);
|
||||
final started = count > 0;
|
||||
return QuestSummary(
|
||||
title: quest.strOr('title'),
|
||||
rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
|
||||
@@ -354,6 +362,15 @@ String _timeAgo(DateTime at) {
|
||||
return appL10n.timeDaysAgo('${gap.inDays}');
|
||||
}
|
||||
|
||||
/// 인기 K-INSIDE 3건 — 미해결(`status == open`) 먼저, 같은 묶음 안에서는 현상금 큰 순.
|
||||
/// 입력이 현상금 내림차순이라 미해결 묶음만 앞으로 옮기면 된다.
|
||||
List<Map<String, dynamic>> popularInsides(
|
||||
List<Map<String, dynamic>> byBounty,
|
||||
) => [
|
||||
...byBounty.where((q) => q['status'] == 'open'),
|
||||
...byBounty.where((q) => q['status'] != 'open'),
|
||||
].take(3).toList();
|
||||
|
||||
/// 오늘 날짜(KST `YYYY-MM-DD`) — 서버 `briefings.dayKey` 와 같은 규약이다.
|
||||
///
|
||||
/// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이
|
||||
|
||||
@@ -31,6 +31,7 @@ import 'widgets/item_sheet.dart';
|
||||
import 'widgets/inside_card.dart';
|
||||
import 'widgets/live_ticker.dart';
|
||||
import '../campaign/campaign_data.dart' show CampaignRepository;
|
||||
import '../campaign/campaign_create_screen.dart';
|
||||
import '../campaign/campaign_list_screen.dart';
|
||||
import '../notification/notification_screen.dart';
|
||||
import '../quest/quest_data.dart' show QuestRepository;
|
||||
@@ -150,19 +151,22 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
debugPrint('[HOME] load failed: ${snap.error}');
|
||||
return SafeArea(child: _LoadFailed(onRetry: _refresh));
|
||||
}
|
||||
return _HomeBody(
|
||||
data: snap.data!,
|
||||
galaxy: _galaxy,
|
||||
zap: _zap,
|
||||
repository: widget.repository,
|
||||
watchPlanet: widget.watchPlanet,
|
||||
planetRepository: widget.planetRepository,
|
||||
onRefresh: _refresh,
|
||||
onCreate: _createThenReload,
|
||||
questRepository: widget.questRepository,
|
||||
campaignRepository: widget.campaignRepository,
|
||||
eclipse: widget.eclipse,
|
||||
notifications: widget.notifications,
|
||||
return _StatusBarScrim(
|
||||
height: MediaQuery.paddingOf(context).top,
|
||||
child: _HomeBody(
|
||||
data: snap.data!,
|
||||
galaxy: _galaxy,
|
||||
zap: _zap,
|
||||
repository: widget.repository,
|
||||
watchPlanet: widget.watchPlanet,
|
||||
planetRepository: widget.planetRepository,
|
||||
onRefresh: _refresh,
|
||||
onCreate: _createThenReload,
|
||||
questRepository: widget.questRepository,
|
||||
campaignRepository: widget.campaignRepository,
|
||||
eclipse: widget.eclipse,
|
||||
notifications: widget.notifications,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -352,7 +356,7 @@ class _HomeBody extends StatelessWidget {
|
||||
zap == null
|
||||
? null
|
||||
: GalaxyMiniMap(
|
||||
size: 140,
|
||||
size: HomeHero.miniMapSize,
|
||||
data: galaxyState.data!,
|
||||
onOpen: () =>
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
@@ -531,24 +535,39 @@ class _HomeBody extends StatelessWidget {
|
||||
const SizedBox(height: _sectionGap),
|
||||
],
|
||||
|
||||
if (data.campaign case final c?) ...[
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: SectionHeader(
|
||||
title: 'HOT ARCHIVE',
|
||||
onMore: () => _openCampaigns(context, campaignRepository),
|
||||
),
|
||||
// HOT ARCHIVE 는 모금 중 캠페인이 없어도 둔다 — 기능명세서 v1.4 §2.114-6
|
||||
// (`2097:16415`) 「더보기 버튼 - HOT ARCHIVE 화면 이동」에 숨김 규칙이 없고,
|
||||
// 캠페인 목록·개설로 가는 길이 이 구획뿐이다(공통 기준 5항).
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: SectionHeader(
|
||||
title: 'HOT ARCHIVE',
|
||||
onMore: () => _openCampaigns(context, campaignRepository),
|
||||
),
|
||||
const SizedBox(height: _headerGap),
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: CampaignCard(
|
||||
),
|
||||
const SizedBox(height: _headerGap),
|
||||
Padding(
|
||||
padding: _h,
|
||||
child: switch (data.campaign) {
|
||||
final c? => CampaignCard(
|
||||
campaign: c,
|
||||
onTap: () => _openCampaigns(context, campaignRepository),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _sectionGap),
|
||||
],
|
||||
// 0 건 — 「새 캠페인」(Figma `179:29123` 캠페인 개설)으로 바로 간다.
|
||||
null => Center(
|
||||
child: CampaignFab(
|
||||
label: context.l10n.campaignNew,
|
||||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
CampaignCreateScreen(repository: campaignRepository),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
const SizedBox(height: _sectionGap),
|
||||
|
||||
Padding(
|
||||
padding: _h,
|
||||
@@ -575,6 +594,51 @@ class _HomeBody extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 상태 표시줄 가림막 — 히어로는 상태 표시줄 뒤까지 배경을 깔아야 해서 목록에
|
||||
/// SafeArea 를 주지 않는다. 대신 세로로 스크롤되면 상태 표시줄 자리를 화면 배경색으로
|
||||
/// 덮어, 아래 구획 머리글(TODAY'S QUEST 등)이 시계·배터리와 겹쳐 그려지지 않게 한다.
|
||||
class _StatusBarScrim extends StatefulWidget {
|
||||
const _StatusBarScrim({required this.height, required this.child});
|
||||
|
||||
final double height;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_StatusBarScrim> createState() => _StatusBarScrimState();
|
||||
}
|
||||
|
||||
class _StatusBarScrimState extends State<_StatusBarScrim> {
|
||||
bool _scrolled = false;
|
||||
|
||||
bool _onScroll(ScrollNotification n) {
|
||||
// 퀘스트 카드의 가로 스크롤은 무시한다 — 바깥 세로 목록만 본다.
|
||||
if (n.depth != 0 || n.metrics.axis != Axis.vertical) return false;
|
||||
final scrolled = n.metrics.pixels > 0;
|
||||
if (scrolled != _scrolled) setState(() => _scrolled = scrolled);
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: widget.child,
|
||||
),
|
||||
if (_scrolled)
|
||||
Positioned(
|
||||
key: const ValueKey('home-status-scrim'),
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: widget.height,
|
||||
child: const ColoredBox(color: AppColors.cosmicVoid),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 상단 활동 POP 잔액. 정책 설계서 §P-02 — 화면 표기는 「활동/지원」이다.
|
||||
class _PopBalance extends StatelessWidget {
|
||||
const _PopBalance({this.value});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import 'home_data.dart';
|
||||
@@ -406,38 +407,3 @@ class _Target extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 취소 / 변경 두 갈래.
|
||||
Future<bool> showConfirmDialog(BuildContext context, String message) async {
|
||||
final r = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.commonChange),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return r ?? false;
|
||||
}
|
||||
|
||||
/// 확인 하나뿐인 알림.
|
||||
Future<void> showAlertDialog(BuildContext context, String message) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.commonConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/ui/planet_25d.dart' show planetColors;
|
||||
import 'home_data.dart';
|
||||
@@ -245,12 +246,12 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
||||
);
|
||||
|
||||
Future<void> _confirm() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierColor: const Color(0xB3000000),
|
||||
builder: (context) => const _ConfirmDialog(),
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.planetCreateConfirm,
|
||||
confirmLabel: context.l10n.commonCreate,
|
||||
);
|
||||
if (ok != true || !mounted || _busy) return;
|
||||
if (!ok || !mounted || _busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
await widget.repository.createPlanet(
|
||||
@@ -356,67 +357,3 @@ class _Button extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본 `Frame 1984080943` — 312 폭 #282828 r12 · 패딩 24/16 · 취소(흰 1 테두리) / 생성(분홍).
|
||||
class _ConfirmDialog extends StatelessWidget {
|
||||
const _ConfirmDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Dialog(
|
||||
backgroundColor: const Color(0xFF282828),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 31),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.planetCreateConfirm,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: Color(0xFFF8F8F8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
side: const BorderSide(color: AppColors.onCosmic),
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.commonCreate),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,10 +78,21 @@ class HomeHero extends StatefulWidget {
|
||||
/// ZAP 옵션 「아이템」 — 사본 「아이템 선택」 시트(`343:204868`).
|
||||
final VoidCallback? onItem;
|
||||
|
||||
/// GALAXY 미니맵 — 행성 영역 우측 상단(해석 확인 요청서 Q-DS-06 권고안 A · 회신 전 임시).
|
||||
/// 사본 홈에는 없는 요소라 행성 칸 모서리에만 얹고 문구·카드와 겹치지 않게 한다.
|
||||
/// GALAXY 미니맵 — 히어로 우측 상단 [miniMapTop] · 오른쪽 끝(여백 0).
|
||||
final Widget? miniMap;
|
||||
|
||||
/// 미니맵 자리(C-113 「최신 확정 HOME Figma 위치」). Figma `89rQgL1JEFlA8yxl9L77bS`
|
||||
/// 「홈」(`2097:16428`) 375pt 실측 — 헤더(y0~124) 바로 아래 히어로 본문 시작점, 인사 문구
|
||||
/// (「1,230 POP 기여로…」) 줄과 같은 높이, 오른쪽 끝에 붙음(여백 0).
|
||||
static const Alignment miniMapAlignment = Alignment.topRight;
|
||||
|
||||
/// 미니맵 위 변 — 상태 표시줄 아래 기준 사본 y(히어로 본문 `Frame` y124).
|
||||
static const double miniMapTop = 124;
|
||||
|
||||
/// 미니맵 한 변 — Figma 「홈」(`2097:16428`) 실측 약 93×90pt. C-113(위치·크기 = Figma)을
|
||||
/// C-105 「약 140pt」보다 우선한다(2026-10-05 대표 결정).
|
||||
static const double miniMapSize = 93;
|
||||
|
||||
/// 사본 기준 배경 높이(상태 표시줄 48 포함).
|
||||
static const double designHeight = 680;
|
||||
|
||||
@@ -191,8 +202,7 @@ class _HomeHeroState extends State<HomeHero> {
|
||||
glow: AppColors.neonViolet,
|
||||
),
|
||||
),
|
||||
if (widget.miniMap case final mini?)
|
||||
Positioned(top: 0, right: 0, child: mini),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -215,6 +225,13 @@ class _HomeHeroState extends State<HomeHero> {
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.miniMap case final mini?)
|
||||
Positioned(
|
||||
top: top + HomeHero.miniMapTop,
|
||||
right: 0,
|
||||
child: mini,
|
||||
),
|
||||
|
||||
if (widget.ticker case final ticker?)
|
||||
Positioned(top: top + 102, left: 0, right: 0, child: ticker),
|
||||
|
||||
|
||||
@@ -198,6 +198,9 @@ abstract interface class InsideRepository {
|
||||
|
||||
/// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다.
|
||||
Future<void> accept(String questionId, String answerId);
|
||||
|
||||
/// 내가 쓴 질문 — MY > 활동 > 인사이드. 최신순.
|
||||
Future<List<InsideQuestion>> mine();
|
||||
}
|
||||
|
||||
/// 현상금 Q&A 실패를 화면 문구로 — 서버 카탈로그 코드(`E-QNA-*` 등)로 가른다.
|
||||
@@ -312,6 +315,10 @@ class SampleInsideRepository implements InsideRepository {
|
||||
@override
|
||||
Future<int> balance() async => 12_400;
|
||||
|
||||
@override
|
||||
Future<List<InsideQuestion>> mine() async =>
|
||||
_sorted(InsideSort.latest).where((q) => q.isMine).toList();
|
||||
|
||||
@override
|
||||
Future<int> minBounty() async => 100;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
@@ -105,25 +107,12 @@ class _InsideDetailScreenState extends State<InsideDetailScreen> {
|
||||
a.id.isNotEmpty;
|
||||
|
||||
Future<void> _accept(InsideAnswer a) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialog) => AlertDialog(
|
||||
content: Text(
|
||||
context.l10n.insideAcceptConfirm('${question.bountyPop}'),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.insideAcceptConfirm('${question.bountyPop}'),
|
||||
confirmLabel: context.l10n.insideAccept,
|
||||
);
|
||||
if (ok != true || _busy || !mounted) return;
|
||||
if (!ok || _busy || !mounted) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
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
|
||||
Future<int> balance() async {
|
||||
final wallet =
|
||||
|
||||
@@ -115,8 +115,8 @@ class _AccountLinkScreenState extends State<AccountLinkScreen> {
|
||||
switch (outcome) {
|
||||
case AuthFailed(:final code, :final message):
|
||||
_toast('[$code] $message');
|
||||
case AuthNotConfigured(:final missing):
|
||||
_toast(context.l10n.settingsLinkNotReady(_name, missing.join(' · ')));
|
||||
case AuthNotConfigured():
|
||||
_toast(context.l10n.settingsLinkNotReady(_name));
|
||||
case AuthSucceeded() || AuthCancelled():
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -33,10 +33,18 @@ enum MyListKind {
|
||||
enum _MySort { latest, pop, solved, achieved }
|
||||
|
||||
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;
|
||||
|
||||
/// 내 인사이드의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
|
||||
final InsideRepository? insides;
|
||||
|
||||
/// 내 캠페인의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
|
||||
final CampaignRepository? campaigns;
|
||||
|
||||
@@ -47,44 +55,6 @@ class MyListsScreen extends StatefulWidget {
|
||||
class _MyListsScreenState extends State<MyListsScreen> {
|
||||
_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) {
|
||||
MyListKind.inside => const [_MySort.latest, _MySort.pop, _MySort.solved],
|
||||
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) {
|
||||
_MySort.pop => [
|
||||
..._insides,
|
||||
]..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
|
||||
_MySort.solved => _insides.where((q) => q.isSolved).toList(),
|
||||
_ => _insides,
|
||||
_MySort.pop => [...mine]
|
||||
..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
|
||||
_MySort.solved => mine.where((q) => q.isSolved).toList(),
|
||||
_ => mine,
|
||||
};
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../core/ui/app_icons.dart';
|
||||
import '../auth/auth.dart';
|
||||
import '../auth/auth_repository.dart';
|
||||
import '../campaign/campaign_data.dart' show CampaignRepository;
|
||||
import '../inside/inside_data.dart' show InsideRepository;
|
||||
import 'my_data.dart';
|
||||
import 'my_lists_screen.dart';
|
||||
import 'profile_edit_screen.dart';
|
||||
@@ -29,6 +30,7 @@ class MyScreen extends StatefulWidget {
|
||||
this.push,
|
||||
this.auth,
|
||||
this.campaigns,
|
||||
this.insides,
|
||||
});
|
||||
|
||||
final MyRepository repository;
|
||||
@@ -36,6 +38,9 @@ class MyScreen extends StatefulWidget {
|
||||
/// 「내 캠페인」이 읽는 저장소. null 이면 표본.
|
||||
final CampaignRepository? campaigns;
|
||||
|
||||
/// 「내 인사이드」가 읽는 저장소. null 이면 표본.
|
||||
final InsideRepository? insides;
|
||||
|
||||
/// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다.
|
||||
final AccountLinker? linker;
|
||||
|
||||
@@ -251,7 +256,10 @@ class _MyScreenState extends State<MyScreen> {
|
||||
_Row(
|
||||
label: context.l10n.myInside,
|
||||
onTap: () => _push(
|
||||
const MyListsScreen(kind: MyListKind.inside),
|
||||
MyListsScreen(
|
||||
kind: MyListKind.inside,
|
||||
insides: widget.insides,
|
||||
),
|
||||
),
|
||||
),
|
||||
_Row(
|
||||
@@ -314,16 +322,23 @@ class _MyScreenState extends State<MyScreen> {
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: ProfileCard(
|
||||
profile: profile,
|
||||
onShare: (target) {
|
||||
onShare: (target) async {
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(this.context);
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(this.context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.myShareLater(target)),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
try {
|
||||
if (!await shareProfile(profile, target)) return;
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.shareCopied),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.commonRequestFailed)));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/push/push_messaging.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../auth/auth.dart';
|
||||
import '../auth/auth_repository.dart';
|
||||
@@ -281,13 +282,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await _notice(context.l10n.settingsSameEmailOnly);
|
||||
case AuthFailed(:final code, :final message):
|
||||
_toast('[$code] $message');
|
||||
case AuthNotConfigured(:final missing):
|
||||
_toast(
|
||||
context.l10n.settingsLinkNotReady(
|
||||
_snsName(context, provider),
|
||||
missing.join(' · '),
|
||||
),
|
||||
);
|
||||
case AuthNotConfigured():
|
||||
_toast(context.l10n.settingsLinkNotReady(_snsName(context, provider)));
|
||||
case AuthSucceeded() || AuthCancelled():
|
||||
break;
|
||||
}
|
||||
@@ -344,19 +340,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
/// 안내 팝업(확인 하나) — 시안 `554:23103`.
|
||||
Future<void> _notice(String message) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _MyDialog(
|
||||
title: message,
|
||||
actions: [
|
||||
_DialogButton(
|
||||
label: context.l10n.commonConfirm,
|
||||
filled: true,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Future<void> _notice(String message) => showAlertDialog(context, message);
|
||||
|
||||
/// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`.
|
||||
Future<bool> _confirm({
|
||||
@@ -366,30 +350,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool withdraw = false,
|
||||
Future<void> Function()? onConfirm,
|
||||
}) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => _MyDialog(
|
||||
title: title,
|
||||
body: body,
|
||||
actions: [
|
||||
_DialogButton(
|
||||
label: context.l10n.commonCancel,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
_DialogButton(
|
||||
label: action,
|
||||
filled: true,
|
||||
onTap: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
title,
|
||||
body: body,
|
||||
confirmLabel: action,
|
||||
);
|
||||
if (ok == true && onConfirm != null) {
|
||||
await onConfirm();
|
||||
} else if (ok == true && withdraw && mounted) {
|
||||
_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.
|
||||
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`: 제목 + 닫기 · 언어 목록(고른 것에 분홍 체크) · 「선택」.
|
||||
class _LanguageSheet extends StatefulWidget {
|
||||
const _LanguageSheet({required this.current});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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/ui/app_icons.dart';
|
||||
import '../my_data.dart';
|
||||
import 'my_cards.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../campaign/campaign_share.dart' show shareHashtags;
|
||||
|
||||
/// 프로필 카드 오버레이 — 시안 `374:15738`(명세 `549:22058`).
|
||||
///
|
||||
@@ -17,11 +21,12 @@ class ProfileCard extends StatelessWidget {
|
||||
final void Function(String target) onShare;
|
||||
|
||||
/// 공유 대상과 시안 아이콘. 명세가 적은 순서 그대로다.
|
||||
/// 프로필 웹 주소가 아직 없어 마지막 칸은 「링크 복사」가 아니라 「문구 복사」다.
|
||||
static List<String> get shareTargets => [
|
||||
appL10n.shareInstagram,
|
||||
appL10n.shareTiktok,
|
||||
'X',
|
||||
appL10n.shareCopyLink,
|
||||
appL10n.shareCopyText,
|
||||
];
|
||||
static const _shareIcons = [
|
||||
'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(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
// 실데이터가 아니면 숨기지 않고 그대로 알린다.
|
||||
if (data.mockNote case final note?) ...[
|
||||
MockBadge(note: note),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
],
|
||||
if (data.mine case final me?) ...[
|
||||
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ class FpiRanking {
|
||||
final FpiRankRow? mine;
|
||||
|
||||
/// 실데이터가 아닌 경우의 사유. null 이면 실연동이다.
|
||||
/// 화면이 이 문구를 그대로 배지로 띄운다 — 숨기지 않는다.
|
||||
/// 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
|
||||
final String? mockNote;
|
||||
|
||||
bool get isMock => mockNote != null;
|
||||
@@ -279,8 +279,8 @@ class PlanetDetail {
|
||||
final int members;
|
||||
final List<LoungePost> posts;
|
||||
|
||||
/// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유. 화면이 그대로 배지로
|
||||
/// 띄운다 — 숨기지 않는다(대조표 3-5).
|
||||
/// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유.
|
||||
/// 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
|
||||
final String? mockNote;
|
||||
|
||||
/// 육각형 그래프 정규화 기준.
|
||||
|
||||
@@ -383,11 +383,6 @@ class _FpiTab extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
if (detail.mockNote case final note?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s3),
|
||||
child: MockBadge(note: note),
|
||||
),
|
||||
const _NebulaFpi(),
|
||||
],
|
||||
),
|
||||
@@ -400,12 +395,6 @@ class _FpiTab extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
// 실데이터가 아닌 자리가 남아 있으면 숨기지 않고 그대로 알린다(대조표 3-5).
|
||||
if (detail.mockNote case final note?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s3),
|
||||
child: MockBadge(note: note),
|
||||
),
|
||||
// 침식·방어 진행 — `fn-eclipse-aggregate` 의 집계 문서와 샤드를 구독한다.
|
||||
// TOTAL FPI 카드 위에 두는 이유는 침식이 곧 이 카드의 값을 깎기 때문이다
|
||||
// (원장 B-55 — FPI 감소는 Eclipse 가 담당한다).
|
||||
@@ -705,12 +694,17 @@ class _ScoreCard extends StatelessWidget {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// 순위가 없으면(집계 모집단 밖 · 0) 「전체 0위」 대신 「순위 집계 전」.
|
||||
Text(
|
||||
context.l10n.planetOverallRank('${score.rank}'),
|
||||
score.rank > 0
|
||||
? context.l10n.planetOverallRank('${score.rank}')
|
||||
: context.l10n.planetRankNone,
|
||||
style: _t(12, color),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
if (trend == 0)
|
||||
if (score.rank <= 0)
|
||||
const SizedBox.shrink()
|
||||
else if (trend == 0)
|
||||
Text(
|
||||
'-',
|
||||
style: _t(
|
||||
@@ -765,7 +759,7 @@ class _ScoreCard extends StatelessWidget {
|
||||
// 서버 산출값이 없으면 숫자를 짓지 않는다 — 「상위 100%」는
|
||||
// 최하위라는 사실 주장이 되어 버린다(대조표 3-5).
|
||||
score.myTopPercent == null
|
||||
? context.l10n.planetTallying
|
||||
? '—'
|
||||
: context.l10n.planetTopPercent('${score.myTopPercent}'),
|
||||
style: _t(10, AppColors.onCosmic, height: 1),
|
||||
),
|
||||
|
||||
@@ -176,8 +176,10 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
trend: daily.isEmpty
|
||||
? 0
|
||||
: axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)),
|
||||
// 없으면 null — 화면이 숫자 대신 「집계 준비 중」을 띄운다.
|
||||
myTopPercent: myTopOf(metric),
|
||||
// 없으면 null — 화면이 숫자 대신 「—」를 띄운다. 행성 값이 0 이면
|
||||
// 기여를 나눌 모수가 없으니 남아 있는 값(시드 등)도 쓰지 않는다.
|
||||
myTopPercent:
|
||||
axes.intOr(_axisKey(metric)) > 0 ? myTopOf(metric) : null,
|
||||
),
|
||||
],
|
||||
posts: posts,
|
||||
|
||||
@@ -95,7 +95,8 @@ class _AdBonusDialog extends StatelessWidget {
|
||||
child: Text(
|
||||
bonusPop == null
|
||||
? context.l10n.adBonusPlaceholder
|
||||
: '+${formatPop(bonusPop!)} POP',
|
||||
// 원장 C-116 — 운영값 기반 「+N 기여 POP」(증분은 기여 POP · C-122).
|
||||
: '+${formatPop(bonusPop!)} ${context.l10n.popKindContribution}',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -285,7 +286,8 @@ class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 실패해도 닫는다 — 오류는 [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 '../../core/ui/app_dialog.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/data/server_clock.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
@@ -401,12 +403,12 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
Future<void> _confirm() async {
|
||||
final pool = _paid ? _pool : 0;
|
||||
final fee = _catalog?.feeOf(pool) ?? 0;
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questCreateConfirm),
|
||||
content: Text(
|
||||
context.l10n.questCreateCost(
|
||||
final go = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questCreateConfirm,
|
||||
cancelLabel: context.l10n.commonKeepEditing,
|
||||
confirmLabel: context.l10n.questCreate,
|
||||
body: context.l10n.questCreateCost(
|
||||
_preview.title,
|
||||
formatPop(_intOf(_reward)),
|
||||
formatPop(_intOf(_participants)),
|
||||
@@ -414,20 +416,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
formatPop(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);
|
||||
final String id;
|
||||
|
||||
@@ -119,6 +119,7 @@ enum QuestType {
|
||||
///
|
||||
/// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다
|
||||
/// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다.
|
||||
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
|
||||
bool get verifiable => switch (this) {
|
||||
QuestType.attend ||
|
||||
QuestType.steps ||
|
||||
@@ -131,12 +132,8 @@ enum QuestType {
|
||||
};
|
||||
|
||||
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
|
||||
String? get blockedReason => switch (this) {
|
||||
QuestType.musicShake || QuestType.streaming =>
|
||||
'발주사 확인 중입니다 — 스밍·Music Shake 유형은 회신 대기 항목입니다(Q-QC-04).',
|
||||
QuestType.unknown => appL10n.questTypeUnverifiable,
|
||||
_ => null,
|
||||
};
|
||||
String? get blockedReason =>
|
||||
verifiable ? null : appL10n.questTypeUnverifiable;
|
||||
}
|
||||
|
||||
/// 서버가 판정하고 지급한 결과 — `fn-quest-*` 응답.
|
||||
@@ -507,6 +504,10 @@ abstract interface class QuestRepository {
|
||||
|
||||
Future<String> create(QuestDraft draft);
|
||||
|
||||
/// 보유 일반 POP(`wallet/main.activityBalance`) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다.
|
||||
/// 모르면 null(헤더는 받은 값을 유지한다).
|
||||
Future<int?> popBalance();
|
||||
|
||||
/// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다.
|
||||
///
|
||||
/// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률
|
||||
@@ -730,6 +731,9 @@ class SampleQuestRepository implements QuestRepository {
|
||||
@override
|
||||
Future<String> create(QuestDraft draft) async => 'q-new';
|
||||
|
||||
@override
|
||||
Future<int?> popBalance() async => null;
|
||||
|
||||
/// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌
|
||||
/// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다.
|
||||
@override
|
||||
|
||||
@@ -2,13 +2,14 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/data/server_clock.dart';
|
||||
import '../../core/monetization/rewarded_ad.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import '../planet/fpi_ranking_screen.dart' show MockBadge;
|
||||
import '../planet/moderation.dart';
|
||||
import 'quest_ad_screens.dart';
|
||||
import 'quest_create_screen.dart' show QuestLinkCard;
|
||||
@@ -135,9 +136,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
color: Color(0xFF2E2E2E),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: image == null
|
||||
? null
|
||||
: Image.asset(image, width: 200, height: 200),
|
||||
child: Image.asset(image, width: 200, height: 200),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -232,8 +231,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
),
|
||||
),
|
||||
// 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위.
|
||||
// 실데이터가 아닌 표시는 기존 관례대로 MockBadge 로 알린다.
|
||||
if (blocked != null) MockBadge(note: blocked),
|
||||
if (blocked != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Text(blocked, style: _strong),
|
||||
),
|
||||
if (submission != null) ..._submissionStatus(submission),
|
||||
if (detail.reviews.isNotEmpty) ..._reviewQueue(detail),
|
||||
],
|
||||
@@ -290,7 +292,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
),
|
||||
child: Text(
|
||||
blocked != null
|
||||
? '발주사 확인 중'
|
||||
? context.l10n.commonComingSoon
|
||||
: !open
|
||||
? context.l10n.questTimeAttackClosed
|
||||
: _busy
|
||||
@@ -388,34 +390,23 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
var resubmit = false;
|
||||
if (!approve) {
|
||||
final input = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialog) => AlertDialog(
|
||||
title: Text(context.l10n.questReject),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: input,
|
||||
decoration: InputDecoration(labelText: context.l10n.questRejectReason),
|
||||
),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: resubmit,
|
||||
onChanged: (v) => setDialog(() => resubmit = v ?? false),
|
||||
title: Text(context.l10n.questAllowResubmit),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questReject,
|
||||
confirmLabel: context.l10n.questReject,
|
||||
content: StatefulBuilder(
|
||||
builder: (context, setDialog) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: input,
|
||||
decoration: InputDecoration(labelText: context.l10n.questRejectReason),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(context.l10n.questReject),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: resubmit,
|
||||
onChanged: (v) => setDialog(() => resubmit = v ?? false),
|
||||
title: Text(context.l10n.questAllowResubmit),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -423,7 +414,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
);
|
||||
reason = input.text.trim();
|
||||
input.dispose();
|
||||
if (ok != true || reason.isEmpty) return;
|
||||
if (!ok || reason.isEmpty) return;
|
||||
}
|
||||
await _run(() => widget.repository.review(
|
||||
s.id,
|
||||
@@ -437,11 +428,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
Future<void> _submitProof(QuestDetail detail) async {
|
||||
final url = TextEditingController();
|
||||
final text = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questSubmit),
|
||||
content: Column(
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questSubmit,
|
||||
confirmLabel: context.l10n.commonSubmit,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
@@ -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 proofText = text.text.trim();
|
||||
url.dispose();
|
||||
text.dispose();
|
||||
if (ok != true || (proofUrl.isEmpty && proofText.isEmpty)) return;
|
||||
if (!ok || (proofUrl.isEmpty && proofText.isEmpty)) return;
|
||||
final done = await _run(
|
||||
() => widget.repository.submit(detail.card.id, url: proofUrl, text: proofText),
|
||||
);
|
||||
@@ -484,11 +464,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
Future<void> _edit(QuestDetail detail) async {
|
||||
final title = TextEditingController(text: detail.card.title);
|
||||
final description = TextEditingController(text: detail.description);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questEdit),
|
||||
content: Column(
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questEdit,
|
||||
confirmLabel: context.l10n.commonEdit,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.l10n.questEditNotice, style: _caption),
|
||||
@@ -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 newDescription = description.text.trim();
|
||||
@@ -619,24 +588,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
/// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·*
|
||||
/// *중단 버튼 퀘스트 진행 상태값 시작전으로 업데이트*
|
||||
Future<void> _confirmStop(QuestDetail detail) async {
|
||||
final stop = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.questStopConfirm),
|
||||
content: Text(context.l10n.questStopBody),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
final stop = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questStopConfirm,
|
||||
body: context.l10n.questStopBody,
|
||||
confirmLabel: context.l10n.questStop,
|
||||
);
|
||||
if (stop != true) return;
|
||||
if (!stop) return;
|
||||
_stopWatching();
|
||||
final next =
|
||||
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 를 확정했다
|
||||
/// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다.
|
||||
Future<void> _claim(QuestDetail detail) async {
|
||||
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건」.
|
||||
// 기본 보상을 먼저 받고 광고 증분을 뒤에 청구한다(원장 B-03 데이터 관계) —
|
||||
// 광고 쪽이 실패해도 기본 보상은 이미 지급되어 있다.
|
||||
setState(() => _busy = true);
|
||||
final QuestReward reward;
|
||||
int? adPop;
|
||||
BackendException? adError;
|
||||
try {
|
||||
final pending = () async {
|
||||
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;
|
||||
reward = await widget.repository.verify(detail.card);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
@@ -714,9 +647,30 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
await _load();
|
||||
return;
|
||||
}
|
||||
// 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
|
||||
final failed = adError;
|
||||
if (failed != null) _toast(failed.message);
|
||||
|
||||
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 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) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
|
||||
if (mounted) _toast(error.message);
|
||||
}
|
||||
if (!mounted) return;
|
||||
}
|
||||
}
|
||||
|
||||
await Navigator.of(context, rootNavigator: true).push<bool>(
|
||||
MaterialPageRoute(
|
||||
|
||||
@@ -63,13 +63,30 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 헤더 보유 POP. 생성(예치 · 수수료) · 보상 뒤 [_load] 가 지갑을 다시 읽어 맞춘다.
|
||||
late int _pop = widget.myPop;
|
||||
|
||||
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;
|
||||
setState(() {
|
||||
_cards = cards;
|
||||
_loading = false;
|
||||
_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),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
formatPop(widget.myPop),
|
||||
formatPop(_pop),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -93,10 +93,17 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
for (final doc in docs) {
|
||||
final quest = doc.data();
|
||||
if (!_open(quest, now, uid)) continue;
|
||||
final progress = await db
|
||||
.doc('users/$uid/questProgress/${doc.id}')
|
||||
.get()
|
||||
.then((s) => s.data() ?? const <String, dynamic>{});
|
||||
// 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다.
|
||||
// UGC 퀘스트 하나의 제출 건 조회가 거부되어 **모든 사용자의** 목록이 무한 로딩이었다(2026-10-04).
|
||||
Map<String, dynamic> progress;
|
||||
try {
|
||||
progress = await db
|
||||
.doc('users/$uid/questProgress/${doc.id}')
|
||||
.get()
|
||||
.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)));
|
||||
}
|
||||
return cards;
|
||||
@@ -145,17 +152,9 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
final type = QuestType.parse(quest.strOr('type'));
|
||||
final authorUid = quest.strOr('authorUid');
|
||||
final dailyLimit = quest.intOr('dailyLimit', 1);
|
||||
// 타임어택은 1인 1회라 날짜로 끊지 않는다 — 창이 자정을 넘어도 「보상 완료」로 남는다.
|
||||
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 (:count, :done) = questToday(type, quest, progress, policy);
|
||||
final tiers = _stepTiers(policy);
|
||||
final steps = type == QuestType.steps;
|
||||
final done = steps ? count >= tiers.length : count >= dailyLimit;
|
||||
final goal = steps
|
||||
? tiers[count.clamp(0, tiers.length - 1)]
|
||||
: 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`(다국어)이라고만 적었다.
|
||||
/// 문자열과 지도(map) 둘 다 들어올 수 있어 둘 다 받는다.
|
||||
@@ -282,9 +264,13 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
Future<QuestSubmission?> _submission(String questId, Map<String, dynamic> quest) async {
|
||||
if (quest.strOr('authorUid').isEmpty) return null;
|
||||
final id = '${questId}_${_backend.requireUid}';
|
||||
final snap = await _backend.db.doc('questSubmissions/$id').get();
|
||||
final data = snap.data();
|
||||
return data == null ? null : _submissionOf(id, data);
|
||||
try {
|
||||
final data = (await _backend.db.doc('questSubmissions/$id').get()).data();
|
||||
return data == null ? null : _submissionOf(id, data);
|
||||
} on Exception {
|
||||
// 규칙 배포 전(없는 문서 get 이 permission-denied) 등 — 제출 없음으로 본다.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 생성자의 검토 대기열. 규칙이 생성자 조건을 증명하도록 `questAuthorUid` 를 함께 건다.
|
||||
@@ -441,6 +427,12 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
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
|
||||
Stream<int> progressStream(QuestCard card) {
|
||||
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
||||
@@ -562,3 +554,47 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
/// 단말 시간대로 끊으면 해외 사용자의 화면과 서버 판정이 하루 어긋난다.
|
||||
String _dayKey(DateTime at) =>
|
||||
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` 상세).
|
||||
///
|
||||
/// ponytail: `quests` 문서(설계서 §2.8)에 이미지 필드가 없어 유형과 제목으로 고른다.
|
||||
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 빈 칸이다.
|
||||
String? questImage(QuestCard card) {
|
||||
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 기본 아이콘이다.
|
||||
String questImage(QuestCard card) {
|
||||
switch (card.type) {
|
||||
case QuestType.attend:
|
||||
return 'assets/figma/quest-img-stamp.png';
|
||||
@@ -27,8 +27,8 @@ String? questImage(QuestCard card) {
|
||||
if (title.contains('만보기') || title.contains('걷기')) {
|
||||
return 'assets/figma/quest-img-shoe.png';
|
||||
}
|
||||
if (title.contains('POP')) return 'assets/figma/quest-img-bolt.png';
|
||||
return null;
|
||||
// 유형 · 제목으로 못 고르면 기본 아이콘(번개) — 빈 칸은 고장처럼 보인다(2026-10-04 실기기 4-9).
|
||||
return 'assets/figma/quest-img-bolt.png';
|
||||
}
|
||||
|
||||
/// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지.
|
||||
@@ -39,7 +39,6 @@ class QuestIconBox extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = questImage(card);
|
||||
return Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
@@ -48,7 +47,7 @@ class QuestIconBox extends StatelessWidget {
|
||||
color: const Color(0xFF2E2E2E),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: image == null ? null : Image.asset(image, width: 42, height: 42),
|
||||
child: Image.asset(questImage(card), width: 42, height: 42),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"commonPrev": "Back",
|
||||
"commonDone": "Done",
|
||||
"commonStart": "Start",
|
||||
"commonNotYet": "The {where} screen arrives in the second build review (2026-09-10).",
|
||||
"commonNotYet": "The {where} screen is coming soon.",
|
||||
"languageSettingsTitle": "Language",
|
||||
"authLogin": "Log in",
|
||||
"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}}",
|
||||
"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",
|
||||
"authNotConfiguredBody": "{name} login opens once the client keys are provided.\n\nRequired\n· {items}",
|
||||
"authNotConfiguredNotice": "{name} login is not ready yet.\nRequired: {items}",
|
||||
"authNotConfiguredBody": "{name} login isn't available on this device yet.\nPlease use another login method.",
|
||||
"authNotConfiguredNotice": "{name} login isn't available on this device yet.",
|
||||
"authResetNeedEmail": "Enter the email address for the reset link first.",
|
||||
"authResetSent": "We sent reset instructions to that address.",
|
||||
"termsTitle": "Terms of use",
|
||||
@@ -46,7 +46,7 @@
|
||||
"myTabActivity": "Activity",
|
||||
"myInside": "Inside",
|
||||
"myCampaign": "Campaigns",
|
||||
"myShareLater": "{target} sharing will be connected in the 09-10 build.",
|
||||
"myShareText": "{nickname} · Lv.{level} · {pop} POP contributed",
|
||||
"myEditPhoto": "Edit profile photo",
|
||||
"myEditProfile": "Edit profile",
|
||||
"settingsTitle": "Settings",
|
||||
@@ -70,12 +70,12 @@
|
||||
"settingsWithdraw": "Delete account",
|
||||
"settingsWithdrawConfirm": "Do you want to delete your account?",
|
||||
"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?",
|
||||
"settingsUnlinkBody": "Only this social login method is removed. Your account data is not deleted.",
|
||||
"settingsUnlink": "Unlink",
|
||||
"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",
|
||||
"settingsDisconnect": "Unlink",
|
||||
"settingsEmailProvider": "Email",
|
||||
@@ -549,6 +549,7 @@
|
||||
"planetNoContributors": "No contributors yet",
|
||||
"planetContributeCta": "Contribute and climb the ranking!",
|
||||
"planetOverallRank": "Overall #{rank}",
|
||||
"planetRankNone": "Not ranked yet",
|
||||
"planetMyContribution": "My share",
|
||||
"planetTallying": "Tallying soon",
|
||||
"planetTopPercent": "Top {percent}%",
|
||||
@@ -567,7 +568,7 @@
|
||||
"questTypeSteps": "Walking",
|
||||
"questTypePlace": "Location",
|
||||
"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",
|
||||
"placePickPrompt": "Choose where to\ncomplete the quest.",
|
||||
"placeSearch": "Search places",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"commonPrev": "Atrás",
|
||||
"commonDone": "Listo",
|
||||
"commonStart": "Empezar",
|
||||
"commonNotYet": "La pantalla {where} llega en la segunda revisión (2026-09-10).",
|
||||
"commonNotYet": "La pantalla {where} llegará pronto.",
|
||||
"languageSettingsTitle": "Idioma",
|
||||
"authLogin": "Iniciar sesión",
|
||||
"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}}",
|
||||
"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",
|
||||
"authNotConfiguredBody": "El inicio con {name} se habilitará cuando se entreguen las claves.\n\nRequerido\n· {items}",
|
||||
"authNotConfiguredNotice": "El inicio con {name} aún no está listo.\nRequerido: {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á disponible en este dispositivo.",
|
||||
"authResetNeedEmail": "Primero introduce el correo para recibir el enlace.",
|
||||
"authResetSent": "Enviamos las instrucciones a esa dirección.",
|
||||
"termsTitle": "Términos de uso",
|
||||
@@ -46,7 +46,7 @@
|
||||
"myTabActivity": "Actividad",
|
||||
"myInside": "Inside",
|
||||
"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",
|
||||
"myEditProfile": "Editar perfil",
|
||||
"settingsTitle": "Ajustes",
|
||||
@@ -70,12 +70,12 @@
|
||||
"settingsWithdraw": "Eliminar cuenta",
|
||||
"settingsWithdrawConfirm": "¿Quieres eliminar tu cuenta?",
|
||||
"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?",
|
||||
"settingsUnlinkBody": "Solo se quita este método de inicio social; los datos de tu cuenta no se eliminan.",
|
||||
"settingsUnlink": "Desvincular",
|
||||
"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",
|
||||
"settingsDisconnect": "Desvincular",
|
||||
"settingsEmailProvider": "Correo",
|
||||
@@ -549,6 +549,7 @@
|
||||
"planetNoContributors": "Aún no hay contribuyentes",
|
||||
"planetContributeCta": "¡Contribuye y entra en el ranking!",
|
||||
"planetOverallRank": "Puesto general {rank}",
|
||||
"planetRankNone": "Sin clasificar aún",
|
||||
"planetMyContribution": "Mi aporte",
|
||||
"planetTallying": "Cálculo pendiente",
|
||||
"planetTopPercent": "Top {percent}%",
|
||||
@@ -567,7 +568,7 @@
|
||||
"questTypeSteps": "Caminar",
|
||||
"questTypePlace": "Ubicación",
|
||||
"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",
|
||||
"placePickPrompt": "Elige dónde\ncompletar la misión.",
|
||||
"placeSearch": "Buscar lugar",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"commonPrev": "戻る",
|
||||
"commonDone": "完了",
|
||||
"commonStart": "はじめる",
|
||||
"commonNotYet": "{where}画面は第2次実装確認(2026-09-10)で追加されます。",
|
||||
"commonNotYet": "{where}画面は準備中です。",
|
||||
"languageSettingsTitle": "言語設定",
|
||||
"authLogin": "ログイン",
|
||||
"authSignup": "新規登録",
|
||||
@@ -20,8 +20,8 @@
|
||||
"authProviderLogin": "{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}ログイン準備中",
|
||||
"authNotConfiguredBody": "{name}ログインは発注元のキー提供後に利用できます。\n\n必要な項目\n· {items}",
|
||||
"authNotConfiguredNotice": "{name}ログインはまだ準備中です。\n必要な項目: {items}",
|
||||
"authNotConfiguredBody": "この端末では{name}ログインをまだご利用いただけません。\n他のログイン方法をご利用ください。",
|
||||
"authNotConfiguredNotice": "この端末では{name}ログインをまだご利用いただけません。",
|
||||
"authResetNeedEmail": "再設定メールを受け取るメールアドレスを先に入力してください。",
|
||||
"authResetSent": "入力されたアドレスに再設定の案内を送信しました。",
|
||||
"termsTitle": "利用規約",
|
||||
@@ -46,7 +46,7 @@
|
||||
"myTabActivity": "アクティビティ",
|
||||
"myInside": "インサイド",
|
||||
"myCampaign": "キャンペーン",
|
||||
"myShareLater": "{target}への共有は09-10回で接続します。",
|
||||
"myShareText": "{nickname} · Lv.{level} · 累計貢献 {pop} POP",
|
||||
"myEditPhoto": "プロフィール写真を編集",
|
||||
"myEditProfile": "プロフィールを編集",
|
||||
"settingsTitle": "設定",
|
||||
@@ -70,12 +70,12 @@
|
||||
"settingsWithdraw": "退会",
|
||||
"settingsWithdrawConfirm": "退会しますか?",
|
||||
"settingsWithdrawBody": "保有しているPOPは消滅し、元に戻せません。",
|
||||
"settingsWithdrawLater": "退会処理は09-10回で接続します。",
|
||||
"settingsWithdrawLater": "退会機能は準備中です。",
|
||||
"settingsUnlinkConfirm": "連携中のアカウントを解除しますか?",
|
||||
"settingsUnlinkBody": "連携したSNSのログイン手段のみ削除され、アカウント情報は削除されません。",
|
||||
"settingsUnlink": "連携解除",
|
||||
"settingsSameEmailOnly": "アカウントのメールと同じ\nソーシャルアカウントのみ連携できます。",
|
||||
"settingsLinkNotReady": "{name}連携はまだ準備中です。必要な項目: {items}",
|
||||
"settingsLinkNotReady": "この端末では{name}連携をまだご利用いただけません。",
|
||||
"settingsConnect": "連携",
|
||||
"settingsDisconnect": "解除",
|
||||
"settingsEmailProvider": "メール",
|
||||
@@ -549,6 +549,7 @@
|
||||
"planetNoContributors": "まだ貢献者がいません",
|
||||
"planetContributeCta": "貢献してランキングに入ろう!",
|
||||
"planetOverallRank": "全体{rank}位",
|
||||
"planetRankNone": "順位集計前",
|
||||
"planetMyContribution": "自分の貢献度",
|
||||
"planetTallying": "集計準備中",
|
||||
"planetTopPercent": "上位{percent}%",
|
||||
@@ -567,7 +568,7 @@
|
||||
"questTypeSteps": "ウォーキング",
|
||||
"questTypePlace": "特定の場所",
|
||||
"questTypeStreaming": "ストリーミング",
|
||||
"questTypeUnverifiable": "このクエストタイプの検証方法はまだ決まっていません。",
|
||||
"questTypeUnverifiable": "このクエストにはまだ参加できません。",
|
||||
"questTabMine": "マイクエスト",
|
||||
"placePickPrompt": "クエストを完了する\n場所を選択してください。",
|
||||
"placeSearch": "場所を検索",
|
||||
|
||||
+18
-20
@@ -9,7 +9,7 @@
|
||||
"commonPrev": "이전",
|
||||
"commonDone": "완료",
|
||||
"commonStart": "시작",
|
||||
"commonNotYet": "{where} 화면은 구현 확인물 2차(2026-09-10)에 들어갑니다.",
|
||||
"commonNotYet": "{where} 화면은 준비 중입니다.",
|
||||
"@commonNotYet": {
|
||||
"placeholders": {
|
||||
"where": {
|
||||
@@ -48,25 +48,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"authNotConfiguredBody": "{name} 로그인은 발주사 키가 들어오면 열립니다.\n\n필요한 항목\n· {items}",
|
||||
"authNotConfiguredBody": "이 기기에서는 {name} 로그인을 아직 이용할 수 없습니다.\n다른 로그인 방법을 이용해 주세요.",
|
||||
"@authNotConfiguredBody": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"items": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"authNotConfiguredNotice": "{name} 로그인은 아직 준비 중입니다.\n필요한 항목: {items}",
|
||||
"authNotConfiguredNotice": "이 기기에서는 {name} 로그인을 아직 이용할 수 없습니다.",
|
||||
"@authNotConfiguredNotice": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"items": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -94,10 +88,16 @@
|
||||
"myTabActivity": "활동",
|
||||
"myInside": "인사이드",
|
||||
"myCampaign": "캠페인",
|
||||
"myShareLater": "{target} 공유는 09-10 회차에 연결합니다.",
|
||||
"@myShareLater": {
|
||||
"myShareText": "{nickname} · Lv.{level} · 누적 기여 {pop} POP",
|
||||
"@myShareText": {
|
||||
"placeholders": {
|
||||
"target": {
|
||||
"nickname": {
|
||||
"type": "String"
|
||||
},
|
||||
"level": {
|
||||
"type": "String"
|
||||
},
|
||||
"pop": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
@@ -125,19 +125,16 @@
|
||||
"settingsWithdraw": "서비스 탈퇴",
|
||||
"settingsWithdrawConfirm": "서비스 탈퇴를 하시겠습니까?",
|
||||
"settingsWithdrawBody": "보유하신 POP 이 사라지고 되돌릴 수 없습니다.",
|
||||
"settingsWithdrawLater": "서비스 탈퇴 처리는 09-10 회차에 연결합니다.",
|
||||
"settingsWithdrawLater": "서비스 탈퇴 기능은 준비 중입니다.",
|
||||
"settingsUnlinkConfirm": "연결된 계정을 끊으시겠습니까?",
|
||||
"settingsUnlinkBody": "연결한 SNS 계정 로그인 수단만 제거되며, 계정 정보는 삭제되지 않습니다.",
|
||||
"settingsUnlink": "연결 해제",
|
||||
"settingsSameEmailOnly": "계정 이메일과 동일한 소셜 계정만\n연결 할 수 있습니다.",
|
||||
"settingsLinkNotReady": "{name} 연동은 아직 준비 중입니다. 필요한 항목: {items}",
|
||||
"settingsLinkNotReady": "이 기기에서는 {name} 연동을 아직 이용할 수 없습니다.",
|
||||
"@settingsLinkNotReady": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"items": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -563,7 +560,7 @@
|
||||
}
|
||||
},
|
||||
"nebulaHeadline": "우리 팬덤 행성 이름을 만들어요.",
|
||||
"nebulaBody": "창립맴버가 목표 POP을 {days}일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 맴버들에게는 기념 배지가 지급됩니다.",
|
||||
"nebulaBody": "창립멤버가 목표 POP을 {days}일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 멤버들에게는 기념 배지가 지급됩니다.",
|
||||
"@nebulaBody": {
|
||||
"placeholders": {
|
||||
"days": {
|
||||
@@ -574,7 +571,7 @@
|
||||
"nebulaNameTaken": "이미 사용중인 이름입니다.",
|
||||
"nebulaNameHint": "팬덤 이름",
|
||||
"nebulaGoal": "목표 ",
|
||||
"nebulaFounders": " / 창립맴버",
|
||||
"nebulaFounders": " / 창립멤버",
|
||||
"nebulaGoalSemantics": "목표 {pop} POP · 창립 멤버 {members}명 · {days}일 고정",
|
||||
"@nebulaGoalSemantics": {
|
||||
"placeholders": {
|
||||
@@ -1098,6 +1095,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"planetRankNone": "순위 집계 전",
|
||||
"planetMyContribution": "내 기여도",
|
||||
"planetTallying": "집계 준비 중",
|
||||
"planetTopPercent": "상위 {percent}%",
|
||||
@@ -1130,7 +1128,7 @@
|
||||
"questTypeSteps": "걷기",
|
||||
"questTypePlace": "특정 위치",
|
||||
"questTypeStreaming": "스트리밍",
|
||||
"questTypeUnverifiable": "이 퀘스트 유형의 검증 방식이 아직 정해지지 않았습니다.",
|
||||
"questTypeUnverifiable": "이 퀘스트는 아직 참여할 수 없습니다.",
|
||||
"questTabMine": "내 퀘스트",
|
||||
"placePickPrompt": "퀘스트를 완료할\n장소를 선택해주세요.",
|
||||
"placeSearch": "장소 검색",
|
||||
|
||||
@@ -161,7 +161,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @commonNotYet.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'{where} 화면은 구현 확인물 2차(2026-09-10)에 들어갑니다.'**
|
||||
/// **'{where} 화면은 준비 중입니다.'**
|
||||
String commonNotYet(String where);
|
||||
|
||||
/// No description provided for @languageSettingsTitle.
|
||||
@@ -227,14 +227,14 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @authNotConfiguredBody.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'{name} 로그인은 발주사 키가 들어오면 열립니다.\n\n필요한 항목\n· {items}'**
|
||||
String authNotConfiguredBody(String name, String items);
|
||||
/// **'이 기기에서는 {name} 로그인을 아직 이용할 수 없습니다.\n다른 로그인 방법을 이용해 주세요.'**
|
||||
String authNotConfiguredBody(String name);
|
||||
|
||||
/// No description provided for @authNotConfiguredNotice.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'{name} 로그인은 아직 준비 중입니다.\n필요한 항목: {items}'**
|
||||
String authNotConfiguredNotice(String name, String items);
|
||||
/// **'이 기기에서는 {name} 로그인을 아직 이용할 수 없습니다.'**
|
||||
String authNotConfiguredNotice(String name);
|
||||
|
||||
/// No description provided for @authResetNeedEmail.
|
||||
///
|
||||
@@ -380,11 +380,11 @@ abstract class AppLocalizations {
|
||||
/// **'캠페인'**
|
||||
String get myCampaign;
|
||||
|
||||
/// No description provided for @myShareLater.
|
||||
/// No description provided for @myShareText.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'{target} 공유는 09-10 회차에 연결합니다.'**
|
||||
String myShareLater(String target);
|
||||
/// **'{nickname} · Lv.{level} · 누적 기여 {pop} POP'**
|
||||
String myShareText(String nickname, String level, String pop);
|
||||
|
||||
/// No description provided for @myEditPhoto.
|
||||
///
|
||||
@@ -527,7 +527,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @settingsWithdrawLater.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'서비스 탈퇴 처리는 09-10 회차에 연결합니다.'**
|
||||
/// **'서비스 탈퇴 기능은 준비 중입니다.'**
|
||||
String get settingsWithdrawLater;
|
||||
|
||||
/// No description provided for @settingsUnlinkConfirm.
|
||||
@@ -557,8 +557,8 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @settingsLinkNotReady.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'{name} 연동은 아직 준비 중입니다. 필요한 항목: {items}'**
|
||||
String settingsLinkNotReady(String name, String items);
|
||||
/// **'이 기기에서는 {name} 연동을 아직 이용할 수 없습니다.'**
|
||||
String settingsLinkNotReady(String name);
|
||||
|
||||
/// No description provided for @settingsConnect.
|
||||
///
|
||||
@@ -1565,7 +1565,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @nebulaBody.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'창립맴버가 목표 POP을 {days}일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 맴버들에게는 기념 배지가 지급됩니다.'**
|
||||
/// **'창립멤버가 목표 POP을 {days}일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 멤버들에게는 기념 배지가 지급됩니다.'**
|
||||
String nebulaBody(String days);
|
||||
|
||||
/// No description provided for @nebulaNameTaken.
|
||||
@@ -1589,7 +1589,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @nebulaFounders.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **' / 창립맴버'**
|
||||
/// **' / 창립멤버'**
|
||||
String get nebulaFounders;
|
||||
|
||||
/// No description provided for @nebulaGoalSemantics.
|
||||
@@ -3398,6 +3398,12 @@ abstract class AppLocalizations {
|
||||
/// **'전체 {rank}위'**
|
||||
String planetOverallRank(String rank);
|
||||
|
||||
/// No description provided for @planetRankNone.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'순위 집계 전'**
|
||||
String get planetRankNone;
|
||||
|
||||
/// No description provided for @planetMyContribution.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
@@ -3509,7 +3515,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @questTypeUnverifiable.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'이 퀘스트 유형의 검증 방식이 아직 정해지지 않았습니다.'**
|
||||
/// **'이 퀘스트는 아직 참여할 수 없습니다.'**
|
||||
String get questTypeUnverifiable;
|
||||
|
||||
/// No description provided for @questTabMine.
|
||||
|
||||
@@ -37,7 +37,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
@@ -91,13 +91,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredBody(String name, String items) {
|
||||
return '$name login opens once the client keys are provided.\n\nRequired\n· $items';
|
||||
String authNotConfiguredBody(String name) {
|
||||
return '$name login isn\'t available on this device yet.\nPlease use another login method.';
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredNotice(String name, String items) {
|
||||
return '$name login is not ready yet.\nRequired: $items';
|
||||
String authNotConfiguredNotice(String name) {
|
||||
return '$name login isn\'t available on this device yet.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -176,8 +176,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get myCampaign => 'Campaigns';
|
||||
|
||||
@override
|
||||
String myShareLater(String target) {
|
||||
return '$target sharing will be connected in the 09-10 build.';
|
||||
String myShareText(String nickname, String level, String pop) {
|
||||
return '$nickname · Lv.$level · $pop POP contributed';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -251,8 +251,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
'Your POP will be lost and this cannot be undone.';
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLater =>
|
||||
'Account deletion will be connected in the 09-10 build.';
|
||||
String get settingsWithdrawLater => 'Account deletion is coming soon.';
|
||||
|
||||
@override
|
||||
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.';
|
||||
|
||||
@override
|
||||
String settingsLinkNotReady(String name, String items) {
|
||||
return '$name linking is not ready yet. Required: $items';
|
||||
String settingsLinkNotReady(String name) {
|
||||
return '$name linking isn\'t available on this device yet.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1916,6 +1915,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Overall #$rank';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planetRankNone => 'Not ranked yet';
|
||||
|
||||
@override
|
||||
String get planetMyContribution => 'My share';
|
||||
|
||||
@@ -1976,7 +1978,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get questTypeUnverifiable =>
|
||||
'The verification method for this quest type isn\'t set yet.';
|
||||
'This quest isn\'t open for participation yet.';
|
||||
|
||||
@override
|
||||
String get questTabMine => 'My quests';
|
||||
|
||||
@@ -37,7 +37,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String commonNotYet(String where) {
|
||||
return 'La pantalla $where llega en la segunda revisión (2026-09-10).';
|
||||
return 'La pantalla $where llegará pronto.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -92,13 +92,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredBody(String name, String items) {
|
||||
return 'El inicio con $name se habilitará cuando se entreguen las claves.\n\nRequerido\n· $items';
|
||||
String authNotConfiguredBody(String name) {
|
||||
return 'El inicio con $name aún no está disponible en este dispositivo.\nUsa otro método de inicio de sesión.';
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredNotice(String name, String items) {
|
||||
return 'El inicio con $name aún no está listo.\nRequerido: $items';
|
||||
String authNotConfiguredNotice(String name) {
|
||||
return 'El inicio con $name aún no está disponible en este dispositivo.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -178,8 +178,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get myCampaign => 'Campañas';
|
||||
|
||||
@override
|
||||
String myShareLater(String target) {
|
||||
return 'Compartir en $target se conectará en la versión 09-10.';
|
||||
String myShareText(String nickname, String level, String pop) {
|
||||
return '$nickname · Lv.$level · $pop POP aportados';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -253,7 +253,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLater =>
|
||||
'La eliminación de cuenta se conectará en la versión 09-10.';
|
||||
'La eliminación de cuenta llegará pronto.';
|
||||
|
||||
@override
|
||||
String get settingsUnlinkConfirm => '¿Desvincular esta cuenta?';
|
||||
@@ -270,8 +270,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
'Solo se pueden vincular cuentas sociales\ncon el mismo correo.';
|
||||
|
||||
@override
|
||||
String settingsLinkNotReady(String name, String items) {
|
||||
return 'La vinculación con $name aún no está lista. Requerido: $items';
|
||||
String settingsLinkNotReady(String name) {
|
||||
return 'La vinculación con $name aún no está disponible en este dispositivo.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1922,6 +1922,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return 'Puesto general $rank';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planetRankNone => 'Sin clasificar aún';
|
||||
|
||||
@override
|
||||
String get planetMyContribution => 'Mi aporte';
|
||||
|
||||
@@ -1983,7 +1986,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String get questTabMine => 'Mis misiones';
|
||||
|
||||
@@ -37,7 +37,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String commonNotYet(String where) {
|
||||
return '$where画面は第2次実装確認(2026-09-10)で追加されます。';
|
||||
return '$where画面は準備中です。';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -91,13 +91,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredBody(String name, String items) {
|
||||
return '$nameログインは発注元のキー提供後に利用できます。\n\n必要な項目\n· $items';
|
||||
String authNotConfiguredBody(String name) {
|
||||
return 'この端末では$nameログインをまだご利用いただけません。\n他のログイン方法をご利用ください。';
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredNotice(String name, String items) {
|
||||
return '$nameログインはまだ準備中です。\n必要な項目: $items';
|
||||
String authNotConfiguredNotice(String name) {
|
||||
return 'この端末では$nameログインをまだご利用いただけません。';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -173,8 +173,8 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get myCampaign => 'キャンペーン';
|
||||
|
||||
@override
|
||||
String myShareLater(String target) {
|
||||
return '$targetへの共有は09-10回で接続します。';
|
||||
String myShareText(String nickname, String level, String pop) {
|
||||
return '$nickname · Lv.$level · 累計貢献 $pop POP';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -247,7 +247,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get settingsWithdrawBody => '保有しているPOPは消滅し、元に戻せません。';
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLater => '退会処理は09-10回で接続します。';
|
||||
String get settingsWithdrawLater => '退会機能は準備中です。';
|
||||
|
||||
@override
|
||||
String get settingsUnlinkConfirm => '連携中のアカウントを解除しますか?';
|
||||
@@ -262,8 +262,8 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get settingsSameEmailOnly => 'アカウントのメールと同じ\nソーシャルアカウントのみ連携できます。';
|
||||
|
||||
@override
|
||||
String settingsLinkNotReady(String name, String items) {
|
||||
return '$name連携はまだ準備中です。必要な項目: $items';
|
||||
String settingsLinkNotReady(String name) {
|
||||
return 'この端末では$name連携をまだご利用いただけません。';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1854,6 +1854,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '全体$rank位';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planetRankNone => '順位集計前';
|
||||
|
||||
@override
|
||||
String get planetMyContribution => '自分の貢献度';
|
||||
|
||||
@@ -1913,7 +1916,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get questTypeStreaming => 'ストリーミング';
|
||||
|
||||
@override
|
||||
String get questTypeUnverifiable => 'このクエストタイプの検証方法はまだ決まっていません。';
|
||||
String get questTypeUnverifiable => 'このクエストにはまだ参加できません。';
|
||||
|
||||
@override
|
||||
String get questTabMine => 'マイクエスト';
|
||||
|
||||
@@ -37,7 +37,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String commonNotYet(String where) {
|
||||
return '$where 화면은 구현 확인물 2차(2026-09-10)에 들어갑니다.';
|
||||
return '$where 화면은 준비 중입니다.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -91,13 +91,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredBody(String name, String items) {
|
||||
return '$name 로그인은 발주사 키가 들어오면 열립니다.\n\n필요한 항목\n· $items';
|
||||
String authNotConfiguredBody(String name) {
|
||||
return '이 기기에서는 $name 로그인을 아직 이용할 수 없습니다.\n다른 로그인 방법을 이용해 주세요.';
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredNotice(String name, String items) {
|
||||
return '$name 로그인은 아직 준비 중입니다.\n필요한 항목: $items';
|
||||
String authNotConfiguredNotice(String name) {
|
||||
return '이 기기에서는 $name 로그인을 아직 이용할 수 없습니다.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -173,8 +173,8 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get myCampaign => '캠페인';
|
||||
|
||||
@override
|
||||
String myShareLater(String target) {
|
||||
return '$target 공유는 09-10 회차에 연결합니다.';
|
||||
String myShareText(String nickname, String level, String pop) {
|
||||
return '$nickname · Lv.$level · 누적 기여 $pop POP';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -247,7 +247,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get settingsWithdrawBody => '보유하신 POP 이 사라지고 되돌릴 수 없습니다.';
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLater => '서비스 탈퇴 처리는 09-10 회차에 연결합니다.';
|
||||
String get settingsWithdrawLater => '서비스 탈퇴 기능은 준비 중입니다.';
|
||||
|
||||
@override
|
||||
String get settingsUnlinkConfirm => '연결된 계정을 끊으시겠습니까?';
|
||||
@@ -262,8 +262,8 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get settingsSameEmailOnly => '계정 이메일과 동일한 소셜 계정만\n연결 할 수 있습니다.';
|
||||
|
||||
@override
|
||||
String settingsLinkNotReady(String name, String items) {
|
||||
return '$name 연동은 아직 준비 중입니다. 필요한 항목: $items';
|
||||
String settingsLinkNotReady(String name) {
|
||||
return '이 기기에서는 $name 연동을 아직 이용할 수 없습니다.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -849,7 +849,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String nebulaBody(String days) {
|
||||
return '창립맴버가 목표 POP을 $days일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 맴버들에게는 기념 배지가 지급됩니다.';
|
||||
return '창립멤버가 목표 POP을 $days일내 달성하면 \n정식 행성으로 탄생합니다!\n진화에 참여한 멤버들에게는 기념 배지가 지급됩니다.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -862,7 +862,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get nebulaGoal => '목표 ';
|
||||
|
||||
@override
|
||||
String get nebulaFounders => ' / 창립맴버';
|
||||
String get nebulaFounders => ' / 창립멤버';
|
||||
|
||||
@override
|
||||
String nebulaGoalSemantics(String pop, String members, String days) {
|
||||
@@ -1855,6 +1855,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return '전체 $rank위';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planetRankNone => '순위 집계 전';
|
||||
|
||||
@override
|
||||
String get planetMyContribution => '내 기여도';
|
||||
|
||||
@@ -1914,7 +1917,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get questTypeStreaming => '스트리밍';
|
||||
|
||||
@override
|
||||
String get questTypeUnverifiable => '이 퀘스트 유형의 검증 방식이 아직 정해지지 않았습니다.';
|
||||
String get questTypeUnverifiable => '이 퀘스트는 아직 참여할 수 없습니다.';
|
||||
|
||||
@override
|
||||
String get questTabMine => '내 퀘스트';
|
||||
|
||||
@@ -37,7 +37,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String commonNotYet(String where) {
|
||||
return '$where页面将在第二次实现确认(2026-09-10)中加入。';
|
||||
return '$where页面准备中。';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -91,13 +91,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredBody(String name, String items) {
|
||||
return '$name登录将在客户提供密钥后开放。\n\n所需项目\n· $items';
|
||||
String authNotConfiguredBody(String name) {
|
||||
return '此设备暂时无法使用$name登录。\n请使用其他登录方式。';
|
||||
}
|
||||
|
||||
@override
|
||||
String authNotConfiguredNotice(String name, String items) {
|
||||
return '$name登录尚在准备中。\n所需项目: $items';
|
||||
String authNotConfiguredNotice(String name) {
|
||||
return '此设备暂时无法使用$name登录。';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -173,8 +173,8 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get myCampaign => '应援活动';
|
||||
|
||||
@override
|
||||
String myShareLater(String target) {
|
||||
return '$target分享将在09-10版本中接入。';
|
||||
String myShareText(String nickname, String level, String pop) {
|
||||
return '$nickname · Lv.$level · 累计贡献 $pop POP';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -247,7 +247,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get settingsWithdrawBody => '您持有的POP将会清零且无法恢复。';
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLater => '注销处理将在09-10版本中接入。';
|
||||
String get settingsWithdrawLater => '注销功能准备中。';
|
||||
|
||||
@override
|
||||
String get settingsUnlinkConfirm => '确定要解除关联账号吗?';
|
||||
@@ -262,8 +262,8 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get settingsSameEmailOnly => '只能关联与账号邮箱\n相同的社交账号。';
|
||||
|
||||
@override
|
||||
String settingsLinkNotReady(String name, String items) {
|
||||
return '$name关联尚在准备中。所需项目: $items';
|
||||
String settingsLinkNotReady(String name) {
|
||||
return '此设备暂时无法使用$name关联。';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1853,6 +1853,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '总排名第 $rank';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planetRankNone => '尚未排名';
|
||||
|
||||
@override
|
||||
String get planetMyContribution => '我的贡献度';
|
||||
|
||||
@@ -1912,7 +1915,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get questTypeStreaming => '流媒体';
|
||||
|
||||
@override
|
||||
String get questTypeUnverifiable => '该任务类型的验证方式尚未确定。';
|
||||
String get questTypeUnverifiable => '该任务暂时无法参与。';
|
||||
|
||||
@override
|
||||
String get questTabMine => '我的任务';
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"commonPrev": "上一步",
|
||||
"commonDone": "完成",
|
||||
"commonStart": "开始",
|
||||
"commonNotYet": "{where}页面将在第二次实现确认(2026-09-10)中加入。",
|
||||
"commonNotYet": "{where}页面准备中。",
|
||||
"languageSettingsTitle": "语言设置",
|
||||
"authLogin": "登录",
|
||||
"authSignup": "注册",
|
||||
@@ -20,8 +20,8 @@
|
||||
"authProviderLogin": "{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}登录准备中",
|
||||
"authNotConfiguredBody": "{name}登录将在客户提供密钥后开放。\n\n所需项目\n· {items}",
|
||||
"authNotConfiguredNotice": "{name}登录尚在准备中。\n所需项目: {items}",
|
||||
"authNotConfiguredBody": "此设备暂时无法使用{name}登录。\n请使用其他登录方式。",
|
||||
"authNotConfiguredNotice": "此设备暂时无法使用{name}登录。",
|
||||
"authResetNeedEmail": "请先输入接收重置邮件的邮箱。",
|
||||
"authResetSent": "已向该地址发送重置说明。",
|
||||
"termsTitle": "使用条款",
|
||||
@@ -46,7 +46,7 @@
|
||||
"myTabActivity": "活动",
|
||||
"myInside": "Inside",
|
||||
"myCampaign": "应援活动",
|
||||
"myShareLater": "{target}分享将在09-10版本中接入。",
|
||||
"myShareText": "{nickname} · Lv.{level} · 累计贡献 {pop} POP",
|
||||
"myEditPhoto": "修改头像",
|
||||
"myEditProfile": "编辑资料",
|
||||
"settingsTitle": "设置",
|
||||
@@ -70,12 +70,12 @@
|
||||
"settingsWithdraw": "注销账号",
|
||||
"settingsWithdrawConfirm": "确定要注销账号吗?",
|
||||
"settingsWithdrawBody": "您持有的POP将会清零且无法恢复。",
|
||||
"settingsWithdrawLater": "注销处理将在09-10版本中接入。",
|
||||
"settingsWithdrawLater": "注销功能准备中。",
|
||||
"settingsUnlinkConfirm": "确定要解除关联账号吗?",
|
||||
"settingsUnlinkBody": "仅移除该社交登录方式,账号信息不会被删除。",
|
||||
"settingsUnlink": "解除关联",
|
||||
"settingsSameEmailOnly": "只能关联与账号邮箱\n相同的社交账号。",
|
||||
"settingsLinkNotReady": "{name}关联尚在准备中。所需项目: {items}",
|
||||
"settingsLinkNotReady": "此设备暂时无法使用{name}关联。",
|
||||
"settingsConnect": "关联",
|
||||
"settingsDisconnect": "解除",
|
||||
"settingsEmailProvider": "邮箱",
|
||||
@@ -549,6 +549,7 @@
|
||||
"planetNoContributors": "还没有贡献者",
|
||||
"planetContributeCta": "贡献一下,登上排行榜吧!",
|
||||
"planetOverallRank": "总排名第 {rank}",
|
||||
"planetRankNone": "尚未排名",
|
||||
"planetMyContribution": "我的贡献度",
|
||||
"planetTallying": "统计准备中",
|
||||
"planetTopPercent": "前 {percent}%",
|
||||
@@ -567,7 +568,7 @@
|
||||
"questTypeSteps": "步行",
|
||||
"questTypePlace": "指定地点",
|
||||
"questTypeStreaming": "流媒体",
|
||||
"questTypeUnverifiable": "该任务类型的验证方式尚未确定。",
|
||||
"questTypeUnverifiable": "该任务暂时无法参与。",
|
||||
"questTabMine": "我的任务",
|
||||
"placePickPrompt": "请选择\n完成任务的地点。",
|
||||
"placeSearch": "搜索地点",
|
||||
|
||||
@@ -107,10 +107,10 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
String get authMethodUnavailable => _remote['authMethodUnavailable'] ?? _base.authMethodUnavailable;
|
||||
|
||||
@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
|
||||
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
|
||||
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;
|
||||
|
||||
@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
|
||||
String get myTabActivity => _remote['myTabActivity'] ?? _base.myTabActivity;
|
||||
@@ -1429,6 +1429,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String planetPerHead(String value) => _fill('planetPerHead', {'value': value}) ?? _base.planetPerHead(value);
|
||||
|
||||
@override
|
||||
String get planetRankNone => _remote['planetRankNone'] ?? _base.planetRankNone;
|
||||
|
||||
@override
|
||||
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;
|
||||
|
||||
@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
|
||||
String get settingsLogout => _remote['settingsLogout'] ?? _base.settingsLogout;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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:firebase_auth/firebase_auth.dart' as fb;
|
||||
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` 라 밖에서 직접 부르면
|
||||
|
||||
@@ -207,13 +207,25 @@ void main() {
|
||||
'active': true, 'title': '오늘의 출석체크', 'rewardPop': 50, 'dailyLimit': 1,
|
||||
});
|
||||
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();
|
||||
expect(snapshot.quests.single.state, QuestState.done);
|
||||
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 {
|
||||
final db = await _seed();
|
||||
await db.doc('quests/attend').set({
|
||||
@@ -464,6 +476,21 @@ void main() {
|
||||
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 {
|
||||
final db = await seedPlanet();
|
||||
await db.doc('fandoms/bts/fpiSnapshots/2026-09-01-00').delete();
|
||||
|
||||
@@ -172,9 +172,15 @@ void main() {
|
||||
// 검토회신서 3절 — *「선택 행성 정보가 행성과 연계된 HUD 가 아니라 화면
|
||||
// 최상단 고정 패널 형태」*. 고정 패널로 되돌아가면 여기서 깨진다.
|
||||
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 tester.tap(find.byKey(const ValueKey('newjeans')));
|
||||
await _settle(tester);
|
||||
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);
|
||||
final after = tester.getCenter(find.text('RANK'));
|
||||
expect(
|
||||
@@ -275,17 +281,63 @@ void main() {
|
||||
await _pump(tester, _galaxy());
|
||||
expect(find.text('RANK'), findsOneWidget);
|
||||
|
||||
// 맵을 끝까지 밀어 내 행성을 화면 밖으로 보낸다.
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await tester.drag(
|
||||
find.byType(InteractiveViewer),
|
||||
const Offset(-400, -400),
|
||||
);
|
||||
await _settle(tester);
|
||||
// 끝까지 확대하며 두 손가락을 오른쪽 아래로 끌어 내 행성을 화면 밖으로 보낸다
|
||||
// (한 손가락 팬은 없다 — 확대 제스처의 초점 이동만 맵을 옮긴다).
|
||||
final viewer = find.byType(InteractiveViewer);
|
||||
final origin = tester.getTopLeft(viewer);
|
||||
final a = await tester.startGesture(origin + const Offset(4, 4));
|
||||
final b = await tester.startGesture(
|
||||
origin + const Offset(24, 24),
|
||||
pointer: 7,
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
// 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 {
|
||||
await _pump(tester, _galaxy());
|
||||
expect(find.text('GALAXY'), findsOneWidget);
|
||||
|
||||
@@ -61,7 +61,8 @@ class _NoTopPercentRepository extends SamplePlanetRepository {
|
||||
FpiScore(
|
||||
metric: s.metric,
|
||||
value: s.value,
|
||||
rank: s.rank,
|
||||
// 집계 모집단 밖(서버 fpiRanks 없음)이면 순위가 0 으로 온다.
|
||||
rank: 0,
|
||||
trend: s.trend,
|
||||
myTopPercent: null,
|
||||
),
|
||||
@@ -166,7 +167,7 @@ void main() {
|
||||
expect(find.text('상위 17%'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('내 기여 상위 집계 전이면 숫자 대신 사유를 띄운다 — 대조표 3-5', (tester) async {
|
||||
testWidgets('집계 값이 없으면 숫자를 짓지 않고 내부 사유도 띄우지 않는다 — 대조표 3-5', (tester) async {
|
||||
await _pumpTall(
|
||||
tester,
|
||||
const PlanetDetailScreen(
|
||||
@@ -177,9 +178,13 @@ void main() {
|
||||
);
|
||||
// 없는 값을 100%(최하위)로 지어내지 않는다.
|
||||
expect(find.text('상위 100%'), findsNothing);
|
||||
expect(find.text('집계 준비 중'), findsWidgets);
|
||||
// 사유는 배지로도 뜬다 — 숨기지 않는다.
|
||||
expect(find.textContaining('MOCK · '), findsOneWidget);
|
||||
expect(find.text('—'), findsWidgets);
|
||||
// 개발용 사유(MOCK · 집계 함수)는 사용자 화면에 내보내지 않는다(10/4 실기기 3-1).
|
||||
expect(find.textContaining('MOCK'), findsNothing);
|
||||
expect(find.textContaining('집계 함수'), findsNothing);
|
||||
// 순위가 없으면 「전체 0위」 대신 「순위 집계 전」.
|
||||
expect(find.text('전체 0위'), findsNothing);
|
||||
expect(find.text('순위 집계 전'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('이클립스가 발동 중이면 침식·방어 진행을 띄운다', (tester) async {
|
||||
|
||||
@@ -90,6 +90,29 @@ void main() {
|
||||
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 {
|
||||
final repo = _AdRepo(
|
||||
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)', () {
|
||||
test('명세가 정한 다섯 상태의 버튼 문구를 그대로 쓴다', () {
|
||||
expect(QuestStage.mine.buttonLabel, '자세히');
|
||||
@@ -233,7 +271,8 @@ void main() {
|
||||
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
|
||||
await tester.pumpAndSettle();
|
||||
// 별첨 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);
|
||||
// 별첨1 ⑥ No-Force — 보지 않아도 기본 보상을 받는다.
|
||||
expect(find.text('기본 보상 받기'), findsOneWidget);
|
||||
@@ -270,14 +309,18 @@ void main() {
|
||||
expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('회신 대기 유형은 참여 버튼이 잠기고 MOCK 으로 알린다', (tester) async {
|
||||
testWidgets('회신 대기 유형은 참여 버튼이 잠기고 사용자 문구로만 알린다', (tester) async {
|
||||
await pumpWide(tester, const QuestDetailScreen(questId: 'q6', repository: _sample));
|
||||
await tester.pump();
|
||||
final button = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, '발주사 확인 중'),
|
||||
find.widgetWithText(FilledButton, '준비 중입니다.'),
|
||||
);
|
||||
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 {
|
||||
@@ -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 「광고 실패 시 기본 보상 지급 · 추가 보상 없음」.
|
||||
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 String? impression;
|
||||
final BackendException? claimError;
|
||||
final BackendException? verifyError;
|
||||
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
|
||||
Future<RewardedAdHandle> loadRewardedAd() async {
|
||||
|
||||
@@ -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/widgets/campaign_period_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fancake/core/ui/app_dialog.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// F5 캠페인 — 화면 대조 원장 8화면.
|
||||
@@ -418,12 +419,10 @@ void main() {
|
||||
expect(find.text('2027년 4월'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Catalog 표본 가격은 앱바 MOCK 알약으로 알린다(G-08)', (tester) async {
|
||||
testWidgets('앱바에 개발용 MOCK 표시를 띄우지 않는다', (tester) async {
|
||||
await pumpWide(tester, CampaignCreateScreen());
|
||||
expect(
|
||||
find.bySemanticsLabel('MOCK · 상품 목록은 Catalog 연결 전 표본입니다'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('MOCK'), findsNothing);
|
||||
expect(find.bySemanticsLabel(RegExp('MOCK')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('값이 다 차기 전까지 완료가 잠겨 있다', (tester) async {
|
||||
@@ -465,7 +464,7 @@ void main() {
|
||||
expect(find.text('이 내용으로 올릴까요?'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
of: find.byType(AppDialog),
|
||||
matching: find.textContaining('500,000 POP · 14일'),
|
||||
),
|
||||
findsOneWidget,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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/my/my_data.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/support_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/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// F6 MY — 화면 대조 원장 21화면.
|
||||
|
||||
@@ -178,11 +181,45 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.tap(find.byType(CircleAvatar).first);
|
||||
await tester.pumpAndSettle();
|
||||
for (final target in const ['인스타그램', '틱톡', 'X', '링크 복사']) {
|
||||
// 프로필 웹 주소가 없어 마지막 칸은 「문구 복사」다(없는 링크를 약속하지 않는다).
|
||||
for (final target in const ['인스타그램', '틱톡', 'X', '문구 복사']) {
|
||||
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 {
|
||||
await pumpWide(tester, MyScreen(repository: SampleMyRepository()));
|
||||
await tester.pump();
|
||||
@@ -844,11 +881,17 @@ void main() {
|
||||
});
|
||||
|
||||
group('내 활동 (F6 · 370:228289 · 370:229665)', () {
|
||||
testWidgets('내 인사이드에 내가 올린 글이 나온다', (tester) async {
|
||||
await pumpWide(tester, const MyListsScreen(kind: MyListKind.inside));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('화양연화'), findsOneWidget);
|
||||
expect(find.text('500 P'), findsOneWidget);
|
||||
testWidgets('내 인사이드는 저장소의 내 질문을 보여 준다 — 고정 표본 없음', (tester) async {
|
||||
await pumpWide(
|
||||
tester,
|
||||
const MyListsScreen(kind: MyListKind.inside, insides: _MyInsides()),
|
||||
);
|
||||
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 {
|
||||
@@ -1000,3 +1043,21 @@ class _PagedZapRepository extends SampleMyRepository {
|
||||
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,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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/home/home_data.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/quest/quest_data.dart';
|
||||
import 'eclipse_stubs.dart';
|
||||
import 'galaxy_mini_map_capture_test.dart' show loadAppFonts, mineAt;
|
||||
import 'perf_paint_test.dart' show medianPaintMicros, paintBudgetMicros;
|
||||
|
||||
/// 프로토타입 궤도 간격 450 × Mass Volume 1.5 × Orbit Exp 1.0.
|
||||
@@ -187,24 +189,68 @@ void main() {
|
||||
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));
|
||||
final p = painterOf(tester);
|
||||
expect(p.compactText, [
|
||||
('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) {
|
||||
expect(pt, greaterThanOrEqualTo(9));
|
||||
expect(pt, greaterThanOrEqualTo(8));
|
||||
}
|
||||
final w = p.hudWidthOf('a')!;
|
||||
expect(w, greaterThan(0));
|
||||
expect(w, lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor));
|
||||
final card = p.compactSize;
|
||||
expect(card.width, 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('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 {
|
||||
await pumpMap(tester, _snap(_base), size: 320);
|
||||
final p = painterOf(tester);
|
||||
@@ -307,11 +353,24 @@ void main() {
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
final mini = find.byType(GalaxyMiniMap);
|
||||
expect(mini, findsOneWidget);
|
||||
// Q-DS-06 권고안 A(회신 전 임시) — 행성 영역 우측 상단 140 정사각.
|
||||
expect(tester.getSize(mini), const Size(140, 140));
|
||||
// C-105 약 140pt · C-113 자리 = Figma 「홈」(2097:16428) 히어로 우측 상단 · 오른쪽 여백 0.
|
||||
expect(
|
||||
tester.getSize(mini),
|
||||
const Size.square(HomeHero.miniMapSize),
|
||||
);
|
||||
expect(
|
||||
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.pump();
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -155,7 +155,7 @@ void main() {
|
||||
expect(find.text('500 P'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('아직 없는 화면으로 가려 하면 언제 열리는지 알린다', (tester) async {
|
||||
testWidgets('아직 없는 화면으로 가려 하면 준비 중이라고 알린다', (tester) async {
|
||||
await _pump(tester);
|
||||
// 첫 「더 보기」는 퀘스트라 실제로 이동한다. 아직 없는 구간까지 굴려서 누른다.
|
||||
// 브리핑 헤더는 「공유」다(`266:90529`). 아직 없는 목록인 인기 K-INSIDE 로 본다.
|
||||
@@ -169,7 +169,9 @@ void main() {
|
||||
await tester.tap(find.descendant(of: briefing, matching: find.text('더 보기')));
|
||||
await tester.pump();
|
||||
// 조용히 무시하면 검수 때 고장으로 보인다.
|
||||
expect(find.textContaining('구현 확인물 2차'), findsOneWidget);
|
||||
expect(find.textContaining('화면은 준비 중입니다.'), findsOneWidget);
|
||||
// 내부 일정(구현 확인물 · 날짜)은 사용자 화면에 내보내지 않는다.
|
||||
expect(find.textContaining('구현 확인물'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('오늘의 퀘스트에서 퀘스트 목록으로 넘어간다', (tester) async {
|
||||
|
||||
+17
-2
@@ -172,11 +172,11 @@ void main() {
|
||||
test('원격 문자열은 인자 값 안의 자리표시자를 다시 치환하지 않는다', () {
|
||||
final t = RemoteL10nDelegate(const {
|
||||
'linkTitle': '{name} 연동',
|
||||
'settingsLinkNotReady': '{name}: {items}',
|
||||
'myShareText': '{nickname}: {level}',
|
||||
});
|
||||
return t.load(const Locale('ko')).then((l10n) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,6 +96,26 @@ void main() {
|
||||
});
|
||||
|
||||
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 {
|
||||
final db = await _seed();
|
||||
final cards = await FirestoreQuestRepository(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user