Files
eryao/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart
T

666 lines
19 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:vibration/vibration.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/gua_icon.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/divination/divination_terms.dart';
import '../../../../shared/widgets/divination/yao_legend.dart';
import '../../../../shared/widgets/divination/yao_line_row.dart';
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/divination_backend_models.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_processing_screen.dart';
class AutoDivinationScreen extends StatefulWidget {
const AutoDivinationScreen({
super.key,
required this.params,
required this.runService,
required this.onCompleted,
});
final DivinationParams params;
final DivinationRunService runService;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
}
class _AutoDivinationScreenState extends State<AutoDivinationScreen>
with TickerProviderStateMixin {
final Random _random = Random.secure();
final List<YaoType> _yaoStates = List<YaoType>.filled(
6,
YaoType.undetermined,
);
late final AnimationController _spinController;
StreamSubscription<AccelerometerEvent>? _accSubscription;
DateTime _selectedTime = DateTime.now();
bool _isSpinning = false;
bool _coin1Yang = true;
bool _coin2Yang = true;
bool _coin3Yang = true;
int _countdown = 0;
int _shakeCount = 0;
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
bool _spinLocked = false;
bool _submitting = false;
@override
void initState() {
super.initState();
_selectedTime = widget.params.divinationTime;
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_listenShake();
}
@override
void dispose() {
_accSubscription?.cancel();
_spinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: Text(l10n.autoScreenTitle),
centerTitle: true,
backgroundColor: colors.surface,
surfaceTintColor: colors.surface,
),
body: _buildBody(context, l10n),
);
}
Widget _buildBody(BuildContext context, AppLocalizations l10n) {
return SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
children: [
_InstructionCard(onTap: () => _showGuide(context, l10n)),
const SizedBox(height: AppSpacing.lg),
_TimeSelectorCard(selectedTime: _selectedTime, onPickTime: _pickTime),
const SizedBox(height: AppSpacing.lg),
_YaoPickerCard(
isSpinning: _isSpinning,
coin1Yang: _coin1Yang,
coin2Yang: _coin2Yang,
coin3Yang: _coin3Yang,
spinController: _spinController,
countdown: _countdown,
shakeCount: _shakeCount,
canShake: _canShake,
onStartShake: _startSpin,
buttonText: _buttonText(l10n),
statusText: _statusText(l10n),
),
const SizedBox(height: AppSpacing.lg),
_HexagramCard(yaoStates: _yaoStates),
const SizedBox(height: AppSpacing.lg),
_ResolveButton(
enabled: _shakeCount >= 6 && !_submitting,
onPressed: _submitRun,
),
],
),
);
}
bool get _canShake => !_isSpinning && _shakeCount < 6;
String _buttonText(AppLocalizations l10n) {
if (_isSpinning) return l10n.autoShaking;
if (_shakeCount == 0) return l10n.autoStartShake;
if (_shakeCount < 6) return l10n.autoContinueShake;
return l10n.autoFinishShake;
}
String _statusText(AppLocalizations l10n) {
if (_isSpinning && _countdown > 0) {
return l10n.autoShakeCountdown(_countdown);
}
if (_shakeCount >= 6) {
return l10n.autoShakeComplete;
}
return l10n.autoShakeRemaining(6 - _shakeCount);
}
void _listenShake() {
_accSubscription = accelerometerEventStream().listen((event) {
if (!_canShake) return;
final acc =
sqrt(event.x * event.x + event.y * event.y + event.z * event.z) - 9.8;
final now = DateTime.now();
if (acc > 15 && now.difference(_lastShake).inMilliseconds > 1500) {
_lastShake = now;
_startSpin();
}
});
}
Future<void> _startSpin() async {
if (!_canShake || _spinLocked) return;
_spinLocked = true;
setState(() {
_isSpinning = true;
_countdown = 3;
});
try {
await _vibrateStrong();
if (!mounted) return;
_spinController.repeat();
for (int i = 3; i > 0; i--) {
await Future<void>.delayed(const Duration(seconds: 1));
if (!mounted) return;
setState(() {
_countdown = i - 1;
});
}
if (!mounted) return;
final c1 = _random.nextBool();
final c2 = _random.nextBool();
final c3 = _random.nextBool();
final yangCount = [c1, c2, c3].where((v) => v).length;
final yao = switch (yangCount) {
0 => YaoType.oldYin,
1 => YaoType.youngYang,
2 => YaoType.youngYin,
3 => YaoType.oldYang,
_ => YaoType.undetermined,
};
setState(() {
_isSpinning = false;
_coin1Yang = c1;
_coin2Yang = c2;
_coin3Yang = c3;
if (_shakeCount < 6) {
_yaoStates[_shakeCount] = yao;
_shakeCount++;
}
});
_spinController
..stop()
..reset();
await _vibrateStrong();
} finally {
_spinLocked = false;
}
}
Future<void> _pickTime() async {
final result = await showDateTimePickerBottomSheet(
context: context,
initialDateTime: _selectedTime,
minDateTime: DateTime(2000),
maxDateTime: DateTime(2100),
);
if (result == null || !mounted) return;
setState(() {
_selectedTime = result;
});
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
PointsBalanceData points;
try {
points = await widget.runService.getPointsBalance();
} catch (_) {
if (!mounted) {
return;
}
Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error);
return;
}
if (!points.canRun || points.availableBalance < points.runCost) {
if (!mounted) {
return;
}
Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning);
return;
}
if (!mounted) {
return;
}
final shouldStart = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AppModalDialog(
title: l10n.divinationCostDialogTitle,
message: l10n.divinationCostDialogBody(
points.runCost,
points.availableBalance,
),
iconWidget: const GuaIcon(),
actions: [
AppModalDialogAction(
label: l10n.cancel,
onPressed: () => Navigator.of(dialogContext).pop(false),
),
AppModalDialogAction(
label: l10n.divinationCostDialogConfirm,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(true),
),
],
);
},
);
if (shouldStart != true) {
return;
}
setState(() {
_submitting = true;
});
try {
if (!mounted) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DivinationProcessingScreen(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _yaoStates,
runService: widget.runService,
onCompleted: widget.onCompleted,
),
),
);
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
Future<void> _vibrateStrong() async {
if (!widget.params.allowVibration) {
return;
}
final hasVibrator = await Vibration.hasVibrator();
if (hasVibrator == true) {
await Vibration.vibrate(duration: 280, amplitude: 255);
return;
}
await HapticFeedback.heavyImpact();
}
Future<void> _showGuide(BuildContext context, AppLocalizations l10n) async {
await showDialog<void>(
context: context,
builder: (context) {
return DivinationGuideDialog(
title: l10n.divinationManualGuideTitle,
guideImages: const [
['assets/images/tutorial/tutorial_1.png'],
['assets/images/tutorial/tutorial_2.png'],
['assets/images/tutorial/tutorial_3.png'],
],
instructions: [
l10n.divinationManualGuideStep1,
l10n.divinationManualGuideStep2,
l10n.divinationManualGuideStep3,
],
);
},
);
}
}
class _InstructionCard extends StatelessWidget {
const _InstructionCard({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return DivinationInstructionCard(
text: l10n.autoShakeInstruction,
onTap: onTap,
);
}
}
class _TimeSelectorCard extends StatelessWidget {
const _TimeSelectorCard({
required this.selectedTime,
required this.onPickTime,
});
final DateTime selectedTime;
final VoidCallback onPickTime;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.autoSelectTime,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: Text(
DateFormat.yMd(
Localizations.localeOf(context).toString(),
).add_Hm().format(selectedTime),
style: Theme.of(context).textTheme.titleMedium,
),
),
OutlinedButton(
onPressed: onPickTime,
child: Text(l10n.divinationModify),
),
],
),
],
),
),
);
}
}
class _YaoPickerCard extends StatelessWidget {
const _YaoPickerCard({
required this.isSpinning,
required this.coin1Yang,
required this.coin2Yang,
required this.coin3Yang,
required this.spinController,
required this.countdown,
required this.shakeCount,
required this.canShake,
required this.onStartShake,
required this.buttonText,
required this.statusText,
});
final bool isSpinning;
final bool coin1Yang;
final bool coin2Yang;
final bool coin3Yang;
final AnimationController spinController;
final int countdown;
final int shakeCount;
final bool canShake;
final VoidCallback onStartShake;
final String buttonText;
final String statusText;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.autoCoinDivination,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: AppSpacing.md),
Text(
statusText,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.lg),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_CoinColumn(
isSpinning: isSpinning,
isYang: coin1Yang,
spinController: spinController,
seed: 1,
),
_CoinColumn(
isSpinning: isSpinning,
isYang: coin2Yang,
spinController: spinController,
seed: 2,
),
_CoinColumn(
isSpinning: isSpinning,
isYang: coin3Yang,
spinController: spinController,
seed: 3,
),
],
),
const SizedBox(height: AppSpacing.lg),
FilledButton(
onPressed: canShake ? onStartShake : null,
style: FilledButton.styleFrom(
backgroundColor: isSpinning
? colors.surfaceContainerHighest
: colors.primary,
fixedSize: const Size(120, 40),
),
child: Text(buttonText),
),
],
),
),
);
}
}
class _CoinColumn extends StatelessWidget {
const _CoinColumn({
required this.isSpinning,
required this.isYang,
required this.spinController,
required this.seed,
});
final bool isSpinning;
final bool isYang;
final AnimationController spinController;
final int seed;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
_CoinFace(
isSpinning: isSpinning,
isYang: isYang,
spinController: spinController,
seed: seed,
),
const SizedBox(height: AppSpacing.sm),
Text(
DivinationTerms.ziHua[isYang] ?? '',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w700,
),
),
],
);
}
}
class _CoinFace extends StatelessWidget {
const _CoinFace({
required this.isSpinning,
required this.isYang,
required this.spinController,
required this.seed,
});
final bool isSpinning;
final bool isYang;
final AnimationController spinController;
final int seed;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: spinController,
builder: (context, _) {
final progress = (spinController.value + (seed % 100) / 100) % 1;
final rotationY = isSpinning
? (progress < 0.5 ? progress * 2 * 180 : (1 - progress) * 2 * 180)
: (isYang ? 0 : 180);
final showingYang = isSpinning ? rotationY < 90 : isYang;
final image = showingYang
? 'assets/images/qigua/hua.jpg'
: 'assets/images/qigua/zi.jpg';
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(rotationY * pi / 180),
child: ClipOval(
child: Image.asset(image, width: 80, height: 80, fit: BoxFit.cover),
),
);
},
);
}
}
class _HexagramCard extends StatelessWidget {
const _HexagramCard({required this.yaoStates});
final List<YaoType> yaoStates;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.autoHexagramForming,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: AppSpacing.md),
for (int i = 5; i >= 0; i--) _YaoRow(index: i, type: yaoStates[i]),
const SizedBox(height: AppSpacing.xs),
const Align(alignment: Alignment.centerLeft, child: YaoLegend()),
],
),
),
);
}
}
class _YaoRow extends StatelessWidget {
const _YaoRow({required this.index, required this.type});
final int index;
final YaoType type;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
child: YaoLineRow(
name: DivinationTerms.yaoNames[index],
type: type,
showChangeMark: true,
lineHeight: 8,
),
);
}
}
class _ResolveButton extends StatelessWidget {
const _ResolveButton({required this.enabled, required this.onPressed});
final bool enabled;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: enabled ? onPressed : null,
child: Text(l10n.autoStartResolve),
),
);
}
}