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

238 lines
9.8 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: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 '../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';
/// 보상 완료 — 화면 대조 원장 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<String, int>? shake;
/// 공유 시트 호출. 테스트가 대신 받는다.
final Future<void> Function(ShareParams params)? share;
final QuestDetail detail;
/// 서버가 지급한 결과. 있으면 **이 값만 띄운다** — 설정 보상 POP 과 실제 지급액이
/// 다를 수 있고(운영값 조정·등급 배수), 화면이 설정값을 띄우면 원장과 어긋난다.
final QuestReward? reward;
/// 광고 추가 보상으로 서버가 지급한 기여 POP. 광고를 안 봤거나 청구가 실패했으면 null.
final int? adBonusPop;
@override
State<QuestRewardScreen> createState() => _QuestRewardScreenState();
}
class _QuestRewardScreenState extends State<QuestRewardScreen> {
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<void> _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: () =>
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),
),
),
],
),
),
),
);
}
}