FPI 사용자·국가 랭킹 서버 집계 · YouTube 스밍 · Music Shake 참여
This commit is contained in:
@@ -60,7 +60,8 @@ enum QuestType {
|
||||
/// 안에서만 참여를 받는다. 판정 시각은 서버 시각이다(`fn-quest-timeattack`).
|
||||
timeAttack,
|
||||
|
||||
/// 스밍·YouTube. 별첨 B 「1차 별도 완청 보상형 Quest 없음」 + Q-QC-04 회신 대기.
|
||||
/// 스밍·YouTube — 무보상 기본 참여(원장 C-04 · B-25, 9/16 확정). POP 지급 없이 참여 기록과
|
||||
/// FPI 원천만 남는다(`fn-quest-stream-participate`).
|
||||
streaming,
|
||||
|
||||
/// 사용자 생성 Vote · SNS — 인증 자료 제출 → 생성자 · 운영자 검토(원장 C-08 · C-10).
|
||||
@@ -109,6 +110,8 @@ enum QuestType {
|
||||
/// `quests.rewardPop` 을 보지 않는다. 그래서 출석만 운영값을 읽는다. 운영값이
|
||||
/// 없으면 null — 숫자를 지어내지 않고 화면이 중립 문구를 쓴다.
|
||||
int? rewardFrom(Map<String, dynamic> quest, Map<String, dynamic> policy) {
|
||||
// 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0.
|
||||
if (this == QuestType.streaming) return 0;
|
||||
final raw = this == QuestType.attend
|
||||
? policy['attendPop']
|
||||
: quest['rewardPop'];
|
||||
@@ -117,19 +120,13 @@ enum QuestType {
|
||||
|
||||
/// 앱이 서버 검증까지 태울 수 있는 유형인가.
|
||||
///
|
||||
/// [musicShake] 와 [streaming] 은 Q-QC-04 회신 대기라 참여를 열지 않는다
|
||||
/// (해석 확인 요청서 8.1 보류 범위). [unknown] 은 판정 함수를 모른다.
|
||||
/// [streaming] 은 무보상 참여(C-04), [musicShake] 는 BPM 구간 판정(B-24 · `fn-quest-verify-music-shake`)으로
|
||||
/// 연다(Q-QC-04 9/16 회신). [unknown] 은 판정 함수를 모른다.
|
||||
/// 내부 사유(질의 번호 등)는 화면에 내보내지 않는다 — 사용자에게는 한 문구다.
|
||||
bool get verifiable => switch (this) {
|
||||
QuestType.attend ||
|
||||
QuestType.steps ||
|
||||
QuestType.place ||
|
||||
QuestType.timeAttack ||
|
||||
QuestType.vote ||
|
||||
QuestType.sns ||
|
||||
QuestType.community => true,
|
||||
QuestType.musicShake || QuestType.streaming || QuestType.unknown => false,
|
||||
};
|
||||
bool get verifiable => this != QuestType.unknown;
|
||||
|
||||
/// 시작 뒤 진행도를 모아야 판정할 수 있는 유형 — 걸음(누적 걸음) · Music Shake(구간 판정).
|
||||
bool get gathers => this == QuestType.steps || this == QuestType.musicShake;
|
||||
|
||||
/// 참여를 열지 않는 사유. 열려 있으면 null 이다.
|
||||
String? get blockedReason =>
|
||||
@@ -645,14 +642,15 @@ class SampleQuestRepository implements QuestRepository {
|
||||
// 잠근 상태를 표본에서도 보여 준다.
|
||||
const QuestCard(
|
||||
id: 'q6',
|
||||
title: '신곡 3분 완청',
|
||||
title: '신곡 스트리밍 참여',
|
||||
categoryName: '스트리밍',
|
||||
type: QuestType.streaming,
|
||||
author: QuestAuthor.admin,
|
||||
stage: QuestStage.notStarted,
|
||||
goal: 1,
|
||||
progress: 0,
|
||||
rewardPop: 10,
|
||||
// 무보상 기본 참여(C-04).
|
||||
rewardPop: 0,
|
||||
participants: 3420,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
|
||||
@@ -86,8 +88,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
});
|
||||
// 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면
|
||||
// 상세를 열어도 실행률이 0 에 멈춘 채로 남는다.
|
||||
if (_stage == QuestStage.inProgress &&
|
||||
detail.card.type == QuestType.steps) {
|
||||
if (_stage == QuestStage.inProgress && detail.card.type.gathers) {
|
||||
_watch(detail.card);
|
||||
}
|
||||
}
|
||||
@@ -593,7 +594,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
case QuestStage.notStarted:
|
||||
// **모을 진행도가 있는 유형만 「진행중」을 지난다.** 걸음은 목표까지
|
||||
// 쌓아야 하지만 출석·방문은 그 자리에서 판정이 끝나므로 참여가 곧 완료다.
|
||||
if (detail.card.goal > 1 && detail.card.type == QuestType.steps) {
|
||||
if (detail.card.goal > 1 && detail.card.type.gathers) {
|
||||
await _start(detail);
|
||||
} else {
|
||||
await _claim(detail);
|
||||
@@ -634,6 +635,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
.listen(
|
||||
(value) {
|
||||
if (!mounted) return;
|
||||
// Music Shake — 박마다 짧은 진동으로 흔들 때를 알린다.
|
||||
if (card.type == QuestType.musicShake) HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
_live = value;
|
||||
if (card.goal > 0 && value >= card.goal) {
|
||||
@@ -712,6 +715,16 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
_busy = false;
|
||||
_stage = QuestStage.rewarded;
|
||||
});
|
||||
// 시청 · 스트리밍은 무보상 참여다(C-04) — 보상 · 광고 화면 없이 참여만 알리고 링크를 연다.
|
||||
if (detail.card.type == QuestType.streaming) {
|
||||
_toast(context.l10n.questStreamJoined);
|
||||
final url = Uri.tryParse(detail.link?.url ?? '');
|
||||
if (url != null && url.hasScheme) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
await _load();
|
||||
return;
|
||||
}
|
||||
// 이미 받은 보상의 재응답 — 보상 화면을 두 번 띄우지 않고 알리기만 한다.
|
||||
if (reward.duplicate) {
|
||||
_toast(context.l10n.questAlreadyRewarded(formatPop(reward.rewardedPop)));
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
/// 생성자로 다른 구현을 넣는다 — 센서 없는 환경에서도 옮기는 규칙을 검증할 수 있어야 한다.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:pedometer/pedometer.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -42,6 +45,72 @@ typedef StepCountProbe = Stream<int> Function();
|
||||
/// 현재 좌표 한 점을 내는 함수.
|
||||
typedef PlaceProbe = Future<PlaceFix> Function();
|
||||
|
||||
/// 흔들기 한 번마다 그 시각을 내는 스트림.
|
||||
typedef ShakeProbe = Stream<DateTime> Function();
|
||||
|
||||
/// 흔들기 감지 — 가속도 크기가 중력(9.8)을 크게 넘는 순간. 이클립스 방어 화면과 같은 문턱 ·
|
||||
/// 간격이다(같은 동작을 같은 기준으로 센다).
|
||||
Stream<DateTime> deviceShakes() async* {
|
||||
var last = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
await for (final e in accelerometerEventStream()) {
|
||||
final magnitude = math.sqrt(e.x * e.x + e.y * e.y + e.z * e.z);
|
||||
final now = DateTime.now();
|
||||
if (!magnitude.isFinite || magnitude < 18 ||
|
||||
now.difference(last) < const Duration(milliseconds: 220)) {
|
||||
continue;
|
||||
}
|
||||
last = now;
|
||||
yield now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24).
|
||||
///
|
||||
/// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것,
|
||||
/// 박에서 [perfectRatio] 박 이내면 perfect · 아니면 good, 없으면 miss. **등급은 서버가 다시
|
||||
/// 매긴다** — 이 표가 스스로 모순되면(합 ≠ 구간 수) 서버가 F 로 끊는다.
|
||||
class ShakeJudge {
|
||||
ShakeJudge({required this.bpm, required this.segments, required this.start})
|
||||
: assert(bpm > 0 && segments > 0);
|
||||
|
||||
final double bpm;
|
||||
final int segments;
|
||||
final DateTime start;
|
||||
|
||||
/// perfect 로 보는 박과의 거리(박 단위). 🟡 표시 판정용 — 등급 문턱은 서버가 가진다.
|
||||
static const perfectRatio = 0.2;
|
||||
|
||||
final _shakes = <DateTime>[];
|
||||
|
||||
Duration get beat => Duration(microseconds: (60e6 / bpm).round());
|
||||
|
||||
/// 마지막 구간의 판정 창(박 + 반 박)까지 끝나는 시각.
|
||||
DateTime get end => start.add(beat * segments - beat ~/ 2);
|
||||
|
||||
void add(DateTime at) => _shakes.add(at);
|
||||
|
||||
/// 구간별 판정 합계 — 서버 `ShakeJudgement` 와 같은 키.
|
||||
Map<String, int> judgement() {
|
||||
var perfect = 0, good = 0, miss = 0, combo = 0, maxCombo = 0;
|
||||
final beatUs = beat.inMicroseconds;
|
||||
for (var i = 0; i < segments; i++) {
|
||||
final at = start.microsecondsSinceEpoch + beatUs * i;
|
||||
final best = _shakes
|
||||
.map((s) => (s.microsecondsSinceEpoch - at).abs())
|
||||
.where((d) => d < beatUs / 2)
|
||||
.fold<int?>(null, (m, d) => m == null || d < m ? d : m);
|
||||
if (best == null) {
|
||||
miss++;
|
||||
combo = 0;
|
||||
continue;
|
||||
}
|
||||
best <= beatUs * perfectRatio ? perfect++ : good++;
|
||||
maxCombo = math.max(maxCombo, ++combo);
|
||||
}
|
||||
return {'perfect': perfect, 'good': good, 'miss': miss, 'maxCombo': maxCombo};
|
||||
}
|
||||
}
|
||||
|
||||
/// 걸음 센서 — `pedometer` 의 `stepCountStream`.
|
||||
///
|
||||
/// 값은 **부팅 이후 누적**이다. 오늘치 합계가 아니므로 저장소가 시작 시점을 기준으로
|
||||
|
||||
@@ -29,14 +29,23 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
this._backend, {
|
||||
StepCountProbe stepCount = deviceStepCount,
|
||||
PlaceProbe placeFix = devicePlaceFix,
|
||||
ShakeProbe shakes = deviceShakes,
|
||||
WalkBaseline? baseline,
|
||||
}) : _stepCount = stepCount,
|
||||
_placeFix = placeFix,
|
||||
_shakes = shakes,
|
||||
_baseline = baseline ?? WalkBaseline();
|
||||
|
||||
final Backend _backend;
|
||||
final StepCountProbe _stepCount;
|
||||
final PlaceProbe _placeFix;
|
||||
final ShakeProbe _shakes;
|
||||
|
||||
/// 마지막 Music Shake 한 판의 판정표. 다 끝난 판만 서버에 보낸다.
|
||||
ShakeJudge? _shakeRun;
|
||||
|
||||
/// 곡 BPM 이 문서에 없을 때 — 🟡 운영 Quest 는 `quests.bpm` 을 채운다.
|
||||
static const defaultBpm = 100.0;
|
||||
|
||||
/// 오늘 걸은 수를 내는 기준선. 하루 단위라 앱을 껐다 켜도 이어진다.
|
||||
final WalkBaseline _baseline;
|
||||
@@ -157,6 +166,9 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
final steps = type == QuestType.steps;
|
||||
final goal = steps
|
||||
? tiers[count.clamp(0, tiers.length - 1)]
|
||||
// Music Shake 의 목표는 곡의 구간 수다 — 진행도가 「n / 구간 수」로 찬다.
|
||||
: type == QuestType.musicShake
|
||||
? quest.intOr('totalSegments', dailyLimit)
|
||||
: dailyLimit;
|
||||
|
||||
return QuestCard(
|
||||
@@ -175,7 +187,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
: QuestStage.notStarted,
|
||||
goal: authorUid.isEmpty ? goal : quest.intOr('goal', 1),
|
||||
// 걸음은 센서를 구독해야 알 수 있어 시작 전에는 0 이다. 나머지는 당일 수행 횟수다.
|
||||
progress: steps ? (done ? goal : 0) : count,
|
||||
progress: type.gathers ? (done ? goal : 0) : count,
|
||||
rewardPop: type.rewardFrom(quest, policy),
|
||||
// 설계서 §2.8 의 `quests` 에 참여 인원 필드가 없다. 세는 곳이 없어 0 이다.
|
||||
participants: quest.intOr('participantCount'),
|
||||
@@ -435,6 +447,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
|
||||
@override
|
||||
Stream<int> progressStream(QuestCard card) {
|
||||
if (card.type == QuestType.musicShake) return _shakeProgress(card);
|
||||
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
||||
// 센서는 부팅 이후 누적을 준다. 하루 단위 기준선을 빼야 정책 원장 **B-26** 의
|
||||
// 구간(1 천·5 천·1 만·1.5 만보)과 같은 것을 세게 된다.
|
||||
@@ -460,6 +473,10 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
QuestType.community => await _backend.call('fnQuestSubmit', {'questId': card.id}),
|
||||
QuestType.timeAttack =>
|
||||
await _backend.call('fnQuestTimeAttack', {'questId': card.id}),
|
||||
// 무보상 참여(C-04) — POP 원장 없이 참여 기록 · FPI 원천만 남는다.
|
||||
QuestType.streaming =>
|
||||
await _backend.call('fnQuestStreamParticipate', {'questId': card.id}),
|
||||
QuestType.musicShake => await _verifyShake(card),
|
||||
_ => throw BackendException('E-QST-01', card.type.blockedReason ?? ''),
|
||||
};
|
||||
|
||||
@@ -502,6 +519,53 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/// 곡 박마다 한 구간 — 시작부터 박 수를 세어 내고, 마지막 구간 판정 창이 닫히면 구간 수를 낸다.
|
||||
/// 흔들기는 그동안만 모은다.
|
||||
Stream<int> _shakeProgress(QuestCard card) async* {
|
||||
_shakeRun = null;
|
||||
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
|
||||
const <String, dynamic>{};
|
||||
final segments = quest.intOr('totalSegments');
|
||||
final bpm = quest['bpm'] is num && (quest['bpm'] as num) > 0
|
||||
? (quest['bpm'] as num).toDouble()
|
||||
: defaultBpm;
|
||||
if (segments <= 0) {
|
||||
throw BackendException('E-QST-01', appL10n.questTypeUnverifiable);
|
||||
}
|
||||
final run = ShakeJudge(bpm: bpm, segments: segments, start: DateTime.now());
|
||||
final sub = _shakes().listen(run.add, onError: (_) {});
|
||||
try {
|
||||
for (var i = 1; i < segments; i++) {
|
||||
await Future<void>.delayed(run.beat);
|
||||
yield i;
|
||||
}
|
||||
await Future<void>.delayed(run.end.difference(DateTime.now()));
|
||||
_shakeRun = run;
|
||||
yield segments;
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _verifyShake(QuestCard card) async {
|
||||
final run = _shakeRun;
|
||||
if (run == null) throw BackendException('E-QST-01', appL10n.questNoShake);
|
||||
final quest = (await _backend.db.doc('quests/${card.id}').get()).data() ??
|
||||
const <String, dynamic>{};
|
||||
final ids = quest['trackIds'];
|
||||
// 곡 ID 는 서버 대조용이다 — 화면에 띄우지 않는다.
|
||||
final trackId = ids is List && ids.isNotEmpty ? '${ids.first}' : quest.strOr('trackId');
|
||||
_shakeRun = null;
|
||||
return _backend.call('fnQuestVerifyMusicShake', {
|
||||
'questId': card.id,
|
||||
'trackId': trackId,
|
||||
'totalSegments': run.segments,
|
||||
'judgement': run.judgement(),
|
||||
'occurredAt': run.end.toUtc().toIso8601String(),
|
||||
...await _fandomField(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _verifyPlace(QuestCard card) async {
|
||||
final quest = await _backend.db
|
||||
.doc('quests/${card.id}')
|
||||
|
||||
Reference in New Issue
Block a user