import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:sensors_plus/sensors_plus.dart'; import '../../core/data/planet_visual_state.dart'; import '../../core/data/backend.dart'; import '../../core/ui/live_stream.dart'; import 'planet_data.dart'; import '../../core/i18n/app_locale.dart'; import '../quest/quest_evidence.dart' show ShakeDetector; /// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다. class HeroParticipationSheet extends StatefulWidget { const HeroParticipationSheet({ super.key, required this.fandomId, required this.watch, required this.repository, this.accelerometer, }); final String fandomId; final PlanetWatch watch; final PlanetRepository repository; final Stream? accelerometer; @override State createState() => _HeroParticipationSheetState(); } class _HeroParticipationSheetState extends State with WidgetsBindingObserver { StreamSubscription? _sensor; PlanetVisualState? _state; final _received = {}; final _unavailable = {}; String? _notice; bool _accessDenied = false; String? _pendingId; bool _sending = false; bool _failed = false; bool _foreground = true; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); final shakes = ShakeDetector(); _sensor = (widget.accelerometer ?? accelerometerEventStream(samplingPeriod: ShakeDetector.sampling)) .listen((event) { if (!shakes.add(event.x, event.y, event.z, DateTime.now())) return; if (!_failed) _claim(); }, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다. } @override void didChangeAppLifecycleState(AppLifecycleState state) { _foreground = state == AppLifecycleState.resumed; } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _sensor?.cancel(); super.dispose(); } String? get _next { final state = _state; if (_accessDenied || state?.isHeroTime != true || state?.heroEndsAt?.isAfter(DateTime.now()) != true) return null; final ids = (state?.heroEventIds ?? {}) .where( (id) => !_received.containsKey(id) && !_unavailable.contains(id), ) .toList() ..sort(); return ids.firstOrNull; } Future _claim() async { if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent != true) return; // 응답 유실 재확인은 이벤트 종료 뒤에도 같은 멱등키로 요청한다. final id = _pendingId ?? _next; if (id == null) return; setState(() { _sending = true; _failed = false; _notice = null; _pendingId = id; }); try { final receipt = await widget.repository.claimHeroDrop(id); if (!mounted) return; setState(() { _received[id] = receipt; _pendingId = null; }); if (_foreground) HapticFeedback.lightImpact(); } catch (error) { if (!mounted) return; final code = error is BackendException ? error.code : null; setState(() { _notice = switch (code) { 'E-POP-02' => context.l10n.heroGiftUnavailable, 'E-POP-03' => context.l10n.heroGiftAlready, 'E-AUTH-03' => context.l10n.heroGiftRelogin, 'E-PERM-01' => context.l10n.heroGiftNoPermission, _ => null, }; _failed = _notice == null; if (!_failed) { _pendingId = null; _unavailable.add(id); _accessDenied = code == 'E-AUTH-03' || code == 'E-PERM-01'; } }); } finally { if (mounted) setState(() => _sending = false); } } @override Widget build(BuildContext context) => LiveStreamBuilder( key: ValueKey(widget.fandomId), create: () => widget.watch(widget.fandomId), builder: (context, state) { _state = state; final available = _next != null; final total = _received.values.fold(0, (sum, r) => sum + r.amount); return SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'HERO TIME · ZAP+', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), Text(context.l10n.heroGiftHowTo), Text(context.l10n.heroGiftPopNote), const SizedBox(height: 16), if (_received.isNotEmpty) Semantics( liveRegion: true, child: Text( context.l10n.heroGiftReceived('$total'), style: Theme.of(context).textTheme.titleMedium, ), ), if (_notice case final notice?) Semantics(liveRegion: true, child: Text(notice)), if (_failed) Text(context.l10n.heroGiftUnknown), if (!available && !_sending && !_failed && _notice == null) Text( _received.isEmpty ? context.l10n.heroGiftNone : context.l10n.heroGiftConfirmed, ), const SizedBox(height: 12), FilledButton.icon( onPressed: _sending || (!available && !_failed) ? null : _claim, icon: const Icon(Icons.auto_awesome), label: Text( _sending ? context.l10n.heroGiftChecking : _failed ? context.l10n.heroGiftRetry : context.l10n.heroGiftTap, ), ), ], ), ), ); }, ); }