사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · 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
+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),
);
}
}