import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:share_plus/share_plus.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/fx_intensity.dart'; import '../campaign/campaign_share.dart' show shareHashtags; import '../home/home_screen.dart' show formatPop; import 'music_shake_play_screen.dart' show ShakeTally; import 'quest_data.dart'; import '../../core/i18n/app_locale.dart'; /// 퀘스트 완료 성공 햅틱 강도 — UX·FX v1.5 §1 「Small: 일반 Quest」 · 「Medium: Music/Location Quest 완료」. FxIntensity questSuccessFx(QuestType type) => switch (type) { QuestType.musicShake || QuestType.place => FxIntensity.medium, _ => FxIntensity.small, }; /// 퀘스트 보상 확정 → HOME 행성 반응(v1.5 4장 · 진척현황 B-3 4-2 · 4-3). 보상 화면 「확인」이 올리고, /// HOME 이 받아 에너지 이동 · 행성 흡수와 「퀘스트 완료가 {팬덤} 성장에 반영」을 띄운다. final questPlanetReaction = ValueNotifier(0); /// 보상 완료 — 화면 대조 원장 F4 · Figma `251:113651`(퀘스트 완료) · `482:15084`(광고 시청 후). /// /// 명세 `518:88630` — *보상 완료 아이콘 · 보상 POP 텍스트 · 중앙 정렬* /// 명세 `518:88644` — *클릭 시 내 POP + 보유 POP Update ·* /// *퀘스트 진행 상태값 보상완료 변경 · 퀘스트 상세 화면 이동* /// 명세 `518:88685` — *광고 시청 POP = 보상 POP × 2 or ×3* /// 잔액(명세 `518:88644` *보유 POP Update*)은 시안에 표시 자리가 없어 호출부가 다시 읽는다. /// /// **POP 을 여기서 더하지 않는다.** 잔액과 원장은 서버가 쓰며(`fn-quest-attend` /// 등), 이 화면은 결과를 보여 주고 상태 전이를 호출부에 알릴 뿐이다. /// /// Music Shake 는 판정 합계(Perfect · Good · Miss · 최대 콤보)를 함께 띄우고, 그 카드를 /// 이미지로 SNS 공유한다 — PRD-04 「퀘스트 결과 카드와 SNS 공유」. class QuestRewardScreen extends StatefulWidget { const QuestRewardScreen({ super.key, required this.detail, this.reward, this.adBonusPop, this.shake, this.share, }); /// Music Shake 판정 합계(`ShakeJudge.judgement()`). 다른 유형은 null. final Map? shake; /// 공유 시트 호출. 테스트가 대신 받는다. final Future Function(ShareParams params)? share; final QuestDetail detail; /// 서버가 지급한 결과. 있으면 **이 값만 띄운다** — 설정 보상 POP 과 실제 지급액이 /// 다를 수 있고(운영값 조정·등급 배수), 화면이 설정값을 띄우면 원장과 어긋난다. final QuestReward? reward; /// 광고 추가 보상으로 서버가 지급한 기여 POP. 광고를 안 봤거나 청구가 실패했으면 null. final int? adBonusPop; @override State createState() => _QuestRewardScreenState(); } class _QuestRewardScreenState extends State { final _card = GlobalKey(); bool _sharing = false; QuestDetail get detail => widget.detail; int? get adBonusPop => widget.adBonusPop; /// 큰 숫자 — 운영 Quest 에서 광고를 봤으면 기본 보상 + 광고 증분 합계(명세 `518:88685` *광고 시청 /// POP = 보상 POP × 2 or ×3*, 배수는 B-03 운영값). 사용자 생성 Quest 는 배수가 아니라 고정 증분 /// (B-28 · C-02)이라 기본 보상만 크게 둔다. 두 값 모두 서버 지급액이고, 증분은 아래 줄에 기여 POP 으로 /// 따로 적는다(C-67 · C-122 유형 구분). int? get rewardPop { final base = widget.reward?.rewardedPop ?? detail.card.rewardPop; final ad = widget.adBonusPop; if (base == null || ad == null || detail.card.author != QuestAuthor.admin) return base; return base + ad; } /// 결과 카드를 PNG 로 굽고 문구 · 해시태그(B-78)와 함께 OS 공유 시트로 보낸다. Future _share(BuildContext button) async { if (_sharing) return; setState(() => _sharing = true); final box = button.findRenderObject() as RenderBox?; final origin = box == null ? null : box.localToGlobal(Offset.zero) & box.size; final s = widget.shake!; final text = [ 'Music Shake · ${detail.card.title}', 'PERFECT ${s['perfect']} · GOOD ${s['good']} · MISS ${s['miss']} · MAX COMBO ${s['maxCombo']}', shareHashtags(topic: 'MusicShake').join(' '), ].join('\n'); try { XFile? file; final boundary = _card.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary != null) { final image = await boundary.toImage(pixelRatio: 3); final bytes = await image.toByteData(format: ui.ImageByteFormat.png); image.dispose(); if (bytes != null) { file = XFile.fromData( bytes.buffer.asUint8List(), mimeType: 'image/png', name: 'fanuzu-music-shake.png', ); } } await (widget.share ?? SharePlus.instance.share)(ShareParams( text: text, files: file == null ? null : [file], sharePositionOrigin: origin, )); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.commonRequestFailed)), ); } } finally { if (mounted) setState(() => _sharing = false); } } @override Widget build(BuildContext context) { final shake = widget.shake; final t = Theme.of(context).textTheme; return Scaffold( body: SafeArea( // 시안의 본문 구간(위 56 머리 · 아래 72 버튼 자리) 가운데에 놓는다. child: Padding( padding: const EdgeInsets.only(top: 56), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: Center( child: RepaintBoundary( key: _card, child: ColoredBox( color: AppColors.cosmicVoid, child: Column( mainAxisSize: MainAxisSize.min, children: [ // 120 원 둘레에 그림자 6 을 포함한 132 이미지. Image.asset('assets/figma/quest-done.png', width: 132, height: 132), const SizedBox(height: 18), Text( context.l10n.questRewardDone, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, height: 1.4, color: AppColors.onCosmic, ), ), const SizedBox(height: 8), Text.rich( TextSpan( children: [ TextSpan( text: rewardPop == null ? 'POP' : '+${formatPop(rewardPop!)}', style: const TextStyle(color: Color(0xFF67D3A9)), ), TextSpan(text: context.l10n.questRewardReceived), ], ), style: const TextStyle( fontSize: 26, fontWeight: FontWeight.w600, height: 1.4, color: AppColors.onCosmic, ), ), if (adBonusPop != null) ...[ const SizedBox(height: AppSpace.s2), Text( context.l10n.questAdBonus(formatPop(adBonusPop!)), style: t.bodySmall, textAlign: TextAlign.center, ), ], if (shake != null) ...[ const SizedBox(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: ShakeTally( perfect: shake['perfect'] ?? 0, good: shake['good'] ?? 0, miss: shake['miss'] ?? 0, maxCombo: shake['maxCombo'] ?? 0, ), ), ], ], ), ), ), ), ), if (shake != null) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Builder( builder: (button) => OutlinedButton( onPressed: _sharing ? null : () => _share(button), style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(56), foregroundColor: AppColors.onCosmic, ), child: Text(context.l10n.shakeShareResult), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: FilledButton( // 설명 프레임 `518:88326` 「확인 버튼 - 클릭 시, 홈 화면 이동」 — 퀘스트 상세 · 목록까지 // 닫고 첫 화면(탭 셸)으로 돌아간다. 퀘스트는 HOME 에서만 열린다. onPressed: () { questPlanetReaction.value++; Navigator.of(context).popUntil((route) => route.isFirst); }, 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.commonDone), ), ), ], ), ), ), ); } }