Files
fanuzu/app/lib/features/quest/quest_ad_screens.dart
T

364 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// 원장 C-116 — 운영값 기반 「+N 기여 POP」(증분은 기여 POP · C-122).
: '+${formatPop(bonusPop!)} ${context.l10n.popKindContribution}',
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] 을 만든 쪽이 따로 받는다.
// `catchError((_) => null)` 은 `Future<int>` 처럼 null 을 못 받는 타입에서 던진다.
unawaited(widget.until?.then<void>((_) {}, onError: (_) {}).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,
),
),
),
],
),
),
),
),
);
}