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