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 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 react() async { if (_disposed || sending || pending > 0) return; questReaction = true; touchSerial++; notifyListeners(); // 에너지가 행성에 닿은 뒤 흡수 — ZapFeedback 이동 시간(380ms)과 같다. await Future.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 _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(); } }