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