diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules index 09f49fd..376ef6d 100644 --- a/app/firebase/firestore.rules +++ b/app/firebase/firestore.rules @@ -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; } diff --git a/app/firebase/test/tests/firestore.rules.test.mjs b/app/firebase/test/tests/firestore.rules.test.mjs index 8879056..7ba0eb2 100644 --- a/app/firebase/test/tests/firestore.rules.test.mjs +++ b/app/firebase/test/tests/firestore.rules.test.mjs @@ -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' })); diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index ecbc297..78f3c69 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -179,6 +179,7 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { push: repos.push, auth: auth, campaigns: repos.campaign, + insides: repos.inside, ), ), ], diff --git a/app/lib/core/ui/app_dialog.dart b/app/lib/core/ui/app_dialog.dart new file mode 100644 index 0000000..ce98958 --- /dev/null +++ b/app/lib/core/ui/app_dialog.dart @@ -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 showConfirmDialog( + BuildContext context, + String title, { + String? body, + Widget? content, + String? cancelLabel, + String? confirmLabel, +}) async { + final r = await showDialog( + 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 showAlertDialog( + BuildContext context, + String title, { + String? body, + String? confirmLabel, +}) => showDialog( + context: context, + barrierColor: _barrier, + builder: (context) => AppDialog( + title: title, + body: body, + confirmLabel: confirmLabel ?? context.l10n.commonConfirm, + ), +); diff --git a/app/lib/core/ui/mock_tag.dart b/app/lib/core/ui/mock_tag.dart deleted file mode 100644 index 8e23f5c..0000000 --- a/app/lib/core/ui/mock_tag.dart +++ /dev/null @@ -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, - ), - ), - ), - ), - ); -} diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart index ea0706f..b9df86a 100644 --- a/app/lib/features/auth/auth.dart +++ b/app/lib/features/auth/auth.dart @@ -87,7 +87,7 @@ final class AuthNotConfigured extends AuthOutcome { final AuthProviderKind provider; - /// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다. + /// 투입해야 하는 것들(파일명·키명). 개발자용이라 로그·README 에만 쓰고 화면에는 내보내지 않는다. final List missing; } diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index 275653c..8eb2a76 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -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 { Future _showNotConfigured(AuthNotConfigured outcome) async { final name = outcome.provider.shortName; - await showDialog( - 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( diff --git a/app/lib/features/campaign/campaign_create_screen.dart b/app/lib/features/campaign/campaign_create_screen.dart index 2412746..809408d 100644 --- a/app/lib/features/campaign/campaign_create_screen.dart +++ b/app/lib/features/campaign/campaign_create_screen.dart @@ -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 { ), 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 { ); Future _confirm() async { - final go = await showDialog( - 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 { } if (!mounted) return; // 올린 캠페인은 심사 전이라 목록에 보이지 않는다 — 어디 갔는지 알려 준다. - await showDialog( - 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); } diff --git a/app/lib/features/campaign/campaign_detail_screen.dart b/app/lib/features/campaign/campaign_detail_screen.dart index 5089930..cd8d728 100644 --- a/app/lib/features/campaign/campaign_detail_screen.dart +++ b/app/lib/features/campaign/campaign_detail_screen.dart @@ -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 { /// 철회 확인 — 등록비 반환 여부를 먼저 알린다(B-58 · 공개 전만 반환). Future _withdraw(CampaignCardData card) async { final beforePublish = card.status == CampaignStatus.review; - final go = await showDialog( - 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) { diff --git a/app/lib/features/eclipse/eclipse_data.dart b/app/lib/features/eclipse/eclipse_data.dart index 91a1f04..1170860 100644 --- a/app/lib/features/eclipse/eclipse_data.dart +++ b/app/lib/features/eclipse/eclipse_data.dart @@ -65,7 +65,7 @@ class EclipseState { /// 방어 마감. 없으면 남은 시간을 띄우지 않는다. final DateTime? endsAt; - /// 실데이터가 아닌 경우의 사유. 화면이 그대로 배지로 띄운다(대조표 3-5). + /// 실데이터가 아닌 경우의 사유. 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출). final String? mockNote; bool get isMock => mockNote != null; diff --git a/app/lib/features/eclipse/widgets/eclipse_banner.dart b/app/lib/features/eclipse/widgets/eclipse_banner.dart index f7c78fc..5dadbf8 100644 --- a/app/lib/features/eclipse/widgets/eclipse_banner.dart +++ b/app/lib/features/eclipse/widgets/eclipse_banner.dart @@ -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), ], ), ), diff --git a/app/lib/features/galaxy/galaxy_home_screen.dart b/app/lib/features/galaxy/galaxy_home_screen.dart index cddc213..2109f06 100644 --- a/app/lib/features/galaxy/galaxy_home_screen.dart +++ b/app/lib/features/galaxy/galaxy_home_screen.dart @@ -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 { 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 { ), ); } - - /// 카드 그림. 시안 세 칸(`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 테두리와 diff --git a/app/lib/features/galaxy/galaxy_screen.dart b/app/lib/features/galaxy/galaxy_screen.dart index 4a7aa93..09ecef2 100644 --- a/app/lib/features/galaxy/galaxy_screen.dart +++ b/app/lib/features/galaxy/galaxy_screen.dart @@ -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 late Future _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( diff --git a/app/lib/features/galaxy/widgets/galaxy_mini_map.dart b/app/lib/features/galaxy/widgets/galaxy_mini_map.dart index 632e8b7..29702f5 100644 --- a/app/lib/features/galaxy/widgets/galaxy_mini_map.dart +++ b/app/lib/features/galaxy/widgets/galaxy_mini_map.dart @@ -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 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(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(0, (m, l) => math.max(m, l.width)); + final textH = lines.fold(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 = []; 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; } diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index a91cc4a..eeaf60a 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -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 {}; @@ -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 {}; @@ -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? progress, Map 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> popularInsides( + List> byBounty, +) => [ + ...byBounty.where((q) => q['status'] == 'open'), + ...byBounty.where((q) => q['status'] != 'open'), +].take(3).toList(); + /// 오늘 날짜(KST `YYYY-MM-DD`) — 서버 `briefings.dayKey` 와 같은 규약이다. /// /// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이 diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index c5b50ec..cea6b22 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -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 { 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( + 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( + 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}); diff --git a/app/lib/features/home/nebula_create_screen.dart b/app/lib/features/home/nebula_create_screen.dart index 60ae64e..e7f1318 100644 --- a/app/lib/features/home/nebula_create_screen.dart +++ b/app/lib/features/home/nebula_create_screen.dart @@ -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 showConfirmDialog(BuildContext context, String message) async { - final r = await showDialog( - 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 showAlertDialog(BuildContext context, String message) => showDialog( - context: context, - builder: (context) => AlertDialog( - content: Text(message), - actions: [ - FilledButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.commonConfirm), - ), - ], - ), -); diff --git a/app/lib/features/home/planet_create_screen.dart b/app/lib/features/home/planet_create_screen.dart index fdadff3..6630b84 100644 --- a/app/lib/features/home/planet_create_screen.dart +++ b/app/lib/features/home/planet_create_screen.dart @@ -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 { ); Future _confirm() async { - final ok = await showDialog( - 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), - ), - ), - ], - ), - ], - ), - ), - ); -} diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart index 57d0ee7..74dfbdc 100644 --- a/app/lib/features/home/widgets/home_hero.dart +++ b/app/lib/features/home/widgets/home_hero.dart @@ -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 { glow: AppColors.neonViolet, ), ), - if (widget.miniMap case final mini?) - Positioned(top: 0, right: 0, child: mini), + ], ), ), @@ -215,6 +225,13 @@ class _HomeHeroState extends State { ), ), + 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), diff --git a/app/lib/features/inside/inside_data.dart b/app/lib/features/inside/inside_data.dart index 16afa65..95413c7 100644 --- a/app/lib/features/inside/inside_data.dart +++ b/app/lib/features/inside/inside_data.dart @@ -198,6 +198,9 @@ abstract interface class InsideRepository { /// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다. Future accept(String questionId, String answerId); + + /// 내가 쓴 질문 — MY > 활동 > 인사이드. 최신순. + Future> mine(); } /// 현상금 Q&A 실패를 화면 문구로 — 서버 카탈로그 코드(`E-QNA-*` 등)로 가른다. @@ -312,6 +315,10 @@ class SampleInsideRepository implements InsideRepository { @override Future balance() async => 12_400; + @override + Future> mine() async => + _sorted(InsideSort.latest).where((q) => q.isMine).toList(); + @override Future minBounty() async => 100; diff --git a/app/lib/features/inside/inside_detail_screen.dart b/app/lib/features/inside/inside_detail_screen.dart index 0e280cc..dc64cce 100644 --- a/app/lib/features/inside/inside_detail_screen.dart +++ b/app/lib/features/inside/inside_detail_screen.dart @@ -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 { a.id.isNotEmpty; Future _accept(InsideAnswer a) async { - final ok = await showDialog( - 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); diff --git a/app/lib/features/inside/inside_repository_firestore.dart b/app/lib/features/inside/inside_repository_firestore.dart index abe18ea..a986f10 100644 --- a/app/lib/features/inside/inside_repository_firestore.dart +++ b/app/lib/features/inside/inside_repository_firestore.dart @@ -95,6 +95,18 @@ class FirestoreInsideRepository implements InsideRepository { ); } + /// 팬덤과 상관없이 내가 쓴 질문 전부(`authorUid` 등호 하나라 복합 색인이 필요 없다). + // ponytail: 정렬은 앱에서 한다. 한 사람의 질문이 수백 건을 넘으면 orderBy + 복합 색인 + 페이지로. + @override + Future> 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 balance() async { final wallet = diff --git a/app/lib/features/my/account_link_screen.dart b/app/lib/features/my/account_link_screen.dart index b0802d5..b61b17d 100644 --- a/app/lib/features/my/account_link_screen.dart +++ b/app/lib/features/my/account_link_screen.dart @@ -115,8 +115,8 @@ class _AccountLinkScreenState extends State { 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; } diff --git a/app/lib/features/my/my_lists_screen.dart b/app/lib/features/my/my_lists_screen.dart index e8e413e..9118676 100644 --- a/app/lib/features/my/my_lists_screen.dart +++ b/app/lib/features/my/my_lists_screen.dart @@ -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 { _MySort _sort = _MySort.latest; - /// 표본 — 시안 `370:228289` 문구. 서버가 붙으면 INSIDE 저장소의 내 글로 옮긴다. - static final _insides = () { - final now = DateTime.now(); - return [ - 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 { }, ); - Widget _insideList(BuildContext context) { + /// 내가 쓴 질문 — 화면을 열 때 한 번 읽고, 정렬 칩은 그 목록을 다시 거른다. + late final Future> _mine = + (widget.insides ?? const SampleInsideRepository()).mine(); + + Widget _insideList(BuildContext context) => + FutureBuilder>( + 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 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( diff --git a/app/lib/features/my/my_screen.dart b/app/lib/features/my/my_screen.dart index 21789bc..1fdeaf0 100644 --- a/app/lib/features/my/my_screen.dart +++ b/app/lib/features/my/my_screen.dart @@ -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 { _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 { 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))); + } }, ), ), diff --git a/app/lib/features/my/settings_screen.dart b/app/lib/features/my/settings_screen.dart index 0cf3208..935493f 100644 --- a/app/lib/features/my/settings_screen.dart +++ b/app/lib/features/my/settings_screen.dart @@ -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 { 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 { } /// 안내 팝업(확인 하나) — 시안 `554:23103`. - Future _notice(String message) => showDialog( - context: context, - builder: (context) => _MyDialog( - title: message, - actions: [ - _DialogButton( - label: context.l10n.commonConfirm, - filled: true, - onTap: () => Navigator.of(context).pop(), - ), - ], - ), - ); + Future _notice(String message) => showAlertDialog(context, message); /// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`. Future _confirm({ @@ -366,30 +350,18 @@ class _SettingsScreenState extends State { bool withdraw = false, Future Function()? onConfirm, }) async { - final ok = await showDialog( - 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 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}); diff --git a/app/lib/features/my/widgets/profile_card.dart b/app/lib/features/my/widgets/profile_card.dart index 06bbf9e..51e6635 100644 --- a/app/lib/features/my/widgets/profile_card.dart +++ b/app/lib/features/my/widgets/profile_card.dart @@ -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 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 shareProfile( + MyProfile profile, + String target, { + Future 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; +} diff --git a/app/lib/features/planet/fpi_ranking_screen.dart b/app/lib/features/planet/fpi_ranking_screen.dart index 421d6ff..270b771 100644 --- a/app/lib/features/planet/fpi_ranking_screen.dart +++ b/app/lib/features/planet/fpi_ranking_screen.dart @@ -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), - ), - ); -} diff --git a/app/lib/features/planet/planet_data.dart b/app/lib/features/planet/planet_data.dart index 2eb86b4..a286d3d 100644 --- a/app/lib/features/planet/planet_data.dart +++ b/app/lib/features/planet/planet_data.dart @@ -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 posts; - /// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유. 화면이 그대로 배지로 - /// 띄운다 — 숨기지 않는다(대조표 3-5). + /// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유. + /// 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출). final String? mockNote; /// 육각형 그래프 정규화 기준. diff --git a/app/lib/features/planet/planet_detail_screen.dart b/app/lib/features/planet/planet_detail_screen.dart index 984bf53..618553f 100644 --- a/app/lib/features/planet/planet_detail_screen.dart +++ b/app/lib/features/planet/planet_detail_screen.dart @@ -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), ), diff --git a/app/lib/features/planet/planet_repository_firestore.dart b/app/lib/features/planet/planet_repository_firestore.dart index e8b336f..187b1a6 100644 --- a/app/lib/features/planet/planet_repository_firestore.dart +++ b/app/lib/features/planet/planet_repository_firestore.dart @@ -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, diff --git a/app/lib/features/quest/quest_ad_screens.dart b/app/lib/features/quest/quest_ad_screens.dart index 4247f96..464b552 100644 --- a/app/lib/features/quest/quest_ad_screens.dart +++ b/app/lib/features/quest/quest_ad_screens.dart @@ -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 { void initState() { super.initState(); // 실패해도 닫는다 — 오류는 [until] 을 만든 쪽이 따로 받는다. - unawaited(widget.until?.catchError((_) => null).whenComplete(_close)); + // `catchError((_) => null)` 은 `Future` 처럼 null 을 못 받는 타입에서 던진다. + unawaited(widget.until?.then((_) {}, onError: (_) {}).whenComplete(_close)); } /// 맨 위가 아니어도 이 화면만 걷어 낸다. 지급이 끝나 보상 화면이 먼저 올라와도 diff --git a/app/lib/features/quest/quest_create_screen.dart b/app/lib/features/quest/quest_create_screen.dart index 48623f6..8c59888 100644 --- a/app/lib/features/quest/quest_create_screen.dart +++ b/app/lib/features/quest/quest_create_screen.dart @@ -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 { Future _confirm() async { final pool = _paid ? _pool : 0; final fee = _catalog?.feeOf(pool) ?? 0; - final go = await showDialog( - 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 { 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; diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index 8d4f0aa..2f292a9 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -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 create(QuestDraft draft); + /// 보유 일반 POP(`wallet/main.activityBalance`) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다. + /// 모르면 null(헤더는 받은 값을 유지한다). + Future popBalance(); + /// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다. /// /// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률 @@ -730,6 +731,9 @@ class SampleQuestRepository implements QuestRepository { @override Future create(QuestDraft draft) async => 'q-new'; + @override + Future popBalance() async => null; + /// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌 /// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다. @override diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart index 96130b5..e3d5e35 100644 --- a/app/lib/features/quest/quest_detail_screen.dart +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -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 { 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 { ), ), // 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 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 { ), child: Text( blocked != null - ? '발주사 확인 중' + ? context.l10n.commonComingSoon : !open ? context.l10n.questTimeAttackClosed : _busy @@ -388,34 +390,23 @@ class _QuestDetailScreenState extends State { var resubmit = false; if (!approve) { final input = TextEditingController(); - final ok = await showDialog( - 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 { ); 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 { Future _submitProof(QuestDetail detail) async { final url = TextEditingController(); final text = TextEditingController(); - final ok = await showDialog( - 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 { ), ], ), - 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 { Future _edit(QuestDetail detail) async { final title = TextEditingController(text: detail.card.title); final description = TextEditingController(text: detail.description); - final ok = await showDialog( - 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 { ), ], ), - 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 { /// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·* /// *중단 버튼 퀘스트 진행 상태값 시작전으로 업데이트* Future _confirmStop(QuestDetail detail) async { - final stop = await showDialog( - 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 { /// 보상받기. /// + /// 순서는 **참여 조건 판정 → 기본 보상 → 광고 선택**이다. 위치 · 권한 · 걸음 · 일일 한도는 + /// 서버가 [QuestRepository.verify] 에서 판정하므로, 그 판정이 통과한 뒤에만 광고를 권한다. + /// 광고를 먼저 보이면 끝까지 보고도 위치 실패로 아무것도 못 받는 경로가 생긴다(2026-10-04 실기기 4-3). + /// /// 명세는 *관리자 퀘스트만 추가 보상 모달 · 사용자 퀘스트는 일반 보상 완료* 로 나눴지만 /// 별첨 B **B-28** 이 사용자 생성 Quest 에도 고정형 기여 POP Reward Ad 를 확정했다 /// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다. Future _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 { 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( MaterialPageRoute( diff --git a/app/lib/features/quest/quest_list_screen.dart b/app/lib/features/quest/quest_list_screen.dart index 6df835f..11acb43 100644 --- a/app/lib/features/quest/quest_list_screen.dart +++ b/app/lib/features/quest/quest_list_screen.dart @@ -63,13 +63,30 @@ class _QuestListScreenState extends State { super.dispose(); } + /// 헤더 보유 POP. 생성(예치 · 수수료) · 보상 뒤 [_load] 가 지갑을 다시 읽어 맞춘다. + late int _pop = widget.myPop; + Future _load() async { - final cards = await widget.repository.load(); + final List 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 { 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, diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart index a38c902..3e520a3 100644 --- a/app/lib/features/quest/quest_repository_firestore.dart +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -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 {}); + // 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다. + // UGC 퀘스트 하나의 제출 건 조회가 거부되어 **모든 사용자의** 목록이 무한 로딩이었다(2026-10-04). + Map progress; + try { + progress = await db + .doc('users/$uid/questProgress/${doc.id}') + .get() + .then((s) => s.data() ?? const {}); + } 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 _stepTiers(Map policy) { - final raw = policy['stepTiers']; - if (raw is List && raw.isNotEmpty) { - final steps = []; - 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 _submission(String questId, Map 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 popBalance() async { + final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get(); + return wallet.data()?.intOr('activityBalance'); + } + @override Stream progressStream(QuestCard card) { if (card.type != QuestType.steps) return const Stream.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 _stepTiers(Map policy) { + final raw = policy['stepTiers']; + if (raw is List && raw.isNotEmpty) { + final steps = []; + 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 quest, + Map? progress, + Map policy, [ + DateTime? now, +]) { + final p = progress ?? const {}; + 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); +} diff --git a/app/lib/features/quest/widgets/quest_card_tile.dart b/app/lib/features/quest/widgets/quest_card_tile.dart index f36de84..28ce0a7 100644 --- a/app/lib/features/quest/widgets/quest_card_tile.dart +++ b/app/lib/features/quest/widgets/quest_card_tile.dart @@ -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), ); } } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 0604e78..5187b88 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -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", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 9abc607..b33191e 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -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", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index fbe8e63..d63d02d 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -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": "場所を検索", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 96b8e56..48f20fe 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -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": "장소 검색", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index fc604ac..a6f0444 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -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. diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 2005786..340cbcc 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index a81a284..6fc7534 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 8ea87f3..a9cf1b1 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -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 => 'マイクエスト'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index d2071ed..5e4d313 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -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 => '내 퀘스트'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index d580b50..c5caba8 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -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 => '我的任务'; diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 78642d1..0679954 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -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": "搜索地点", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 7d5a78c..b00f6a0 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -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; diff --git a/app/test/app_dialog_test.dart b/app/test/app_dialog_test.dart new file mode 100644 index 0000000..1394576 --- /dev/null +++ b/app/test/app_dialog_test.dart @@ -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> open( + WidgetTester tester, + Future Function(BuildContext) show, + ) async { + final results = []; + 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); + }); +} diff --git a/app/test/auth_error_message_test.dart b/app/test/auth_error_message_test.dart index 8fb61bc..9300fe1 100644 --- a/app/test/auth_error_message_test.dart +++ b/app/test/auth_error_message_test.dart @@ -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` 라 밖에서 직접 부르면 diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart index 602516e..ea72610 100644 --- a/app/test/backend_repository_test.dart +++ b/app/test/backend_repository_test.dart @@ -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(); diff --git a/app/test/f2_screens_test.dart b/app/test/f2_screens_test.dart index 9381069..50a83bb 100644 --- a/app/test/f2_screens_test.dart +++ b/app/test/f2_screens_test.dart @@ -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( + 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); diff --git a/app/test/f3_screens_test.dart b/app/test/f3_screens_test.dart index c07fafb..ec93c6e 100644 --- a/app/test/f3_screens_test.dart +++ b/app/test/f3_screens_test.dart @@ -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 { diff --git a/app/test/f4_screens_test.dart b/app/test/f4_screens_test.dart index 279d9ef..d1e30e8 100644 --- a/app/test/f4_screens_test.dart +++ b/app/test/f4_screens_test.dart @@ -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( - 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 popBalance() async => 12260; +} + +/// 목록이 실패하는 표본 — 로딩이 끝나지 않으면 탭 전체가 무한 로딩이다. +class _FailingList extends SampleQuestRepository { + @override + Future> 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 = []; + var verified = 0; + + @override + Future verify(QuestCard card) async { + verified += 1; + if (verifyError case final error?) throw error; + return super.verify(card); + } @override Future loadRewardedAd() async { diff --git a/app/test/f5_screens_test.dart b/app/test/f5_screens_test.dart index 9a0305a..49a8dbf 100644 --- a/app/test/f5_screens_test.dart +++ b/app/test/f5_screens_test.dart @@ -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, diff --git a/app/test/f6_screens_test.dart b/app/test/f6_screens_test.dart index 11d1209..38b3db5 100644 --- a/app/test/f6_screens_test.dart +++ b/app/test/f6_screens_test.dart @@ -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> mine() async => [ + InsideQuestion( + id: 'mine-1', + title: 'device-check-question', + body: '본문', + authorName: '팬', + createdAt: DateTime(2026, 10, 4), + bountyPop: 100, + isMine: true, + ), + ]; +} diff --git a/app/test/galaxy_home_card_test.dart b/app/test/galaxy_home_card_test.dart new file mode 100644 index 0000000..0c216cb --- /dev/null +++ b/app/test/galaxy_home_card_test.dart @@ -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')); + } + }); +} diff --git a/app/test/galaxy_mini_map_capture_test.dart b/app/test/galaxy_mini_map_capture_test.dart new file mode 100644 index 0000000..95f05bf --- /dev/null +++ b/app/test/galaxy_mini_map_capture_test.dart @@ -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 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 loadAppFonts() async { + final manifest = + json.decode(await rootBundle.loadString('FontManifest.json')) as List; + for (final font in manifest.cast>()) { + final loader = FontLoader(font['family'] as String); + for (final f in (font['fonts'] as List).cast>()) { + 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()); + }); + }); + } +} diff --git a/app/test/galaxy_mini_map_test.dart b/app/test/galaxy_mini_map_test.dart index 5453ff3..070e432 100644 --- a/app/test/galaxy_mini_map_test.dart +++ b/app/test/galaxy_mini_map_test.dart @@ -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(); diff --git a/app/test/home_entry_test.dart b/app/test/home_entry_test.dart new file mode 100644 index 0000000..e53928c --- /dev/null +++ b/app/test/home_entry_test.dart @@ -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> call( + String name, [ + Map payload = const {}, + ]) async => {'code': 'ABCDEFGH23'}; +} + +/// 모금 중 캠페인이 없는 HOME. +class _NoCampaignRepo extends SampleHomeRepository { + const _NoCampaignRepo(); + + @override + Stream watchGrowth(String fandomId) => const Stream.empty(); + + @override + Future 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 _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 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'], + ); + }); + }); +} diff --git a/app/test/home_screen_test.dart b/app/test/home_screen_test.dart index 783eeb3..a0995a6 100644 --- a/app/test/home_screen_test.dart +++ b/app/test/home_screen_test.dart @@ -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 { diff --git a/app/test/i18n_test.dart b/app/test/i18n_test.dart index 14f3e9e..c80c9c4 100644 --- a/app/test/i18n_test.dart +++ b/app/test/i18n_test.dart @@ -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()) { + if (!file.path.endsWith('.arb')) continue; + final arb = jsonDecode(file.readAsStringSync()) as Map; + 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'); + } + } + } + }); } diff --git a/app/test/quest_repository_test.dart b/app/test/quest_repository_test.dart index 0fbcea6..900e877 100644 --- a/app/test/quest_repository_test.dart +++ b/app/test/quest_repository_test.dart @@ -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( diff --git a/app/test/quest_today_test.dart b/app/test/quest_today_test.dart new file mode 100644 index 0000000..d951fb8 --- /dev/null +++ b/app/test/quest_today_test.dart @@ -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 = {}; + + 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'); + }); +}