사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정

This commit is contained in:
Sn-Kinos
2026-10-05 00:44:39 +09:00
parent c8d29d032b
commit 8a0297eb92
66 changed files with 1812 additions and 959 deletions
+1
View File
@@ -179,6 +179,7 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
push: repos.push,
auth: auth,
campaigns: repos.campaign,
insides: repos.inside,
),
),
],
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import '../i18n/app_locale.dart';
import '../theme/tokens.dart';
/// 앱 공통 확인 창 — Figma `2066:17799`(행성 생성 확인) · `554:24669`(설정 확인 팝업).
///
/// 카드 312 폭 · #282828 r12 · 안쪽 24/16 · 문구 16/600 가운데 정렬,
/// 그 아래 **취소(흰 테두리) · 실행(분홍 채움)을 가로로 반씩**(48 높이 · 간격 8 · r8).
/// 단일 버튼 알림(Figma `511:86714` · `554:23103`)은 실행 버튼 하나가 폭을 다 쓴다.
///
/// [content] 는 입력 칸이 있는 창(반려 사유 · 인증 제출 등)에서 문구 대신 넣는다.
class AppDialog extends StatelessWidget {
const AppDialog({
super.key,
required this.title,
this.body,
this.content,
this.cancelLabel,
required this.confirmLabel,
});
final String title;
final String? body;
final Widget? content;
/// null 이면 실행 버튼 하나만 둔다.
final String? cancelLabel;
final String confirmLabel;
static const _shape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)),
);
static TextStyle _text(double size, FontWeight weight, Color color) =>
TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: weight,
height: 1.5,
letterSpacing: size * -0.025,
color: color,
);
@override
Widget build(BuildContext context) {
Widget button(String label, bool value, {required bool filled}) {
final text = Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _text(
16,
FontWeight.w600,
filled ? AppColors.onCosmicAccent : AppColors.onCosmic,
).copyWith(height: 1),
);
void pop() => Navigator.of(context).pop(value);
return Expanded(
child: filled
? FilledButton(
onPressed: pop,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: const EdgeInsets.symmetric(horizontal: 12),
backgroundColor: AppColors.cosmicAccent,
shape: _shape,
),
child: text,
)
: OutlinedButton(
onPressed: pop,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: const EdgeInsets.symmetric(horizontal: 12),
side: const BorderSide(color: AppColors.onCosmic),
shape: _shape,
),
child: text,
),
);
}
return Dialog(
backgroundColor: const Color(0xFF282828), // Figma 2066:17799 fill
insetPadding: const EdgeInsets.symmetric(horizontal: 31.5, vertical: 24),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: _text(16, FontWeight.w600, AppColors.onCosmic),
),
if (body case final b?) ...[
const SizedBox(height: 16),
Text(
b,
textAlign: TextAlign.center,
style: _text(16, FontWeight.w500, AppColors.onCosmic)
.copyWith(height: 1.4),
),
],
if (content case final c?) ...[
const SizedBox(height: 16),
c,
],
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
if (cancelLabel case final c?) ...[
button(c, false, filled: false),
const SizedBox(width: 8),
],
button(confirmLabel, true, filled: true),
],
),
],
),
),
);
}
}
const _barrier = Color(0xB3000000);
/// 취소 · 실행 두 갈래. 실행이면 true. [confirmLabel] 기본은 「변경」.
Future<bool> showConfirmDialog(
BuildContext context,
String title, {
String? body,
Widget? content,
String? cancelLabel,
String? confirmLabel,
}) async {
final r = await showDialog<bool>(
context: context,
barrierColor: _barrier,
builder: (context) => AppDialog(
title: title,
body: body,
content: content,
cancelLabel: cancelLabel ?? context.l10n.commonCancel,
confirmLabel: confirmLabel ?? context.l10n.commonChange,
),
);
return r ?? false;
}
/// 확인 하나뿐인 알림 — Figma `511:86714`(타이틀 가운데 · 확인 버튼 가운데).
Future<void> showAlertDialog(
BuildContext context,
String title, {
String? body,
String? confirmLabel,
}) => showDialog<void>(
context: context,
barrierColor: _barrier,
builder: (context) => AppDialog(
title: title,
body: body,
confirmLabel: confirmLabel ?? context.l10n.commonConfirm,
),
);
-39
View File
@@ -1,39 +0,0 @@
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// 표본 데이터 알림(G-08) — 본문을 밀지 않도록 앱바 오른쪽에 작은 「MOCK」 알약으로 둔다.
/// 사유 문구는 길게 눌렀을 때와 스크린리더로 읽힌다.
class MockTag extends StatelessWidget {
const MockTag({super.key, required this.note});
final String note;
@override
Widget build(BuildContext context) => Tooltip(
message: note,
child: Semantics(
label: 'MOCK · $note',
excludeSemantics: true,
child: Container(
margin: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.neonViolet),
),
child: Text(
'MOCK',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 10,
fontWeight: FontWeight.w600,
height: 1.2,
letterSpacing: -0.25,
color: AppColors.neonViolet,
),
),
),
),
);
}
+1 -1
View File
@@ -87,7 +87,7 @@ final class AuthNotConfigured extends AuthOutcome {
final AuthProviderKind provider;
/// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다.
/// 투입해야 하는 것들(파일명·키명). 개발자용이라 로그·README 에만 쓰고 화면에는 내보내지 않는다.
final List<String> missing;
}
+9 -22
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/firebase/firebase_boot.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
@@ -232,23 +234,11 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
final name = outcome.provider.shortName;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.authNotConfiguredTitle(name)),
content: Text(
context.l10n.authNotConfiguredBody(
name,
outcome.missing.join('\n· '),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.commonConfirm),
),
],
),
await showAlertDialog(
context,
context.l10n.authNotConfiguredTitle(name),
// 설정 항목(outcome.missing)은 개발자용이라 화면에 내보내지 않는다.
body: context.l10n.authNotConfiguredBody(name),
);
}
@@ -381,11 +371,8 @@ String? authNotice(AuthOutcome outcome, [AppLocalizations? l10n]) =>
AuthSucceeded() => null,
AuthCancelled() => null,
AuthFailed(:final code, :final message) => '[$code] $message',
AuthNotConfigured(:final provider, :final missing) =>
(l10n ?? koL10n).authNotConfiguredNotice(
provider.shortName,
missing.join(' · '),
),
AuthNotConfigured(:final provider) =>
(l10n ?? koL10n).authNotConfiguredNotice(provider.shortName),
};
TextStyle _abz(double size, Color color) => TextStyle(
@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/data/backend.dart' show BackendException;
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/mock_tag.dart';
import '../home/home_screen.dart' show formatPop;
import 'campaign_data.dart';
import 'widgets/campaign_period_sheet.dart';
@@ -123,8 +124,6 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
),
title: Text(context.l10n.campaignNew),
actions: [
// G-08 — 지원 종류 가격은 인스타 광고 300,000 외 자리표시다.
const MockTag(note: '상품 목록은 Catalog 연결 전 표본입니다'),
TextButton(
onPressed: _ready && !_saving ? _confirm : null,
// 시안 — 오른쪽 끝에서 16(x 335).
@@ -457,31 +456,19 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
);
Future<void> _confirm() async {
final go = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.campaignSubmitTitle),
content: Text(
context.l10n.campaignSubmitBody(
_title.text.trim(),
formatPop(_goalPop),
'$_days',
formatPop(campaignRegistrationFeePop),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonKeepEditing),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.campaignSubmit),
),
],
final go = await showConfirmDialog(
context,
context.l10n.campaignSubmitTitle,
body: context.l10n.campaignSubmitBody(
_title.text.trim(),
formatPop(_goalPop),
'$_days',
formatPop(campaignRegistrationFeePop),
),
cancelLabel: context.l10n.commonKeepEditing,
confirmLabel: context.l10n.campaignSubmit,
);
if (go != true || !mounted) return;
if (!go || !mounted) return;
setState(() => _saving = true);
final String id;
@@ -511,18 +498,10 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
}
if (!mounted) return;
// 올린 캠페인은 심사 전이라 목록에 보이지 않는다 — 어디 갔는지 알려 준다.
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.campaignPendingTitle),
content: Text(context.l10n.campaignPendingBody),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.commonConfirm),
),
],
),
await showAlertDialog(
context,
context.l10n.campaignPendingTitle,
body: context.l10n.campaignPendingBody,
);
if (mounted) Navigator.of(context).pop(id);
}
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
@@ -935,32 +937,19 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
/// 철회 확인 — 등록비 반환 여부를 먼저 알린다(B-58 · 공개 전만 반환).
Future<void> _withdraw(CampaignCardData card) async {
final beforePublish = card.status == CampaignStatus.review;
final go = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.campaignWithdrawTitle),
content: Text(
beforePublish
final go = await showConfirmDialog(
context,
context.l10n.campaignWithdrawTitle,
confirmLabel: context.l10n.campaignWithdrawConfirm,
body: beforePublish
? context.l10n.campaignWithdrawRefund(
formatPop(campaignRegistrationFeePop),
)
: context.l10n.campaignWithdrawNoRefund(
formatPop(campaignRegistrationFeePop),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.campaignWithdrawConfirm),
),
],
),
);
if (go != true || !mounted) return;
if (!go || !mounted) return;
try {
await widget.repository.withdraw(card.id);
} on Object catch (error) {
+1 -1
View File
@@ -65,7 +65,7 @@ class EclipseState {
/// 방어 마감. 없으면 남은 시간을 띄우지 않는다.
final DateTime? endsAt;
/// 실데이터가 아닌 경우의 사유. 화면이 그대로 배지로 띄운다(대조표 3-5).
/// 실데이터가 아닌 경우의 사유. 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
final String? mockNote;
bool get isMock => mockNote != null;
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/live_stream.dart';
import '../../home/home_screen.dart' show formatPop;
import '../../planet/fpi_ranking_screen.dart' show MockBadge;
import '../eclipse_data.dart';
import '../../../core/i18n/app_locale.dart';
@@ -136,9 +135,6 @@ class EclipseBannerCard extends StatelessWidget {
),
style: t.labelSmall,
),
// 실데이터가 아니면 숨기지 않는다 — 대조표 3-5 · 공통 규칙 (4).
if (state.mockNote case final note?)
MockBadge(note: note),
],
),
),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../core/data/planet_visual_state.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart';
import '../notification/notification_screen.dart';
import '../eclipse/eclipse_data.dart';
@@ -25,6 +26,15 @@ import '../../core/i18n/app_locale.dart';
/// 궤도 맵([GalaxyScreen])은 시안의 갤럭시맵 명세(HUD · 3D 갤럭시)를 구현한 화면이라
/// 그대로 두고, 카드나 우측 상단 목록 아이콘에서 들어간다.
class GalaxyHomeScreen extends StatefulWidget {
/// 카드 그림 — HOME · 맵과 같이 팬덤 `stage` 로 고른다. Stage0 성운만 성운 그림이고
/// 정식 행성은 시안 행성 그림(`image 8` · 파란 행성 벡터)을 칸마다 번갈아 쓴다.
/// 예전엔 칸 순서로 골라 둘째 칸이 행성이어도 성운 그림이 됐다.
static String cardImageOf(PlanetNode p, int i) => p.stage == 0
? 'assets/figma/nebula-card.png'
: (i.isEven
? 'assets/figma/planet-detail-hero.png'
: 'assets/figma/planet-earth.png');
const GalaxyHomeScreen({
super.key,
this.watchPlanet = noPlanetWatch,
@@ -247,7 +257,7 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
Expanded(
child: _PlanetCard(
planet: p,
image: _imageFor(i),
image: GalaxyHomeScreen.cardImageOf(p, i),
selected: p.isMine,
onTap: () => _choose(p),
),
@@ -279,13 +289,6 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
),
);
}
/// 카드 그림. 시안 세 칸(`image 8` · `image 15` · 파란 행성 벡터)을 순서대로 쓴다.
static String _imageFor(int i) => switch (i) {
0 => 'assets/figma/planet-detail-hero.png',
1 => 'assets/figma/nebula-card.png',
_ => 'assets/figma/planet-earth.png',
};
}
/// 행성 카드 — 시안 109×109 #2c2c2c r12, 그림 88(안쪽 11·10), 선택은 #ff7ddd 테두리와
+13 -6
View File
@@ -31,7 +31,8 @@ import '../../core/i18n/app_locale.dart';
/// 화면 대조 원장 F2 의 2번. **09-03 GALAXY 2.5D 1차 시연 대상**이다.
///
/// 명세대로 갖춘 것
/// - 3d갤럭시 브라우징 줌인·줌아웃, 터치 이동 → [InteractiveViewer]
/// - 3d갤럭시 브라우징 줌인·줌아웃 → [InteractiveViewer] 핀치. 터치 이동은 행성 탭 중심
/// 전환이 맡고, 한 손가락 끌기는 궤도 회전이다(맵 팬 없음 — 장면이 화면 밖으로 밀리지 않게)
/// - 내 행성 한가운데, 행성 터치 시 화면 중심 전환 이동
/// - HUD 에 Rank · FPI 총점 · 접속자수, 터치 시 FPI 상세로
/// - GALAXY 텍스트 좌측 하단 · 돋보기 우측 하단 → 검색 화면
@@ -40,8 +41,7 @@ import '../../core/i18n/app_locale.dart';
///
/// 2026-09-09 검토회신서 3절이 지적한 것과 그 답
/// - *드래그·회전에 관성과 감속이 없다* → 궤도 각속도를 [GalaxyClock] 이 물리로
/// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다. 이동(팬)의
/// 관성은 [InteractiveViewer] 가 이미 가진 플링을 그대로 쓴다.
/// 굴린다. 드래그가 밀고, 손을 떼면 마찰이 기본 표류로 되돌린다.
/// - *행성 간 이동감이 없다* → 중심 전환이 순간이동이 아니라
/// [GalaxyMotion.recenter] 동안 감속하며 날아간다. 목표 좌표는 매 프레임 다시
/// 잡으므로 도는 행성도 정확히 잡는다.
@@ -82,6 +82,10 @@ class _GalaxyScreenState extends State<GalaxyScreen>
late Future<GalaxySnapshot> _snapshot;
final _clock = GalaxyClock();
@visibleForTesting
GalaxyClock get clockForTest => _clock;
final _view = TransformationController();
late final Ticker _ticker;
@@ -394,9 +398,12 @@ class _Map extends StatelessWidget {
minScale: GalaxyMotion.minZoom,
maxScale: GalaxyMotion.maxZoom,
boundaryMargin: const EdgeInsets.all(240),
// 이동(팬)의 관성은 InteractiveViewer 의 플링이 이미 준다.
// 여기서 더하는 것은 **회전 관성**이다 — 가로로 끌면 궤도가 따라
// 돌고, 손을 떼면 마찰이 기본 표류로 되돌린다.
// 한 손가락 끌기는 맵을 옮기지 않고 **궤도를 돌린다**(프로토타입 View
// Rotation · 별첨 1 ①-3 드래그 360° 회전). 팬을 켜 두면 가로로 끌 때
// 장면이 통째로 밀려 화면 밖으로 사라진다(10/4 실기기). 손을 떼면
// [GalaxyClock] 마찰이 기본 표류로 되돌린다(관성 · 체크리스트 3-2).
// 핀치 줌과 행성 탭 중심 전환은 그대로다.
panEnabled: false,
onInteractionStart: (_) => onGrab(),
// 세로 드래그·핀치 줌까지 넘기면 각속도를 덮어써 궤도가 선다.
onInteractionUpdate: (d) => onDrag(
@@ -19,6 +19,8 @@ import '../../../core/i18n/app_locale.dart';
/// - **게이지 조절 8개는 넣지 않는다(C-51).**
/// - 색은 테마 결정 전까지 프로토타입 값을 쓴다(C-49).
/// - 데이터는 받은 [GalaxySnapshot] 만 쓴다. 프로토타입의 표본 5행성은 쓰지 않는다.
/// - 궤도는 FPI 순위, 첫 화면 시점은 내 행성 중앙(C-103). 140pt 에서는 내 행성 간략 카드
/// 한 장만, 다른 행성 HUD 없음(C-105). 드래그 회전은 관성 없음(C-105).
///
/// 반응: 스냅샷이 바뀌면(순위·FPI) 행성이 새 궤도로 미끄러지고 HUD 숫자가 바뀐다(프로토타입
/// `updateFPI`). 가로로 끌면 장면이 돈다(±180°, 프로토타입 View Rotation 범위). 누르면 [onOpen].
@@ -33,7 +35,7 @@ class GalaxyMiniMap extends StatefulWidget {
final GalaxySnapshot data;
final VoidCallback onOpen;
/// 정사각 한 변. HOME 행성 영역 우측 상단(Q-DS-06 권고안 A · 회신 전 임시).
/// 정사각 한 변. HOME 은 약 140pt(C-105 · 자리는 C-113 → `HomeHero.miniMapAlignment`).
final double size;
@override
@@ -172,6 +174,10 @@ final _tanHalfFov = math.tan(35 * math.pi / 180);
const _hudW = 512.0;
const _hudH = 256.0;
/// 별·위성 점 크기(논리 px). 프로토타입 점은 원근으로 1px 아래로 줄어 GL 최소 크기(버퍼
/// 1px, 픽셀비 2 → 0.5pt)로 찍힌다. 0 이면 1 물리 px 이라 3x 기기에서 1/3 로 옅어진다.
const _dot = 0.4;
/// 1 논리 픽셀보다 가는 선은 머리카락 선(1 물리 픽셀)으로.
double _px(double w) => w < 1 ? 0 : w;
@@ -293,18 +299,22 @@ ui.Picture _hudPicture(PlanetNode p, Color color, double share) {
return rec.endRecording();
}
/// 반지름 1 단위의 광륜(6R, 가산 합성) + 발광 구체.
/// 프로토타입 광륜 반지름(행성 반지름 배수). `aura.scale = R × curScale × 6` 이 다시
/// curScale(Mass x1.5)로 커지는 group 안에 있어 지름은 `6 × 1.5 R` 이다.
const _auraR = 3 * _massVolume;
/// 반지름 1 단위의 광륜(가산 합성) + 발광 구체.
ui.Picture _spherePicture(Color c) {
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
canvas.drawCircle(
Offset.zero,
3,
_auraR,
Paint()
..blendMode = BlendMode.plus
..shader = ui.Gradient.radial(
Offset.zero,
3,
_auraR,
[
c.withValues(alpha: 0.4),
c.withValues(alpha: 0.15),
@@ -360,19 +370,22 @@ class GalaxyMiniMapPainter extends CustomPainter {
/// 그리는 행성 수(내 행성은 순위 밖이어도 더한다).
static const maxPlanets = 10;
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비).
static const hudFloor = 0.7;
/// 간략 카드(내 행성)의 최대 폭(미니맵 폭 대비). 넘치면 카드를 통째로 줄인다(숫자는 자르지 않는다).
static const hudFloor = 0.55;
/// 이 폭 이상이면 프로토타입 HUD 카드를 모든 행성에 그대로 그린다.
static const fullHudMin = 280.0;
/// 간략 카드 글자 크기(pt) — 이름 · 둘째 줄.
static const compactNamePt = 11.0;
static const compactLinePt = 9.0;
/// 간략 카드 글자 크기(pt) — 이름 · 숫자 줄. 프로토타입 텍스처의 줄 구성(이름 / RANK · FPI /
/// LIVE)을 세 줄로 줄인 것이다. 140pt 에서 카드가 장면을 덮지 않게 한 줄을 짧게 둔다.
static const compactNamePt = 9.0;
static const compactLinePt = 8.0;
/// 간략 카드 둘째 줄 — `#순위 · FPI n · LIVE n`.
static String compactLine(PlanetNode p) =>
'#${p.rank} · FPI ${formatPop(p.fpi)} · LIVE ${formatPop(p.online)}';
/// 간략 카드 숫자 줄 — `#순위 · FPI n` / `LIVE n`.
static List<String> compactLines(PlanetNode p) => [
'#${p.rank} · FPI ${formatPop(p.fpi)}',
'LIVE ${formatPop(p.online)}',
];
ui.Picture? _compact;
Object? _compactKey;
@@ -388,42 +401,49 @@ class GalaxyMiniMapPainter extends CustomPainter {
if (key == _compactKey && _compact != null) return _compact!;
_compact?.dispose();
_compactKey = key;
const pad = 6.0, barW = 3.0, gap = 4.0;
final textMax = maxW - pad * 2 - barW - gap;
TextPainter line(String text, double pt, FontWeight weight, Color c) =>
TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: pt,
fontWeight: weight,
height: 1.2,
color: c,
),
),
textDirection: TextDirection.ltr,
maxLines: 2,
ellipsis: '…',
)..layout(maxWidth: textMax);
final name = line(
b.node.name.toUpperCase(),
compactNamePt,
FontWeight.w800,
b.color,
);
// 좁으면 LIVE 앞에서 둘째 줄이 한 번 꺾인다(글자 크기는 줄이지 않는다).
final stats = line(
compactLine(b.node),
compactLinePt,
FontWeight.w700,
const Color(0xE6FFFFFF),
);
final textW = math.max(name.width, stats.width);
const pad = 4.0, barW = 2.0, gap = 3.0;
TextPainter line(
String text,
double pt,
FontWeight weight,
Color c, [
double maxWidth = double.infinity,
]) => TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: pt,
fontWeight: weight,
height: 1.15,
color: c,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '…',
)..layout(maxWidth: maxWidth);
final texts = [
(b.node.name.toUpperCase(), compactNamePt, FontWeight.w800, b.color),
for (final t in compactLines(b.node))
(t, compactLinePt, FontWeight.w700, const Color(0xE6FFFFFF)),
];
final stats = [
for (final (t, pt, w, c) in texts.skip(1)) line(t, pt, w, c),
];
final statsW = stats.fold<double>(0, (m, l) => math.max(m, l.width));
// 긴 이름은 말줄임 — 카드 폭은 숫자 줄(또는 최대 폭)이 정한다.
final (nt, npt, nw, nc) = texts.first;
final nameMax = math.max(statsW, maxW - pad * 2 - gap - barW);
final lines = [line(nt, npt, nw, nc, nameMax), ...stats];
final textW = lines.fold<double>(0, (m, l) => math.max(m, l.width));
final textH = lines.fold<double>(0, (m, l) => m + l.height);
final w = pad * 2 + textW + gap + barW;
final h = pad * 2 + name.height + 2 + stats.height;
final h = pad * 2 + textH;
// 숫자 줄이 최대 폭을 넘을 때만 카드를 줄인다 — 줄을 자르면 순위·FPI 숫자가 사라진다.
final k = math.min(1.0, maxW / w);
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
final canvas = Canvas(rec)..scale(k);
final box = Rect.fromLTWH(0, 0, w, h);
canvas.drawRect(box, Paint()..color = const Color(0xE6000514));
canvas.drawRect(
@@ -433,8 +453,13 @@ class GalaxyMiniMapPainter extends CustomPainter {
..strokeWidth = 1
..color = b.color.withValues(alpha: 0.8),
);
name.paint(canvas, const Offset(pad, pad));
stats.paint(canvas, Offset(pad, pad + name.height + 2));
var y = pad;
for (final l in lines) {
l.paint(canvas, Offset(pad, y));
y += l.height;
l.dispose();
}
// 프로토타입 막대 `160 * fpi / 5000` — 1위 대비 비율.
final barH = h - pad * 2;
canvas.drawRect(
Rect.fromLTWH(
@@ -445,13 +470,8 @@ class GalaxyMiniMapPainter extends CustomPainter {
),
Paint()..color = _emerald,
);
compactText = [
(b.node.name.toUpperCase(), compactNamePt),
(compactLine(b.node), compactLinePt),
];
name.dispose();
stats.dispose();
_compactSize = Size(w, h);
compactText = [for (final (t, pt, _, _) in texts) (t, pt * k)];
_compactSize = Size(w * k, h * k);
return _compact = rec.endRecording();
}
@@ -466,6 +486,15 @@ class GalaxyMiniMapPainter extends CustomPainter {
@visibleForTesting
Color? colorOf(String id) => _bodies[id]?.color;
/// 행성의 현재 월드 좌표(x, z). 장면 원점 = 화면 중앙. 없으면 null.
@visibleForTesting
Offset? worldOf(String id) =>
_bodies[id] == null ? null : Offset(_bodies[id]!.wx, _bodies[id]!.wz);
/// 마지막으로 그린 간략 카드 크기(논리 px).
@visibleForTesting
Size get compactSize => _compactSize;
/// 행성의 현재 궤도 반지름(월드 단위). 없으면 null.
@visibleForTesting
double? orbitOf(String id) => _bodies[id]?.rad;
@@ -607,13 +636,14 @@ class GalaxyMiniMapPainter extends CustomPainter {
..shader = ui.Gradient.radial(
center,
r,
const [Color(0x0A10B981), Color(0x033B82F6), Color(0x00000000)],
const [Color(0x0410B981), Color(0x013B82F6), Color(0x00000000)],
const [0, 0.4, 0.8],
),
);
canvas.restore();
}
// 프로토타입 별 8000개. 화면 넓이당 밀도는 390×844 화면 기준으로 맞춘다.
// 프로토타입 별 8000개. 미니맵은 휴대폰 전체 화면 장면의 축소판으로 보이게, 화면 넓이당
// 밀도를 390×844 기준으로 맞춘다(덜어내지 않으면 140pt 에 별이 5배 빽빽하다).
final keep = (size.width * size.height / (390 * 844)).clamp(0.0, 1.0);
final pts = <double>[];
for (var i = 0; i < 8000; i++) {
@@ -632,7 +662,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
ui.PointMode.points,
Float32List.fromList(pts),
Paint()
..strokeWidth = 0
..strokeWidth = _dot
..color = const Color(0xCCBFBFFF),
);
_bg = rec.endRecording();
@@ -708,11 +738,9 @@ class GalaxyMiniMapPainter extends CustomPainter {
}
if (out.isNotEmpty) {
_fill
..strokeWidth = 0
..strokeWidth = _dot
..blendMode = BlendMode.plus
..color = b.color.withValues(
alpha: 0.9 * (2 * _scale * focal / d).clamp(0.2, 1.0),
);
..color = b.color.withValues(alpha: 0.9);
canvas.drawRawPoints(ui.PointMode.points, out, _fill);
_fill.blendMode = BlendMode.srcOver;
}
@@ -5,6 +5,7 @@ import '../../core/data/invite_code.dart';
import '../../core/data/server_clock.dart';
import 'home_data.dart';
import '../quest/quest_data.dart' show QuestType;
import '../quest/quest_repository_firestore.dart' show questToday;
import '../../core/i18n/app_locale.dart';
/// 홈의 실 서버 구현 — 화면 대조 원장 F1.
@@ -137,10 +138,13 @@ class FirestoreHomeRepository implements HomeRepository {
.collection('heroGiftProducts')
.where('active', isEqualTo: true)
.get();
// 인기 K-INSIDE — 기능명세서 v1.4 §2.114-7 「최대 3개 소팅(미해결 + 고액 포인트 순)」.
// 미해결(`open`) 먼저, 그 안에서 현상금 큰 순. 정렬은 아래 [popularInsides].
// ponytail: 현상금 상위 20건 안에서만 고른다. (status, bountyPop) 색인을 배포하면 질의로 옮긴다.
final insidesF = db
.collection('questions')
.orderBy('createdAt', descending: true)
.limit(3)
.orderBy('bountyPop', descending: true)
.limit(20)
.get();
final wallet = (await walletF).data() ?? const <String, dynamic>{};
@@ -167,7 +171,9 @@ class FirestoreHomeRepository implements HomeRepository {
final campaigns = (await campaignF).docs
.where((d) => !blocked.contains(d.data()['creatorUid']))
.toList();
final insides = (await insidesF).docs;
final insides = popularInsides([
for (final doc in (await insidesF).docs) doc.data(),
]);
final progressById = {for (final doc in progress) doc.id: doc.data()};
final policy = (await policyF).data() ?? const <String, dynamic>{};
@@ -185,7 +191,7 @@ class FirestoreHomeRepository implements HomeRepository {
],
briefings: [for (final doc in briefings) ?_briefing(doc.data())],
campaign: campaigns.isEmpty ? null : _campaign(campaigns.first.data()),
insides: [for (final doc in insides) _inside(doc.data())],
insides: [for (final data in insides) _inside(data)],
items: [for (final doc in (await itemsF).docs) _item(doc.id, doc.data())],
);
}
@@ -284,7 +290,8 @@ class FirestoreHomeRepository implements HomeRepository {
members: validInt(fandom['memberCount']),
requiredMembers: targets?.members,
// Q-PN-01 A안 — 탄생은 창립자만.
isFounder: fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
isFounder:
fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
requiredSfpi: validNumber(next?['requiredSfpi']),
nebulaGoalPassed: goalPassed,
);
@@ -296,9 +303,10 @@ class FirestoreHomeRepository implements HomeRepository {
Map<String, dynamic>? progress,
Map<String, dynamic> policy,
) {
final done =
(progress?.intOr('count') ?? 0) >= quest.intOr('dailyLimit', 1);
final started = (progress?.intOr('count') ?? 0) > 0;
// 퀘스트 목록 · 상세와 같은 판정(`questToday`) — 오늘(KST) 행만 센다.
final (:count, :done) =
questToday(QuestType.parse(quest.strOr('type')), quest, progress, policy);
final started = count > 0;
return QuestSummary(
title: quest.strOr('title'),
rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
@@ -354,6 +362,15 @@ String _timeAgo(DateTime at) {
return appL10n.timeDaysAgo('${gap.inDays}');
}
/// 인기 K-INSIDE 3건 — 미해결(`status == open`) 먼저, 같은 묶음 안에서는 현상금 큰 순.
/// 입력이 현상금 내림차순이라 미해결 묶음만 앞으로 옮기면 된다.
List<Map<String, dynamic>> popularInsides(
List<Map<String, dynamic>> byBounty,
) => [
...byBounty.where((q) => q['status'] == 'open'),
...byBounty.where((q) => q['status'] != 'open'),
].take(3).toList();
/// 오늘 날짜(KST `YYYY-MM-DD`) — 서버 `briefings.dayKey` 와 같은 규약이다.
///
/// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이
+92 -28
View File
@@ -31,6 +31,7 @@ import 'widgets/item_sheet.dart';
import 'widgets/inside_card.dart';
import 'widgets/live_ticker.dart';
import '../campaign/campaign_data.dart' show CampaignRepository;
import '../campaign/campaign_create_screen.dart';
import '../campaign/campaign_list_screen.dart';
import '../notification/notification_screen.dart';
import '../quest/quest_data.dart' show QuestRepository;
@@ -150,19 +151,22 @@ class _HomeScreenState extends State<HomeScreen> {
debugPrint('[HOME] load failed: ${snap.error}');
return SafeArea(child: _LoadFailed(onRetry: _refresh));
}
return _HomeBody(
data: snap.data!,
galaxy: _galaxy,
zap: _zap,
repository: widget.repository,
watchPlanet: widget.watchPlanet,
planetRepository: widget.planetRepository,
onRefresh: _refresh,
onCreate: _createThenReload,
questRepository: widget.questRepository,
campaignRepository: widget.campaignRepository,
eclipse: widget.eclipse,
notifications: widget.notifications,
return _StatusBarScrim(
height: MediaQuery.paddingOf(context).top,
child: _HomeBody(
data: snap.data!,
galaxy: _galaxy,
zap: _zap,
repository: widget.repository,
watchPlanet: widget.watchPlanet,
planetRepository: widget.planetRepository,
onRefresh: _refresh,
onCreate: _createThenReload,
questRepository: widget.questRepository,
campaignRepository: widget.campaignRepository,
eclipse: widget.eclipse,
notifications: widget.notifications,
),
);
},
),
@@ -352,7 +356,7 @@ class _HomeBody extends StatelessWidget {
zap == null
? null
: GalaxyMiniMap(
size: 140,
size: HomeHero.miniMapSize,
data: galaxyState.data!,
onOpen: () =>
Navigator.of(context, rootNavigator: true).push(
@@ -531,24 +535,39 @@ class _HomeBody extends StatelessWidget {
const SizedBox(height: _sectionGap),
],
if (data.campaign case final c?) ...[
Padding(
padding: _h,
child: SectionHeader(
title: 'HOT ARCHIVE',
onMore: () => _openCampaigns(context, campaignRepository),
),
// HOT ARCHIVE 는 모금 중 캠페인이 없어도 둔다 — 기능명세서 v1.4 §2.114-6
// (`2097:16415`) 「더보기 버튼 - HOT ARCHIVE 화면 이동」에 숨김 규칙이 없고,
// 캠페인 목록·개설로 가는 길이 이 구획뿐이다(공통 기준 5항).
Padding(
padding: _h,
child: SectionHeader(
title: 'HOT ARCHIVE',
onMore: () => _openCampaigns(context, campaignRepository),
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: CampaignCard(
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: switch (data.campaign) {
final c? => CampaignCard(
campaign: c,
onTap: () => _openCampaigns(context, campaignRepository),
),
),
const SizedBox(height: _sectionGap),
],
// 0 건 — 「새 캠페인」(Figma `179:29123` 캠페인 개설)으로 바로 간다.
null => Center(
child: CampaignFab(
label: context.l10n.campaignNew,
onTap: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) =>
CampaignCreateScreen(repository: campaignRepository),
),
),
),
),
},
),
const SizedBox(height: _sectionGap),
Padding(
padding: _h,
@@ -575,6 +594,51 @@ class _HomeBody extends StatelessWidget {
}
}
/// 상태 표시줄 가림막 — 히어로는 상태 표시줄 뒤까지 배경을 깔아야 해서 목록에
/// SafeArea 를 주지 않는다. 대신 세로로 스크롤되면 상태 표시줄 자리를 화면 배경색으로
/// 덮어, 아래 구획 머리글(TODAY'S QUEST 등)이 시계·배터리와 겹쳐 그려지지 않게 한다.
class _StatusBarScrim extends StatefulWidget {
const _StatusBarScrim({required this.height, required this.child});
final double height;
final Widget child;
@override
State<_StatusBarScrim> createState() => _StatusBarScrimState();
}
class _StatusBarScrimState extends State<_StatusBarScrim> {
bool _scrolled = false;
bool _onScroll(ScrollNotification n) {
// 퀘스트 카드의 가로 스크롤은 무시한다 — 바깥 세로 목록만 본다.
if (n.depth != 0 || n.metrics.axis != Axis.vertical) return false;
final scrolled = n.metrics.pixels > 0;
if (scrolled != _scrolled) setState(() => _scrolled = scrolled);
return false;
}
@override
Widget build(BuildContext context) => Stack(
fit: StackFit.expand,
children: [
NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: widget.child,
),
if (_scrolled)
Positioned(
key: const ValueKey('home-status-scrim'),
top: 0,
left: 0,
right: 0,
height: widget.height,
child: const ColoredBox(color: AppColors.cosmicVoid),
),
],
);
}
/// 상단 활동 POP 잔액. 정책 설계서 §P-02 — 화면 표기는 「활동/지원」이다.
class _PopBalance extends StatelessWidget {
const _PopBalance({this.value});
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart';
import '../../core/data/backend.dart' show BackendException;
import 'home_data.dart';
@@ -406,38 +407,3 @@ class _Target extends StatelessWidget {
],
);
}
/// 취소 / 변경 두 갈래.
Future<bool> showConfirmDialog(BuildContext context, String message) async {
final r = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.commonChange),
),
],
),
);
return r ?? false;
}
/// 확인 하나뿐인 알림.
Future<void> showAlertDialog(BuildContext context, String message) => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
content: Text(message),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.commonConfirm),
),
],
),
);
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../core/data/backend.dart' show BackendException;
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/planet_25d.dart' show planetColors;
import 'home_data.dart';
@@ -245,12 +246,12 @@ class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
);
Future<void> _confirm() async {
final ok = await showDialog<bool>(
context: context,
barrierColor: const Color(0xB3000000),
builder: (context) => const _ConfirmDialog(),
final ok = await showConfirmDialog(
context,
context.l10n.planetCreateConfirm,
confirmLabel: context.l10n.commonCreate,
);
if (ok != true || !mounted || _busy) return;
if (!ok || !mounted || _busy) return;
_busy = true;
try {
await widget.repository.createPlanet(
@@ -356,67 +357,3 @@ class _Button extends StatelessWidget {
);
}
/// 사본 `Frame 1984080943` — 312 폭 #282828 r12 · 패딩 24/16 · 취소(흰 1 테두리) / 생성(분홍).
class _ConfirmDialog extends StatelessWidget {
const _ConfirmDialog();
@override
Widget build(BuildContext context) => Dialog(
backgroundColor: const Color(0xFF282828),
insetPadding: const EdgeInsets.symmetric(horizontal: 31),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.planetCreateConfirm,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.5,
color: Color(0xFFF8F8F8),
),
),
const SizedBox(height: AppSpace.s4),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context, false),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.onCosmic,
side: const BorderSide(color: AppColors.onCosmic),
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
child: Text(context.l10n.commonCancel),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
child: Text(context.l10n.commonCreate),
),
),
],
),
],
),
),
);
}
+21 -4
View File
@@ -78,10 +78,21 @@ class HomeHero extends StatefulWidget {
/// ZAP 옵션 「아이템」 — 사본 「아이템 선택」 시트(`343:204868`).
final VoidCallback? onItem;
/// GALAXY 미니맵 — 행성 영역 우측 상단(해석 확인 요청서 Q-DS-06 권고안 A · 회신 전 임시).
/// 사본 홈에는 없는 요소라 행성 칸 모서리에만 얹고 문구·카드와 겹치지 않게 한다.
/// GALAXY 미니맵 — 히어로 우측 상단 [miniMapTop] · 오른쪽 끝(여백 0).
final Widget? miniMap;
/// 미니맵 자리(C-113 「최신 확정 HOME Figma 위치」). Figma `89rQgL1JEFlA8yxl9L77bS`
/// 「홈」(`2097:16428`) 375pt 실측 — 헤더(y0~124) 바로 아래 히어로 본문 시작점, 인사 문구
/// (「1,230 POP 기여로…」) 줄과 같은 높이, 오른쪽 끝에 붙음(여백 0).
static const Alignment miniMapAlignment = Alignment.topRight;
/// 미니맵 위 변 — 상태 표시줄 아래 기준 사본 y(히어로 본문 `Frame` y124).
static const double miniMapTop = 124;
/// 미니맵 한 변 — Figma 「홈」(`2097:16428`) 실측 약 93×90pt. C-113(위치·크기 = Figma)을
/// C-105 「약 140pt」보다 우선한다(2026-10-05 대표 결정).
static const double miniMapSize = 93;
/// 사본 기준 배경 높이(상태 표시줄 48 포함).
static const double designHeight = 680;
@@ -191,8 +202,7 @@ class _HomeHeroState extends State<HomeHero> {
glow: AppColors.neonViolet,
),
),
if (widget.miniMap case final mini?)
Positioned(top: 0, right: 0, child: mini),
],
),
),
@@ -215,6 +225,13 @@ class _HomeHeroState extends State<HomeHero> {
),
),
if (widget.miniMap case final mini?)
Positioned(
top: top + HomeHero.miniMapTop,
right: 0,
child: mini,
),
if (widget.ticker case final ticker?)
Positioned(top: top + 102, left: 0, right: 0, child: ticker),
+7
View File
@@ -198,6 +198,9 @@ abstract interface class InsideRepository {
/// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다.
Future<void> accept(String questionId, String answerId);
/// 내가 쓴 질문 — MY > 활동 > 인사이드. 최신순.
Future<List<InsideQuestion>> mine();
}
/// 현상금 Q&A 실패를 화면 문구로 — 서버 카탈로그 코드(`E-QNA-*` 등)로 가른다.
@@ -312,6 +315,10 @@ class SampleInsideRepository implements InsideRepository {
@override
Future<int> balance() async => 12_400;
@override
Future<List<InsideQuestion>> mine() async =>
_sorted(InsideSort.latest).where((q) => q.isMine).toList();
@override
Future<int> minBounty() async => 100;
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
@@ -105,25 +107,12 @@ class _InsideDetailScreenState extends State<InsideDetailScreen> {
a.id.isNotEmpty;
Future<void> _accept(InsideAnswer a) async {
final ok = await showDialog<bool>(
context: context,
builder: (dialog) => AlertDialog(
content: Text(
context.l10n.insideAcceptConfirm('${question.bountyPop}'),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialog).pop(false),
child: Text(context.l10n.commonCancel),
),
TextButton(
onPressed: () => Navigator.of(dialog).pop(true),
child: Text(context.l10n.insideAccept),
),
],
),
final ok = await showConfirmDialog(
context,
context.l10n.insideAcceptConfirm('${question.bountyPop}'),
confirmLabel: context.l10n.insideAccept,
);
if (ok != true || _busy || !mounted) return;
if (!ok || _busy || !mounted) return;
setState(() => _busy = true);
try {
await widget.repository!.accept(question.id, a.id);
@@ -95,6 +95,18 @@ class FirestoreInsideRepository implements InsideRepository {
);
}
/// 팬덤과 상관없이 내가 쓴 질문 전부(`authorUid` 등호 하나라 복합 색인이 필요 없다).
// ponytail: 정렬은 앱에서 한다. 한 사람의 질문이 수백 건을 넘으면 orderBy + 복합 색인 + 페이지로.
@override
Future<List<InsideQuestion>> mine() async {
final snap = await _backend.db
.collection('questions')
.where('authorUid', isEqualTo: _backend.requireUid)
.get();
return [for (final doc in snap.docs) _question(doc)]
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
@override
Future<int> balance() async {
final wallet =
+2 -2
View File
@@ -115,8 +115,8 @@ class _AccountLinkScreenState extends State<AccountLinkScreen> {
switch (outcome) {
case AuthFailed(:final code, :final message):
_toast('[$code] $message');
case AuthNotConfigured(:final missing):
_toast(context.l10n.settingsLinkNotReady(_name, missing.join(' · ')));
case AuthNotConfigured():
_toast(context.l10n.settingsLinkNotReady(_name));
case AuthSucceeded() || AuthCancelled():
break;
}
+29 -45
View File
@@ -33,10 +33,18 @@ enum MyListKind {
enum _MySort { latest, pop, solved, achieved }
class MyListsScreen extends StatefulWidget {
const MyListsScreen({super.key, required this.kind, this.campaigns});
const MyListsScreen({
super.key,
required this.kind,
this.campaigns,
this.insides,
});
final MyListKind kind;
/// 내 인사이드의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
final InsideRepository? insides;
/// 내 캠페인의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
final CampaignRepository? campaigns;
@@ -47,44 +55,6 @@ class MyListsScreen extends StatefulWidget {
class _MyListsScreenState extends State<MyListsScreen> {
_MySort _sort = _MySort.latest;
/// 표본 — 시안 `370:228289` 문구. 서버가 붙으면 INSIDE 저장소의 내 글로 옮긴다.
static final _insides = () {
final now = DateTime.now();
return <InsideQuestion>[
InsideQuestion(
id: 'i1',
title: 'BTS 컴백 알려주세요!',
body: 'BTS 한국 컴백 시기가 궁급합니다!\n아시는 분들 댓글 부탁드려요 ㅠㅠ',
authorName: 'BTS_Hong',
createdAt: now,
bountyPop: 100,
isMine: true,
),
InsideQuestion(
id: 'i2',
title: '화양연화 세계관 해석해주실 분?',
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(minutes: 10)),
bountyPop: 500,
viewCount: 3,
isMine: true,
),
InsideQuestion(
id: 'i3',
title: 'BTS 이 포즈 뭔가요?',
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(days: 30)),
bountyPop: 800,
answerCount: 24,
viewCount: 82,
isMine: true,
isSolved: true,
),
];
}();
List<_MySort> get _sorts => switch (widget.kind) {
MyListKind.inside => const [_MySort.latest, _MySort.pop, _MySort.solved],
MyListKind.campaign => const [_MySort.latest, _MySort.achieved],
@@ -132,13 +102,27 @@ class _MyListsScreenState extends State<MyListsScreen> {
},
);
Widget _insideList(BuildContext context) {
/// 내가 쓴 질문 — 화면을 열 때 한 번 읽고, 정렬 칩은 그 목록을 다시 거른다.
late final Future<List<InsideQuestion>> _mine =
(widget.insides ?? const SampleInsideRepository()).mine();
Widget _insideList(BuildContext context) =>
FutureBuilder<List<InsideQuestion>>(
future: _mine,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
return _insideItems(context, snap.data ?? const []);
},
);
Widget _insideItems(BuildContext context, List<InsideQuestion> mine) {
final items = switch (_sort) {
_MySort.pop => [
..._insides,
]..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
_MySort.solved => _insides.where((q) => q.isSolved).toList(),
_ => _insides,
_MySort.pop => [...mine]
..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
_MySort.solved => mine.where((q) => q.isSolved).toList(),
_ => mine,
};
if (items.isEmpty) {
return Center(
+25 -10
View File
@@ -9,6 +9,7 @@ import '../../core/ui/app_icons.dart';
import '../auth/auth.dart';
import '../auth/auth_repository.dart';
import '../campaign/campaign_data.dart' show CampaignRepository;
import '../inside/inside_data.dart' show InsideRepository;
import 'my_data.dart';
import 'my_lists_screen.dart';
import 'profile_edit_screen.dart';
@@ -29,6 +30,7 @@ class MyScreen extends StatefulWidget {
this.push,
this.auth,
this.campaigns,
this.insides,
});
final MyRepository repository;
@@ -36,6 +38,9 @@ class MyScreen extends StatefulWidget {
/// 「내 캠페인」이 읽는 저장소. null 이면 표본.
final CampaignRepository? campaigns;
/// 「내 인사이드」가 읽는 저장소. null 이면 표본.
final InsideRepository? insides;
/// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다.
final AccountLinker? linker;
@@ -251,7 +256,10 @@ class _MyScreenState extends State<MyScreen> {
_Row(
label: context.l10n.myInside,
onTap: () => _push(
const MyListsScreen(kind: MyListKind.inside),
MyListsScreen(
kind: MyListKind.inside,
insides: widget.insides,
),
),
),
_Row(
@@ -314,16 +322,23 @@ class _MyScreenState extends State<MyScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: ProfileCard(
profile: profile,
onShare: (target) {
onShare: (target) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(this.context);
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(context.l10n.myShareLater(target)),
behavior: SnackBarBehavior.floating,
),
);
try {
if (!await shareProfile(profile, target)) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(l10n.shareCopied),
behavior: SnackBarBehavior.floating,
),
);
} catch (_) {
messenger.showSnackBar(SnackBar(content: Text(l10n.commonRequestFailed)));
}
},
),
),
+10 -133
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/push/push_messaging.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart';
import '../auth/auth.dart';
import '../auth/auth_repository.dart';
@@ -281,13 +282,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
await _notice(context.l10n.settingsSameEmailOnly);
case AuthFailed(:final code, :final message):
_toast('[$code] $message');
case AuthNotConfigured(:final missing):
_toast(
context.l10n.settingsLinkNotReady(
_snsName(context, provider),
missing.join(' · '),
),
);
case AuthNotConfigured():
_toast(context.l10n.settingsLinkNotReady(_snsName(context, provider)));
case AuthSucceeded() || AuthCancelled():
break;
}
@@ -344,19 +340,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
/// 안내 팝업(확인 하나) — 시안 `554:23103`.
Future<void> _notice(String message) => showDialog<void>(
context: context,
builder: (context) => _MyDialog(
title: message,
actions: [
_DialogButton(
label: context.l10n.commonConfirm,
filled: true,
onTap: () => Navigator.of(context).pop(),
),
],
),
);
Future<void> _notice(String message) => showAlertDialog(context, message);
/// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`.
Future<bool> _confirm({
@@ -366,30 +350,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool withdraw = false,
Future<void> Function()? onConfirm,
}) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => _MyDialog(
title: title,
body: body,
actions: [
_DialogButton(
label: context.l10n.commonCancel,
onTap: () => Navigator.of(context).pop(false),
),
_DialogButton(
label: action,
filled: true,
onTap: () => Navigator.of(context).pop(true),
),
],
),
final ok = await showConfirmDialog(
context,
title,
body: body,
confirmLabel: action,
);
if (ok == true && onConfirm != null) {
await onConfirm();
} else if (ok == true && withdraw && mounted) {
_toast(context.l10n.settingsWithdrawLater);
}
return ok ?? false;
return ok;
}
}
@@ -522,101 +494,6 @@ class _SnsRow extends StatelessWidget {
}
/// 팝업 — 바탕 #2d2d2d · 좌우 31.5 · 안쪽 24/16 · 제목 16/600 가운데 · 버튼 높이 48 간격 8.
class _MyDialog extends StatelessWidget {
const _MyDialog({required this.title, this.body, required this.actions});
final String title;
final String? body;
final List<Widget> actions;
@override
Widget build(BuildContext context) => Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 31.5),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: myText(16, FontWeight.w600),
),
if (body case final b?) ...[
const SizedBox(height: 16),
Text(
b,
textAlign: TextAlign.center,
style: myText(16, FontWeight.w500),
),
],
const SizedBox(height: 24),
Row(
children: [
for (final (i, a) in actions.indexed) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: a),
],
],
),
],
),
),
);
}
class _DialogButton extends StatelessWidget {
const _DialogButton({
required this.label,
required this.onTap,
this.filled = false,
});
final String label;
final VoidCallback onTap;
final bool filled;
@override
Widget build(BuildContext context) {
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
);
final text = Text(
label,
style: myText(
16,
FontWeight.w600,
height: 1,
color: filled ? AppColors.onCosmicAccent : AppColors.onCosmic,
),
);
return SizedBox(
height: 48,
child: filled
? FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: EdgeInsets.zero,
backgroundColor: AppColors.cosmicAccent,
shape: shape,
),
child: text,
)
: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: EdgeInsets.zero,
side: const BorderSide(color: AppColors.onCosmic),
shape: shape,
),
child: text,
),
);
}
}
/// 언어 설정 시트 — 시안 `357:224379`: 제목 + 닫기 · 언어 목록(고른 것에 분홍 체크) · 「선택」.
class _LanguageSheet extends StatefulWidget {
const _LanguageSheet({required this.current});
+32 -1
View File
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/format.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../my_data.dart';
import 'my_cards.dart';
import '../../../core/i18n/app_locale.dart';
import '../../campaign/campaign_share.dart' show shareHashtags;
/// 프로필 카드 오버레이 — 시안 `374:15738`(명세 `549:22058`).
///
@@ -17,11 +21,12 @@ class ProfileCard extends StatelessWidget {
final void Function(String target) onShare;
/// 공유 대상과 시안 아이콘. 명세가 적은 순서 그대로다.
/// 프로필 웹 주소가 아직 없어 마지막 칸은 「링크 복사」가 아니라 「문구 복사」다.
static List<String> get shareTargets => [
appL10n.shareInstagram,
appL10n.shareTiktok,
'X',
appL10n.shareCopyLink,
appL10n.shareCopyText,
];
static const _shareIcons = [
'assets/figma/share-instagram.png',
@@ -106,3 +111,29 @@ class ProfileCard extends StatelessWidget {
),
);
}
/// 프로필 공유 문구 — 공유 시트와 복사가 같은 문구를 쓴다.
String profileShareText(MyProfile profile, [AppLocalizations? l10n]) => [
(l10n ?? appL10n).myShareText(
profile.nickname,
'${profile.userLevel}',
formatPop(profile.contributedPop),
),
shareHashtags().join(' '),
].join('\n');
/// 프로필 카드 공유. 앱별 전용 SDK 없이 OS 공유 시트로 보내고(사용자가 그 안에서
/// 인스타 · 틱톡 · X 를 고른다), 마지막 칸은 문구를 복사한다. 복사했으면 true.
Future<bool> shareProfile(
MyProfile profile,
String target, {
Future<ShareResult> Function(ShareParams)? share,
}) async {
final text = profileShareText(profile);
if (target == ProfileCard.shareTargets.last) {
await Clipboard.setData(ClipboardData(text: text));
return true;
}
await (share ?? SharePlus.instance.share)(ShareParams(text: text));
return false;
}
@@ -192,11 +192,6 @@ class _RankingBody extends StatelessWidget {
return ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
// 실데이터가 아니면 숨기지 않고 그대로 알린다.
if (data.mockNote case final note?) ...[
MockBadge(note: note),
const SizedBox(height: AppSpace.s3),
],
if (data.mine case final me?) ...[
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
const SizedBox(height: AppSpace.s4),
@@ -461,29 +456,3 @@ class _Podium extends StatelessWidget {
],
);
}
/// 실데이터가 아닌 자리를 숨기지 않고 그대로 알리는 배지 — 대조표 3-5.
/// 랭킹 화면과 행성 상세 FPI 탭이 같은 배지를 쓴다.
class MockBadge extends StatelessWidget {
const MockBadge({super.key, required this.note});
final String note;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(AppSpace.s4, AppSpace.s3, AppSpace.s4, 0),
padding: const EdgeInsets.all(AppSpace.s3),
decoration: BoxDecoration(
color: AppColors.cosmicVoid,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.neonViolet),
),
child: Text(
'MOCK · $note',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: AppColors.neonViolet),
),
);
}
+3 -3
View File
@@ -130,7 +130,7 @@ class FpiRanking {
final FpiRankRow? mine;
/// 실데이터가 아닌 경우의 사유. null 이면 실연동이다.
/// 화면이 이 문구를 그대로 배지로 띄운다 — 숨기지 않는다.
/// 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
final String? mockNote;
bool get isMock => mockNote != null;
@@ -279,8 +279,8 @@ class PlanetDetail {
final int members;
final List<LoungePost> posts;
/// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유. 화면이 그대로 배지로
/// 띄운다 — 숨기지 않는다(대조표 3-5).
/// 이 화면에 실데이터가 아닌 자리가 남아 있으면 그 사유.
/// 개발자 진단 · 테스트용이며 사용자 화면에는 내보내지 않는다(10/4 실기기 — MOCK 문구 노출).
final String? mockNote;
/// 육각형 그래프 정규화 기준.
@@ -383,11 +383,6 @@ class _FpiTab extends StatelessWidget {
padding: const EdgeInsets.all(16),
sliver: SliverList.list(
children: [
if (detail.mockNote case final note?)
Padding(
padding: const EdgeInsets.only(bottom: AppSpace.s3),
child: MockBadge(note: note),
),
const _NebulaFpi(),
],
),
@@ -400,12 +395,6 @@ class _FpiTab extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
sliver: SliverList.list(
children: [
// 실데이터가 아닌 자리가 남아 있으면 숨기지 않고 그대로 알린다(대조표 3-5).
if (detail.mockNote case final note?)
Padding(
padding: const EdgeInsets.only(bottom: AppSpace.s3),
child: MockBadge(note: note),
),
// 침식·방어 진행 — `fn-eclipse-aggregate` 의 집계 문서와 샤드를 구독한다.
// TOTAL FPI 카드 위에 두는 이유는 침식이 곧 이 카드의 값을 깎기 때문이다
// (원장 B-55 — FPI 감소는 Eclipse 가 담당한다).
@@ -705,12 +694,17 @@ class _ScoreCard extends StatelessWidget {
),
Row(
children: [
// 순위가 없으면(집계 모집단 밖 · 0) 「전체 0위」 대신 「순위 집계 전」.
Text(
context.l10n.planetOverallRank('${score.rank}'),
score.rank > 0
? context.l10n.planetOverallRank('${score.rank}')
: context.l10n.planetRankNone,
style: _t(12, color),
),
const SizedBox(width: 4),
if (trend == 0)
if (score.rank <= 0)
const SizedBox.shrink()
else if (trend == 0)
Text(
'-',
style: _t(
@@ -765,7 +759,7 @@ class _ScoreCard extends StatelessWidget {
// 서버 산출값이 없으면 숫자를 짓지 않는다 — 「상위 100%」는
// 최하위라는 사실 주장이 되어 버린다(대조표 3-5).
score.myTopPercent == null
? context.l10n.planetTallying
? '—'
: context.l10n.planetTopPercent('${score.myTopPercent}'),
style: _t(10, AppColors.onCosmic, height: 1),
),
@@ -176,8 +176,10 @@ class FirestorePlanetRepository implements PlanetRepository {
trend: daily.isEmpty
? 0
: axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)),
// 없으면 null — 화면이 숫자 대신 「집계 준비 중」을 띄운다.
myTopPercent: myTopOf(metric),
// 없으면 null — 화면이 숫자 대신 「—」를 띄운다. 행성 값이 0 이면
// 기여를 나눌 모수가 없으니 남아 있는 값(시드 등)도 쓰지 않는다.
myTopPercent:
axes.intOr(_axisKey(metric)) > 0 ? myTopOf(metric) : null,
),
],
posts: posts,
+4 -2
View File
@@ -95,7 +95,8 @@ class _AdBonusDialog extends StatelessWidget {
child: Text(
bonusPop == null
? context.l10n.adBonusPlaceholder
: '+${formatPop(bonusPop!)} POP',
// 원장 C-116 — 운영값 기반 「+N 기여 POP」(증분은 기여 POP · C-122).
: '+${formatPop(bonusPop!)} ${context.l10n.popKindContribution}',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w600,
@@ -285,7 +286,8 @@ class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
void initState() {
super.initState();
// 실패해도 닫는다 — 오류는 [until] 을 만든 쪽이 따로 받는다.
unawaited(widget.until?.catchError((_) => null).whenComplete(_close));
// `catchError((_) => null)` 은 `Future<int>` 처럼 null 을 못 받는 타입에서 던진다.
unawaited(widget.until?.then<void>((_) {}, onError: (_) {}).whenComplete(_close));
}
/// 맨 위가 아니어도 이 화면만 걷어 낸다. 지급이 끝나 보상 화면이 먼저 올라와도
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/data/backend.dart';
import '../../core/data/server_clock.dart';
import '../../core/theme/tokens.dart';
@@ -401,12 +403,12 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
Future<void> _confirm() async {
final pool = _paid ? _pool : 0;
final fee = _catalog?.feeOf(pool) ?? 0;
final go = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.questCreateConfirm),
content: Text(
context.l10n.questCreateCost(
final go = await showConfirmDialog(
context,
context.l10n.questCreateConfirm,
cancelLabel: context.l10n.commonKeepEditing,
confirmLabel: context.l10n.questCreate,
body: context.l10n.questCreateCost(
_preview.title,
formatPop(_intOf(_reward)),
formatPop(_intOf(_participants)),
@@ -414,20 +416,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
formatPop(fee),
formatPop(pool + fee),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonKeepEditing),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.questCreate),
),
],
),
);
if (go != true || !mounted) return;
if (!go || !mounted) return;
setState(() => _saving = true);
final String id;
+10 -6
View File
@@ -119,6 +119,7 @@ enum QuestType {
///
/// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다
/// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다.
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
bool get verifiable => switch (this) {
QuestType.attend ||
QuestType.steps ||
@@ -131,12 +132,8 @@ enum QuestType {
};
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
String? get blockedReason => switch (this) {
QuestType.musicShake || QuestType.streaming =>
'발주사 확인 중입니다 — 스밍·Music Shake 유형은 회신 대기 항목입니다(Q-QC-04).',
QuestType.unknown => appL10n.questTypeUnverifiable,
_ => null,
};
String? get blockedReason =>
verifiable ? null : appL10n.questTypeUnverifiable;
}
/// 서버가 판정하고 지급한 결과 — `fn-quest-*` 응답.
@@ -507,6 +504,10 @@ abstract interface class QuestRepository {
Future<String> create(QuestDraft draft);
/// 보유 일반 POP(`wallet/main.activityBalance`) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다.
/// 모르면 null(헤더는 받은 값을 유지한다).
Future<int?> popBalance();
/// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다.
///
/// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률
@@ -730,6 +731,9 @@ class SampleQuestRepository implements QuestRepository {
@override
Future<String> create(QuestDraft draft) async => 'q-new';
@override
Future<int?> popBalance() async => null;
/// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌
/// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다.
@override
+72 -118
View File
@@ -2,13 +2,14 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/data/backend.dart';
import '../../core/data/server_clock.dart';
import '../../core/monetization/rewarded_ad.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../home/home_screen.dart' show formatPop;
import '../planet/fpi_ranking_screen.dart' show MockBadge;
import '../planet/moderation.dart';
import 'quest_ad_screens.dart';
import 'quest_create_screen.dart' show QuestLinkCard;
@@ -135,9 +136,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
color: Color(0xFF2E2E2E),
shape: BoxShape.circle,
),
child: image == null
? null
: Image.asset(image, width: 200, height: 200),
child: Image.asset(image, width: 200, height: 200),
),
),
const SizedBox(height: 24),
@@ -232,8 +231,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
),
),
// 회신 대기 유형은 참여를 열지 않는다 — 해석 확인 요청서 8.1 보류 범위.
// 실데이터가 아닌 표시는 기존 관례대로 MockBadge 로 알린다.
if (blocked != null) MockBadge(note: blocked),
if (blocked != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(blocked, style: _strong),
),
if (submission != null) ..._submissionStatus(submission),
if (detail.reviews.isNotEmpty) ..._reviewQueue(detail),
],
@@ -290,7 +292,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
),
child: Text(
blocked != null
? '발주사 확인 중'
? context.l10n.commonComingSoon
: !open
? context.l10n.questTimeAttackClosed
: _busy
@@ -388,34 +390,23 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
var resubmit = false;
if (!approve) {
final input = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialog) => AlertDialog(
title: Text(context.l10n.questReject),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: input,
decoration: InputDecoration(labelText: context.l10n.questRejectReason),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: resubmit,
onChanged: (v) => setDialog(() => resubmit = v ?? false),
title: Text(context.l10n.questAllowResubmit),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
final ok = await showConfirmDialog(
context,
context.l10n.questReject,
confirmLabel: context.l10n.questReject,
content: StatefulBuilder(
builder: (context, setDialog) => Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: input,
decoration: InputDecoration(labelText: context.l10n.questRejectReason),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.questReject),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: resubmit,
onChanged: (v) => setDialog(() => resubmit = v ?? false),
title: Text(context.l10n.questAllowResubmit),
),
],
),
@@ -423,7 +414,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
);
reason = input.text.trim();
input.dispose();
if (ok != true || reason.isEmpty) return;
if (!ok || reason.isEmpty) return;
}
await _run(() => widget.repository.review(
s.id,
@@ -437,11 +428,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
Future<void> _submitProof(QuestDetail detail) async {
final url = TextEditingController();
final text = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.questSubmit),
content: Column(
final ok = await showConfirmDialog(
context,
context.l10n.questSubmit,
confirmLabel: context.l10n.commonSubmit,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
@@ -457,23 +448,12 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.commonSubmit),
),
],
),
);
final proofUrl = url.text.trim();
final proofText = text.text.trim();
url.dispose();
text.dispose();
if (ok != true || (proofUrl.isEmpty && proofText.isEmpty)) return;
if (!ok || (proofUrl.isEmpty && proofText.isEmpty)) return;
final done = await _run(
() => widget.repository.submit(detail.card.id, url: proofUrl, text: proofText),
);
@@ -484,11 +464,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
Future<void> _edit(QuestDetail detail) async {
final title = TextEditingController(text: detail.card.title);
final description = TextEditingController(text: detail.description);
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.questEdit),
content: Column(
final ok = await showConfirmDialog(
context,
context.l10n.questEdit,
confirmLabel: context.l10n.commonEdit,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.questEditNotice, style: _caption),
@@ -504,17 +484,6 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.commonEdit),
),
],
),
);
final newTitle = title.text.trim();
final newDescription = description.text.trim();
@@ -619,24 +588,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·*
/// *중단 버튼 퀘스트 진행 상태값 시작전으로 업데이트*
Future<void> _confirmStop(QuestDetail detail) async {
final stop = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.questStopConfirm),
content: Text(context.l10n.questStopBody),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.questStop),
),
],
),
final stop = await showConfirmDialog(
context,
context.l10n.questStopConfirm,
body: context.l10n.questStopBody,
confirmLabel: context.l10n.questStop,
);
if (stop != true) return;
if (!stop) return;
_stopWatching();
final next =
await widget.repository.advance(detail.card.id, QuestStage.notStarted);
@@ -650,46 +608,21 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 보상받기.
///
/// 순서는 **참여 조건 판정 → 기본 보상 → 광고 선택**이다. 위치 · 권한 · 걸음 · 일일 한도는
/// 서버가 [QuestRepository.verify] 에서 판정하므로, 그 판정이 통과한 뒤에만 광고를 권한다.
/// 광고를 먼저 보이면 끝까지 보고도 위치 실패로 아무것도 못 받는 경로가 생긴다(2026-10-04 실기기 4-3).
///
/// 명세는 *관리자 퀘스트만 추가 보상 모달 · 사용자 퀘스트는 일반 보상 완료* 로 나눴지만
/// 별첨 B **B-28** 이 사용자 생성 Quest 에도 고정형 기여 POP Reward Ad 를 확정했다
/// (공통 기준 2항 — 확정 정책 우선). 그래서 두 갈래 모두 모달을 띄우고 금액만 다르다.
Future<void> _claim(QuestDetail detail) async {
if (_busy) return;
final bonus =
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false;
if (!mounted) return;
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
String? impression;
if (bonus) {
impression = await _watchAd();
if (!mounted) return;
}
// 판정과 지급은 서버가 한다. 화면은 결과를 받아 띄우기만 한다 —
// 별첨 1 ② 「클라이언트 단독 지급 0건」.
// 기본 보상을 먼저 받고 광고 증분을 뒤에 청구한다(원장 B-03 데이터 관계) —
// 광고 쪽이 실패해도 기본 보상은 이미 지급되어 있다.
setState(() => _busy = true);
final QuestReward reward;
int? adPop;
BackendException? adError;
try {
final pending = () async {
final base = await widget.repository.verify(detail.card);
if (impression != null && !base.duplicate) {
try {
adPop = await widget.repository.claimAdReward(impression, base);
} on BackendException catch (error) {
adError = error;
}
}
return base;
}();
// 광고 시청 후 대기(`482:15084`) — 서버가 판정·지급하는 동안 띄운다.
if (impression != null) {
unawaited(_warp(QuestAdWarpScreen.after(until: pending)));
}
reward = await pending;
reward = await widget.repository.verify(detail.card);
} catch (error) {
if (!mounted) return;
setState(() => _busy = false);
@@ -714,9 +647,30 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
await _load();
return;
}
// 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
final failed = adError;
if (failed != null) _toast(failed.message);
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) —
// 광고가 실패해도 기본 보상은 그대로다.
final bonus =
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false;
if (!mounted) return;
int? adPop;
if (bonus) {
// 끝까지 본 광고의 노출 ID. null 이면 추가 보상을 청구하지 않는다.
final impression = await _watchAd();
if (!mounted) return;
if (impression != null) {
final claim = widget.repository.claimAdReward(impression, reward);
// 광고 시청 후 대기(`482:15084`) — 서버가 증분을 지급하는 동안 띄운다.
unawaited(_warp(QuestAdWarpScreen.after(until: claim)));
try {
adPop = await claim;
} on BackendException catch (error) {
// 한도 초과(E-AD-02) 등 — 기본 보상은 받았으니 보상 화면은 그대로 띄운다.
if (mounted) _toast(error.message);
}
if (!mounted) return;
}
}
await Navigator.of(context, rootNavigator: true).push<bool>(
MaterialPageRoute(
+19 -2
View File
@@ -63,13 +63,30 @@ class _QuestListScreenState extends State<QuestListScreen> {
super.dispose();
}
/// 헤더 보유 POP. 생성(예치 · 수수료) · 보상 뒤 [_load] 가 지갑을 다시 읽어 맞춘다.
late int _pop = widget.myPop;
Future<void> _load() async {
final cards = await widget.repository.load();
final List<QuestCard> cards;
try {
cards = await widget.repository.load();
} on Exception {
// 실패해도 로딩을 끝낸다 — 끝내지 않으면 탭 전체가 무한 로딩으로 남는다(2026-10-04 실기기).
if (!mounted) return;
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(context.l10n.commonRequestFailed),
));
return;
}
final pop = await widget.repository.popBalance().catchError((Object _) => null);
if (!mounted) return;
setState(() {
_cards = cards;
_loading = false;
_end = cards.length < 10;
if (pop != null) _pop = pop;
});
}
@@ -259,7 +276,7 @@ class _QuestListScreenState extends State<QuestListScreen> {
Image.asset('assets/figma/icon-bolt.png', width: 10, height: 10.4),
const SizedBox(width: 5),
Text(
formatPop(widget.myPop),
formatPop(_pop),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
@@ -93,10 +93,17 @@ class FirestoreQuestRepository implements QuestRepository {
for (final doc in docs) {
final quest = doc.data();
if (!_open(quest, now, uid)) continue;
final progress = await db
.doc('users/$uid/questProgress/${doc.id}')
.get()
.then((s) => s.data() ?? const <String, dynamic>{});
// 카드 한 장의 부가 조회가 실패해도 목록 전체를 깨지 않는다 — 그 카드만 시작 전으로 둔다.
// UGC 퀘스트 하나의 제출 건 조회가 거부되어 **모든 사용자의** 목록이 무한 로딩이었다(2026-10-04).
Map<String, dynamic> progress;
try {
progress = await db
.doc('users/$uid/questProgress/${doc.id}')
.get()
.then((s) => s.data() ?? const <String, dynamic>{});
} on Exception {
progress = const {};
}
cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)));
}
return cards;
@@ -145,17 +152,9 @@ class FirestoreQuestRepository implements QuestRepository {
final type = QuestType.parse(quest.strOr('type'));
final authorUid = quest.strOr('authorUid');
final dailyLimit = quest.intOr('dailyLimit', 1);
// 타임어택은 1인 1회라 날짜로 끊지 않는다 — 창이 자정을 넘어도 「보상 완료」로 남는다.
final sameDay = type == QuestType.timeAttack ||
progress.strOr('dayKey') == _dayKey(DateTime.now());
final count = sameDay ? progress.intOr('count') : 0;
// 걸음은 **구간표**가 목표다 — 정책 원장 **B-26**(1 천·5 천·1 만·1.5 만보). 오늘
// 받은 구간 수가 `count` 이므로 다음 문턱이 지금 목표다. 나머지 유형은 일일 한도가
// 곧 목표 횟수다 — 설계서 §2.8 이 `quests` 에 목표 수치 필드를 두지 않았다.
final (:count, :done) = questToday(type, quest, progress, policy);
final tiers = _stepTiers(policy);
final steps = type == QuestType.steps;
final done = steps ? count >= tiers.length : count >= dailyLimit;
final goal = steps
? tiers[count.clamp(0, tiers.length - 1)]
: dailyLimit;
@@ -191,23 +190,6 @@ class FirestoreQuestRepository implements QuestRepository {
);
}
/// 걸음 구간 문턱들 — `config/policy.stepTiers`(운영값).
///
/// **화면 표시용이다. 판정은 서버가 다시 한다.** 문서가 아직 없을 때만 예전 단일
/// 목표(`stepsGoal`)로 떨어뜨린다 — 앱에 구간값을 박으면 운영값이 두 벌이 된다.
List<int> _stepTiers(Map<String, dynamic> policy) {
final raw = policy['stepTiers'];
if (raw is List && raw.isNotEmpty) {
final steps = <int>[];
for (final row in raw) {
if (row is Map && row['steps'] is num) {
steps.add((row['steps'] as num).round());
}
}
if (steps.isNotEmpty) return steps..sort();
}
return [policy.intOr('stepsGoal', 10000)];
}
/// 다국어 제목 — 설계서 §2.8 은 `title`(다국어)이라고만 적었다.
/// 문자열과 지도(map) 둘 다 들어올 수 있어 둘 다 받는다.
@@ -282,9 +264,13 @@ class FirestoreQuestRepository implements QuestRepository {
Future<QuestSubmission?> _submission(String questId, Map<String, dynamic> quest) async {
if (quest.strOr('authorUid').isEmpty) return null;
final id = '${questId}_${_backend.requireUid}';
final snap = await _backend.db.doc('questSubmissions/$id').get();
final data = snap.data();
return data == null ? null : _submissionOf(id, data);
try {
final data = (await _backend.db.doc('questSubmissions/$id').get()).data();
return data == null ? null : _submissionOf(id, data);
} on Exception {
// 규칙 배포 전(없는 문서 get 이 permission-denied) 등 — 제출 없음으로 본다.
return null;
}
}
/// 생성자의 검토 대기열. 규칙이 생성자 조건을 증명하도록 `questAuthorUid` 를 함께 건다.
@@ -441,6 +427,12 @@ class FirestoreQuestRepository implements QuestRepository {
return result.strOr('questId');
}
@override
Future<int?> popBalance() async {
final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get();
return wallet.data()?.intOr('activityBalance');
}
@override
Stream<int> progressStream(QuestCard card) {
if (card.type != QuestType.steps) return const Stream<int>.empty();
@@ -562,3 +554,47 @@ class FirestoreQuestRepository implements QuestRepository {
/// 단말 시간대로 끊으면 해외 사용자의 화면과 서버 판정이 하루 어긋난다.
String _dayKey(DateTime at) =>
at.toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
/// 걸음 구간 문턱들 — `config/policy.stepTiers`(운영값).
///
/// **화면 표시용이다. 판정은 서버가 다시 한다.** 문서가 아직 없을 때만 예전 단일
/// 목표(`stepsGoal`)로 떨어뜨린다 — 앱에 구간값을 박으면 운영값이 두 벌이 된다.
List<int> _stepTiers(Map<String, dynamic> policy) {
final raw = policy['stepTiers'];
if (raw is List && raw.isNotEmpty) {
final steps = <int>[];
for (final row in raw) {
if (row is Map && row['steps'] is num) {
steps.add((row['steps'] as num).round());
}
}
if (steps.isNotEmpty) return steps..sort();
}
return [policy.intOr('stepsGoal', 10000)];
}
/// 오늘(KST) 수행 횟수와 완료 여부 — **HOME 과 퀘스트 목록 · 상세가 같이 쓰는 단일 판정.**
///
/// `users/{uid}/questProgress/{questId}.count` 는 `dayKey` 가 오늘일 때만 오늘 값이다. 날짜를
/// 보지 않으면 9/21 출석의 `count: 1` 이 매일 「보상 완료」로 남는다(2026-10-04 실기기 4-1).
/// 타임어택은 1인 1회라 날짜로 끊지 않는다 — 창이 자정을 넘어도 「보상 완료」로 남는다.
///
/// 걸음은 **구간표**가 목표다 — 정책 원장 **B-26**(1 천·5 천·1 만·1.5 만보). 오늘 받은 구간
/// 수가 `count` 라 구간을 다 받아야 완료다. 나머지 유형은 일일 한도가 곧 목표 횟수다 —
/// 설계서 §2.8 이 `quests` 에 목표 수치 필드를 두지 않았다.
({int count, bool done}) questToday(
QuestType type,
Map<String, dynamic> quest,
Map<String, dynamic>? progress,
Map<String, dynamic> policy, [
DateTime? now,
]) {
final p = progress ?? const <String, dynamic>{};
final sameDay = type == QuestType.timeAttack ||
p.strOr('dayKey') == _dayKey(now ?? DateTime.now());
final count = sameDay ? p.intOr('count') : 0;
final done = type == QuestType.steps
? count >= _stepTiers(policy).length
: count >= quest.intOr('dailyLimit', 1);
return (count: count, done: done);
}
@@ -9,8 +9,8 @@ import '../../../core/i18n/app_locale.dart';
/// 퀘스트 대표 이미지 — Figma 사본의 3D 아이콘(`264:46867` 목록 · `264:48835` 상세).
///
/// ponytail: `quests` 문서(설계서 §2.8)에 이미지 필드가 없어 유형과 제목으로 고른다.
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 빈 칸이다.
String? questImage(QuestCard card) {
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 기본 아이콘이다.
String questImage(QuestCard card) {
switch (card.type) {
case QuestType.attend:
return 'assets/figma/quest-img-stamp.png';
@@ -27,8 +27,8 @@ String? questImage(QuestCard card) {
if (title.contains('만보기') || title.contains('걷기')) {
return 'assets/figma/quest-img-shoe.png';
}
if (title.contains('POP')) return 'assets/figma/quest-img-bolt.png';
return null;
// 유형 · 제목으로 못 고르면 기본 아이콘(번개) — 빈 칸은 고장처럼 보인다(2026-10-04 실기기 4-9).
return 'assets/figma/quest-img-bolt.png';
}
/// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지.
@@ -39,7 +39,6 @@ class QuestIconBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
final image = questImage(card);
return Container(
width: 50,
height: 50,
@@ -48,7 +47,7 @@ class QuestIconBox extends StatelessWidget {
color: const Color(0xFF2E2E2E),
borderRadius: BorderRadius.circular(16),
),
child: image == null ? null : Image.asset(image, width: 42, height: 42),
child: Image.asset(questImage(card), width: 42, height: 42),
);
}
}
+8 -7
View File
@@ -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",
+8 -7
View File
@@ -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",
+8 -7
View File
@@ -9,7 +9,7 @@
"commonPrev": "戻る",
"commonDone": "完了",
"commonStart": "はじめる",
"commonNotYet": "{where}画面は第2次実装確認(2026-09-10)で追加されます。",
"commonNotYet": "{where}画面は準備中です。",
"languageSettingsTitle": "言語設定",
"authLogin": "ログイン",
"authSignup": "新規登録",
@@ -20,8 +20,8 @@
"authProviderLogin": "{provider, select, apple{Appleでログイン} google{Googleでログイン} kakao{Kakaoでログイン} line{LINEでログイン} other{メールでログイン}}",
"authProviderSignup": "{provider, select, apple{Appleで登録} google{Googleで登録} kakao{Kakaoで登録} line{LINEで登録} other{メールで登録}}",
"authNotConfiguredTitle": "{name}ログイン準備中",
"authNotConfiguredBody": "{name}ログインは発注元のキー提供後に利用できます。\n\n必要な項目\n· {items}",
"authNotConfiguredNotice": "{name}ログインはまだ準備中です。\n必要な項目: {items}",
"authNotConfiguredBody": "この端末では{name}ログインをまだご利用いただけません。\n他のログイン方法をご利用ください。",
"authNotConfiguredNotice": "この端末では{name}ログインをまだご利用いただけません。",
"authResetNeedEmail": "再設定メールを受け取るメールアドレスを先に入力してください。",
"authResetSent": "入力されたアドレスに再設定の案内を送信しました。",
"termsTitle": "利用規約",
@@ -46,7 +46,7 @@
"myTabActivity": "アクティビティ",
"myInside": "インサイド",
"myCampaign": "キャンペーン",
"myShareLater": "{target}への共有は09-10回で接続します。",
"myShareText": "{nickname} · Lv.{level} · 累計貢献 {pop} POP",
"myEditPhoto": "プロフィール写真を編集",
"myEditProfile": "プロフィールを編集",
"settingsTitle": "設定",
@@ -70,12 +70,12 @@
"settingsWithdraw": "退会",
"settingsWithdrawConfirm": "退会しますか?",
"settingsWithdrawBody": "保有しているPOPは消滅し、元に戻せません。",
"settingsWithdrawLater": "退会処理は09-10回で接続します。",
"settingsWithdrawLater": "退会機能は準備中です。",
"settingsUnlinkConfirm": "連携中のアカウントを解除しますか?",
"settingsUnlinkBody": "連携したSNSのログイン手段のみ削除され、アカウント情報は削除されません。",
"settingsUnlink": "連携解除",
"settingsSameEmailOnly": "アカウントのメールと同じ\nソーシャルアカウントのみ連携できます。",
"settingsLinkNotReady": "{name}連携はまだ準備中です。必要な項目: {items}",
"settingsLinkNotReady": "この端末では{name}連携をまだご利用いただけません。",
"settingsConnect": "連携",
"settingsDisconnect": "解除",
"settingsEmailProvider": "メール",
@@ -549,6 +549,7 @@
"planetNoContributors": "まだ貢献者がいません",
"planetContributeCta": "貢献してランキングに入ろう!",
"planetOverallRank": "全体{rank}位",
"planetRankNone": "順位集計前",
"planetMyContribution": "自分の貢献度",
"planetTallying": "集計準備中",
"planetTopPercent": "上位{percent}%",
@@ -567,7 +568,7 @@
"questTypeSteps": "ウォーキング",
"questTypePlace": "特定の場所",
"questTypeStreaming": "ストリーミング",
"questTypeUnverifiable": "このクエストタイプの検証方法はまだ決まっていません。",
"questTypeUnverifiable": "このクエストにはまだ参加できません。",
"questTabMine": "マイクエスト",
"placePickPrompt": "クエストを完了する\n場所を選択してください。",
"placeSearch": "場所を検索",
+18 -20
View File
@@ -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": "장소 검색",
+20 -14
View File
@@ -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.
+14 -12
View File
@@ -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';
+14 -11
View File
@@ -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';
+14 -11
View File
@@ -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 => 'マイクエスト';
+16 -13
View File
@@ -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 => '내 퀘스트';
+14 -11
View File
@@ -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 => '我的任务';
+8 -7
View File
@@ -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": "搜索地点",
+7 -4
View File
@@ -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;