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 Function(DivinationResultData result) onCompleted; @override State createState() => _AutoDivinationScreenState(); } class _AutoDivinationScreenState extends State with TickerProviderStateMixin { final Random _random = Random.secure(); final List _yaoStates = List.filled( 6, YaoType.undetermined, ); late final AnimationController _spinController; StreamSubscription? _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 _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.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 _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 _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( 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( MaterialPageRoute( builder: (_) => DivinationProcessingScreen( params: widget.params.copyWith(divinationTime: _selectedTime), yaoStates: _yaoStates, runService: widget.runService, onCompleted: widget.onCompleted, ), ), ); } finally { if (mounted) { setState(() { _submitting = false; }); } } } Future _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 _showGuide(BuildContext context, AppLocalizations l10n) async { await showDialog( 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 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), ), ); } }