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_dialog.dart' show keepWords; 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 showQuestAdBonus(BuildContext context, {int? bonusPop}) => showDialog( 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( // 25pt 두 줄 제목 — 낱말 중간(「얻으세 / 요.」)이 아니라 어절에서 바꾼다. keepWords(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 _product = Store.instance?.product(Store.focusMonthly).catchError((_) => null) ?? Future.value(); Future _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( 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? until; @override State createState() => _QuestAdWarpScreenState(); } class _QuestAdWarpScreenState extends State with SingleTickerProviderStateMixin { String _message(BuildContext context) => widget.message ?? (widget._after ? context.l10n.adWarpAfter : context.l10n.adWarpBefore); /// 명세 `518:88645` — 시청 전 *광고 시청을 북돋을 수 있는 짧은 애니메이션*, 시청 후 *서비스에 /// 재몰입 할 수 있는 애니메이션*. 행성이 한 번 커지며 반 바퀴 돈다(한 번만 — 반복하지 않는다). late final AnimationController _spin = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), )..forward(); @override void dispose() { _spin.dispose(); super.dispose(); } @override void initState() { super.initState(); // 실패해도 닫는다 — 오류는 [until] 을 만든 쪽이 따로 받는다. // `catchError((_) => null)` 은 `Future` 처럼 null 을 못 받는 타입에서 던진다. unawaited(widget.until?.then((_) {}, 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: [ AnimatedBuilder( animation: _spin, builder: (_, child) { final t = Curves.easeOutBack.transform(_spin.value); return Transform.rotate( angle: (widget._after ? -1 : 1) * (1 - _spin.value) * 3.14159, child: Transform.scale(scale: 0.8 + 0.2 * t, child: child), ); }, child: 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, ), ), ), ], ), ), ), ), ); }