HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:math' as math;
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';
/// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다.
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<AccelerometerEvent>? accelerometer;
@override
State<HeroParticipationSheet> createState() => _HeroParticipationSheetState();
}
class _HeroParticipationSheetState extends State<HeroParticipationSheet>
with WidgetsBindingObserver {
StreamSubscription<AccelerometerEvent>? _sensor;
PlanetVisualState? _state;
final _received = <String, HeroDropReceipt>{};
final _unavailable = <String>{};
String? _notice;
bool _accessDenied = false;
String? _pendingId;
bool _sending = false;
bool _failed = false;
bool _foreground = true;
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_sensor = (widget.accelerometer ?? accelerometerEventStream()).listen((event) {
final magnitude = math.sqrt(event.x * event.x + event.y * event.y + event.z * event.z);
final now = DateTime.now();
if (!magnitude.isFinite || magnitude < 18 || now.difference(_lastShake).inMilliseconds < 220) return;
_lastShake = now;
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<void> _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<PlanetVisualState>(
key: ValueKey(widget.fandomId),
create: () => widget.watch(widget.fandomId),
builder: (context, state) {
_state = state;
final available = _next != null;
final total = _received.values.fold<int>(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),
),
]),
));
},
);
}