이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유

This commit is contained in:
Sn-Kinos
2026-10-05 21:39:22 +09:00
parent 65669b969e
commit 17c658b272
30 changed files with 1337 additions and 144 deletions
+106 -3
View File
@@ -1,7 +1,13 @@
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';
@@ -15,14 +21,25 @@ import '../../core/i18n/app_locale.dart';
///
/// **POP 을 여기서 더하지 않는다.** 잔액과 원장은 서버가 쓰며(`fn-quest-attend`
/// 등), 이 화면은 결과를 보여 주고 상태 전이를 호출부에 알릴 뿐이다.
class QuestRewardScreen extends StatelessWidget {
///
/// 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 과 실제 지급액이
@@ -32,10 +49,64 @@ class QuestRewardScreen extends StatelessWidget {
/// 광고 추가 보상으로 서버가 지급한 기여 POP. 광고를 안 봤거나 청구가 실패했으면 null.
final int? adBonusPop;
int? get rewardPop => reward?.rewardedPop ?? detail.card.rewardPop;
@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;
int? get rewardPop => widget.reward?.rewardedPop ?? detail.card.rewardPop;
/// 결과 카드를 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(
@@ -47,7 +118,11 @@ class QuestRewardScreen extends StatelessWidget {
children: [
Expanded(
child: Center(
child: Column(
child: RepaintBoundary(
key: _card,
child: ColoredBox(
color: AppColors.cosmicVoid,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 120 원 둘레에 그림자 6 을 포함한 132 이미지.
@@ -88,10 +163,38 @@ class QuestRewardScreen extends StatelessWidget {
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(