QUEST · CAMPAIGN 화면 — 퀘스트 참여·생성·인증 · 캠페인 목록·상세·후원·랭킹

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 5de04ac281
commit 18afc9cb98
25 changed files with 9368 additions and 0 deletions
@@ -0,0 +1,361 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import '../../core/data/backend.dart';
import '../../core/monetization/store.dart';
import '../home/home_screen.dart' show formatPop;
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/i18n/app_locale.dart';
/// 추가 보상 모달 — Figma `272:245274`(홈_퀘스트/퀘스트 상세) · 명세 `523:18244`.
///
/// true 는 광고 보고 받기, false 는 기본 보상 받기, null 은 닫기다. 별첨 1 ⑥
/// 「No-Force」라 **닫아도 기본 보상은 받는다**(호출부가 null 을 false 로 본다).
///
/// [bonusPop] 은 끝까지 보면 더 받는 기여 POP 안내값([QuestCard.adBonusPop])이다.
Future<bool?> showQuestAdBonus(BuildContext context, {int? bonusPop}) =>
showDialog<bool>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.7),
// 시안은 화면 전체 기준 가운데다(안전 영역 기준이면 7 내려간다).
useSafeArea: false,
builder: (context) => _AdBonusDialog(bonusPop: bonusPop),
);
class _AdBonusDialog extends StatelessWidget {
const _AdBonusDialog({this.bonusPop});
final int? bonusPop;
static const _white = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
height: 24 / 14,
color: AppColors.onCosmic,
);
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: AppColors.cosmicSurface,
insetPadding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: const ImageIcon(AppIcons.x, size: 24, color: AppColors.onCosmic),
),
),
const SizedBox(height: 24),
Text(
context.l10n.adMoreChance,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 15),
// 추가분 — 운영 Quest 는 기본 보상 × (배수 − 1)(B-03 기본 x2 · 지정 x3), 사용자
// 생성 Quest 는 고정 10 기여 POP(C-02). 실제 지급액은 서버가 원장으로 정한다.
Text(
context.l10n.adWatchPrompt,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 16),
Container(
height: 130,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF2E5B49)),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Text(
bonusPop == null
? context.l10n.adBonusPlaceholder
: '+${formatPop(bonusPop!)} POP',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w600,
height: 1.4,
color: Color(0xFF67D3A9),
),
),
),
const SizedBox(height: 24),
// 광고 제거 구독(명세 `523:18250` *어드민에 등록된 아이템명 및 가격*) ·
// 파티 팩. 파티 팩은 상품 정의가 없어 시안 자리만 둔다.
Row(
children: [
Expanded(child: _FocusOffer()),
SizedBox(width: 8),
Expanded(
child: _Offer(
title: context.l10n.adPartyPack,
titleColor: Color(0xFFDEAB53),
border: Color(0xFFDEAB53),
label: context.l10n.adPickProduct,
),
),
],
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
backgroundColor: AppColors.cosmicAccent,
foregroundColor: Colors.black,
textStyle: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 24 / 16,
),
),
child: Text(context.l10n.adWatchBonus),
),
],
),
),
const SizedBox(height: 13),
GestureDetector(
onTap: () => Navigator.of(context).pop(false),
child: Text(
context.l10n.adBasicReward,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 24 / 14,
color: AppColors.onCosmicVariant,
),
),
),
],
),
),
);
}
}
/// Focus(광고 제거 구독 · 원장 B-04) 구입 버튼. 가격은 스토어가 준 현지 가격이다 —
/// 시안의 「₩3,900/월」은 확정가(US$1.99)와 달라 쓰지 않는다(공통 기준 2항).
/// 스토어에 상품이 없으면 「스토어 상품 준비 중」으로 멈춘다.
class _FocusOffer extends StatefulWidget {
const _FocusOffer();
@override
State<_FocusOffer> createState() => _FocusOfferState();
}
class _FocusOfferState extends State<_FocusOffer> {
late final Future<ProductDetails?> _product =
Store.instance?.product(Store.focusMonthly).catchError((_) => null) ??
Future.value();
Future<void> _buy(ProductDetails product) async {
try {
await Store.instance?.buy(product.id);
} on Exception catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(error is BackendException
? error.message
: context.l10n.payOpenFailed),
));
}
}
@override
Widget build(BuildContext context) => FutureBuilder<ProductDetails?>(
future: _product,
builder: (context, snap) {
final product = snap.data;
final offer = _Offer(
title: context.l10n.focusMode,
titleColor: const Color(0xFFB96774),
border: const Color(0xFF59242F),
label: snap.connectionState != ConnectionState.done
? '…'
: product == null
? Store.notReady
: context.l10n.pricePerMonth(product.price),
);
return product == null
? offer
: GestureDetector(onTap: () => _buy(product), child: offer);
},
);
}
class _Offer extends StatelessWidget {
const _Offer({
required this.title,
required this.titleColor,
required this.border,
required this.label,
});
final String title;
final Color titleColor;
final Color border;
final String label;
@override
Widget build(BuildContext context) => Container(
constraints: const BoxConstraints(minHeight: 56),
padding: const EdgeInsets.symmetric(vertical: 4),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
border: Border.all(color: border),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: _AdBonusDialog._white.copyWith(color: titleColor)),
Text(label, style: _AdBonusDialog._white),
],
),
);
}
/// 광고 전후 대기 화면 — Figma `494:14332`(광고 시청 전) · `482:15084`(광고 시청 후).
///
/// [until] 이 끝나면 스스로 닫힌다. 「건너뛰기」는 기다리지 않고 닫는다.
class QuestAdWarpScreen extends StatefulWidget {
const QuestAdWarpScreen({
super.key,
required String this.message,
this.until,
}) : _after = false;
/// 광고를 찾는 중.
const QuestAdWarpScreen.before({super.key, this.until})
: message = null,
_after = false;
/// 보상을 받는 중.
const QuestAdWarpScreen.after({super.key, this.until})
: message = null,
_after = true;
/// null 이면 [before]/[after] 기본 문구를 지금 언어로 쓴다.
final String? message;
final bool _after;
final Future<Object?>? until;
@override
State<QuestAdWarpScreen> createState() => _QuestAdWarpScreenState();
}
class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
String _message(BuildContext context) =>
widget.message ??
(widget._after ? context.l10n.adWarpAfter : context.l10n.adWarpBefore);
@override
void initState() {
super.initState();
// 실패해도 닫는다 — 오류는 [until] 을 만든 쪽이 따로 받는다.
unawaited(widget.until?.catchError((_) => null).whenComplete(_close));
}
/// 맨 위가 아니어도 이 화면만 걷어 낸다. 지급이 끝나 보상 화면이 먼저 올라와도
/// 대기 화면이 그 밑에 남지 않는다.
void _close() {
final route = mounted ? ModalRoute.of(context) : null;
if (route != null && route.isActive) Navigator.of(context).removeRoute(route);
}
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
// 시안의 본문 구간(위 56 머리 · 아래 72 버튼 자리) 가운데에 놓는다.
child: Padding(
padding: const EdgeInsets.only(top: 56, bottom: 72),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/figma/quest-warp-planet.png', width: 212, height: 212),
const SizedBox(height: 24),
// 시안은 405 폭 한 줄이라 375 화면 밖으로 잘린다 — 좌우 16 안에서 가운데 두 줄로.
// 행간은 한 줄이면 사본 값 1, 두 줄로 넘어갈 때만 1.4.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: LayoutBuilder(
builder: (context, box) {
// Text 와 같은 글꼴로 재야 한다(테마 기본 글꼴을 물려받는다).
final style = DefaultTextStyle.of(context).style.merge(
const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1,
letterSpacing: -0.3,
color: AppColors.onCosmic,
),
);
final painter = TextPainter(
text: TextSpan(text: _message(context), style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: box.maxWidth);
final wraps = painter.didExceedMaxLines;
painter.dispose();
return Text(
_message(context),
textAlign: TextAlign.center,
style: wraps ? style.copyWith(height: 1.4) : style,
);
},
),
),
const SizedBox(height: 24),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Text(
context.l10n.adSkip,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 24 / 14,
color: AppColors.onCosmicVariant,
),
),
),
],
),
),
),
),
);
}