Files
fanuzu/app/lib/features/galaxy/zap_controller.dart
T

142 lines
5.2 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../core/data/backend.dart';
import 'galaxy_data.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/fx_intensity.dart';
/// HOME과 Galaxy의 동일한 기여 거래: 연타 합산, 확정 결과, 동일 키 재확인.
class ZapController extends ChangeNotifier {
ZapController(this.repository, {required this.onFailure});
final GalaxyRepository repository;
final ValueChanged<String> onFailure;
int? balance;
int pending = 0;
bool sending = false;
int touchSerial = 0;
ZapResult? result;
Timer? _burst;
bool _disposed = false;
({String fandomId, int count, String key, int streak})? _batch;
bool get needsRetry => _batch != null && !sending;
/// 아직 확정되지 않은 연타 — 보내는 중인 묶음 + 다음 묶음. 표시 잔액은 이만큼 미리 뺀다.
int get queued => pending + (sending ? _batch?.count ?? 0 : 0);
/// 이어서 누른 횟수 — Figma `2024:20758` 「0.5초간 [+1] 표시(연속 가능)」: [+1] 이 떠 있는
/// [streakWindow] 안에 다시 누르면 이어지고, 넘기면 1 부터 다시 센다.
int streak = 0;
Timer? _streakTimer;
/// 마지막으로 확정된 묶음의 끝 탭이 몇 번째 연속이었나 — 확정 결과 [+1] · [+10] 은 이것으로 정한다.
/// 확정 안 된 뒤 연타는 세지 않는다.
int confirmedStreak = 0;
String? _pendingFandom;
static const streakWindow = Duration(milliseconds: 500);
/// 지금 결과가 ZAP 이 아니라 퀘스트 보상 확정의 행성 반응인가([react]).
bool questReaction = false;
/// 퀘스트 보상 확정 뒤 행성 반응 — v1.5 4장 「성공 햅틱 → 보상/기여 에너지 이동 → Planet 반응 →
/// 실제 지급/성장 결과」의 에너지 이동 · 반응 두 단계. ZAP 과 같은 이동 · 흡수 연출을 쓰고 잔액은
/// 건드리지 않는다. 서버가 보상을 확정한 뒤에만 부른다(C-79).
Future<void> react() async {
if (_disposed || sending || pending > 0) return;
questReaction = true;
touchSerial++;
notifyListeners();
// 에너지가 행성에 닿은 뒤 흡수 — ZapFeedback 이동 시간(380ms)과 같다.
await Future<void>.delayed(const Duration(milliseconds: 380));
if (_disposed || !questReaction) return;
result = ZapResult(
txId: 'quest-${DateTime.now().microsecondsSinceEpoch}',
balanceAfter: -1,
);
notifyListeners();
}
/// 보내는 중에도 받는다 — 다음 묶음으로 쌓였다가 앞 묶음이 확정되면 보낸다. 실패한 묶음이
/// 있으면([needsRetry]) 같은 키로 다시 확인하기 전까지 받지 않는다.
void tap(String fandomId) {
if (_disposed || needsRetry || fandomId.isEmpty) return;
// 쌓인 연타는 한 행성 몫이다. 다른 행성 탭은 쌓인 묶음이 나간 뒤에 받는다.
if (pending > 0 && fandomId != _pendingFandom) return;
_pendingFandom = fandomId;
questReaction = false;
if (pending == 0) fxHaptic(FxIntensity.small);
streak = _streakTimer?.isActive == true ? streak + 1 : 1;
_streakTimer?.cancel();
_streakTimer = Timer(streakWindow, () {});
pending++;
touchSerial++;
notifyListeners();
_burst ??= Timer(const Duration(milliseconds: 450), () {
_burst = null;
_flush(_pendingFandom!);
});
}
void retry() {
if (_batch case final batch?) _flush(batch.fandomId);
}
Future<void> _flush(String fandomId) async {
// 보내는 중이면 확정 뒤(finally)에 이어 보낸다.
if (_disposed || sending) return;
final batch =
_batch ??
(
fandomId: fandomId,
count: pending,
key: 'z${DateTime.now().microsecondsSinceEpoch}',
streak: streak,
);
if (batch.count == 0) return;
_burst?.cancel();
_burst = null;
_batch = batch;
sending = true;
pending = 0;
notifyListeners();
try {
final receipt = await repository.zap(
fandomId: batch.fandomId,
count: batch.count,
clientSeq: batch.key,
);
if (_disposed) return;
if (receipt.txId.isEmpty) throw StateError('Missing ZAP receipt');
if (receipt.balanceAfter >= 0) balance = receipt.balanceAfter;
confirmedStreak = batch.streak;
result = receipt;
_batch = null;
} catch (error) {
if (_disposed) return;
// 실패하면 뒤에 쌓인 연타는 보내지 않는다 — 실패 묶음을 같은 키로 다시 확인하는 것이 먼저다.
pending = 0;
_burst?.cancel();
_burst = null;
onFailure(switch (error) {
BackendException(code: 'E-POP-01') => appL10n.zapFailNoPop,
BackendException(code: 'E-POP-02') => appL10n.zapFailLedger,
BackendException(code: 'E-AUTH-03') => appL10n.authLoginRequired,
_ => appL10n.zapFailRetry,
});
} finally {
if (!_disposed) {
sending = false;
notifyListeners();
if (_batch == null && pending > 0 && _burst == null) _flush(_pendingFandom!);
}
}
}
@override
void dispose() {
_disposed = true;
_burst?.cancel();
_streakTimer?.cancel();
super.dispose();
}
}