108 lines
3.4 KiB
Dart
108 lines
3.4 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})? _batch;
|
|
bool get needsRetry => _batch != null && !sending;
|
|
|
|
/// 지금 결과가 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();
|
|
}
|
|
|
|
void tap(String fandomId) {
|
|
if (_disposed || sending || _batch != null || fandomId.isEmpty) return;
|
|
questReaction = false;
|
|
if (pending == 0) fxHaptic(FxIntensity.small);
|
|
pending++;
|
|
touchSerial++;
|
|
notifyListeners();
|
|
_burst ??= Timer(const Duration(milliseconds: 450), () => _flush(fandomId));
|
|
}
|
|
|
|
void retry() {
|
|
if (_batch case final batch?) _flush(batch.fandomId);
|
|
}
|
|
|
|
Future<void> _flush(String fandomId) async {
|
|
if (_disposed || sending) return;
|
|
final batch =
|
|
_batch ??
|
|
(
|
|
fandomId: fandomId,
|
|
count: pending,
|
|
key: 'z${DateTime.now().microsecondsSinceEpoch}',
|
|
);
|
|
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;
|
|
result = receipt;
|
|
_batch = null;
|
|
} catch (error) {
|
|
if (_disposed) return;
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
_burst?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|