193 lines
6.2 KiB
Dart
193 lines
6.2 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|