362 lines
13 KiB
Dart
362 lines
13 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|