import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:onboarding_overlay/onboarding_overlay.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_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/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; import '../../data/models/divination_result.dart'; import '../../data/models/yao_coin_converter.dart'; import '../../data/services/divination_run_service.dart'; import 'divination_processing_screen.dart'; class ManualDivinationScreen extends StatefulWidget { const ManualDivinationScreen({ super.key, required this.params, required this.runService, this.divinationApi, required this.onCompleted, }); final DivinationParams params; final DivinationRunService runService; final DivinationApi? divinationApi; final Future Function(DivinationResultData result) onCompleted; @override State createState() => _ManualDivinationScreenState(); } class _ManualDivinationScreenState extends State with TickerProviderStateMixin { late DateTime _selectedTime; final List _selectedYaos = List.filled(6, null); late final AnimationController _blinkController; bool _submitting = false; final GlobalKey _onboardingKey = GlobalKey(); final ScrollController _scrollController = ScrollController(); final GlobalKey _timeCardKey = GlobalKey(); final GlobalKey _yaoCardKey = GlobalKey(); final GlobalKey _analyzeButtonKey = GlobalKey(); final FocusNode _guideStep1Focus = FocusNode(); final FocusNode _guideStep2Focus = FocusNode(); final FocusNode _guideStep3Focus = FocusNode(); final FocusNode _guideStep4Focus = FocusNode(); @override void initState() { super.initState(); _selectedTime = widget.params.divinationTime; _blinkController = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), )..repeat(reverse: true); } @override void dispose() { _scrollController.dispose(); _blinkController.dispose(); _guideStep1Focus.dispose(); _guideStep2Focus.dispose(); _guideStep3Focus.dispose(); _guideStep4Focus.dispose(); super.dispose(); } bool get _allSelected => _selectedYaos.every((v) => v != null); @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final guideSteps = [ OnboardingStep( focusNode: _guideStep1Focus, titleText: l10n.manualGuideStep1Title, bodyText: l10n.manualGuideStep1Body, titleTextColor: Colors.white, bodyTextColor: Colors.white, hasArrow: false, hasLabelBox: true, fullscreen: true, overlayColor: Colors.black.withValues(alpha: 0.7), ), OnboardingStep( focusNode: _guideStep2Focus, titleText: l10n.manualGuideStep2Title, bodyText: l10n.manualGuideStep2Body, titleTextColor: Colors.white, bodyTextColor: Colors.white, hasArrow: true, hasLabelBox: true, arrowPosition: ArrowPosition.top, delay: const Duration(milliseconds: 320), overlayColor: Colors.black.withValues(alpha: 0.7), ), OnboardingStep( focusNode: _guideStep3Focus, titleText: l10n.manualGuideStep3Title, bodyText: l10n.manualGuideStep3Body, titleTextColor: Colors.white, bodyTextColor: Colors.white, hasArrow: true, hasLabelBox: true, arrowPosition: ArrowPosition.top, delay: const Duration(milliseconds: 320), overlayColor: Colors.black.withValues(alpha: 0.7), ), OnboardingStep( focusNode: _guideStep4Focus, titleText: l10n.manualGuideStep4Title, bodyText: l10n.manualGuideStep4Body, titleTextColor: Colors.white, bodyTextColor: Colors.white, hasArrow: true, hasLabelBox: true, arrowPosition: ArrowPosition.top, delay: const Duration(milliseconds: 320), overlayColor: Colors.black.withValues(alpha: 0.7), ), ]; return Scaffold( backgroundColor: colors.surface, appBar: AppBar( title: Text(l10n.manualScreenTitle), centerTitle: true, backgroundColor: colors.surface, surfaceTintColor: colors.surface, ), body: Onboarding( key: _onboardingKey, steps: guideSteps, onChanged: _onGuideStepChanged, child: SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.all(AppSpacing.xl), child: Column( children: [ _buildInstruction(), const SizedBox(height: AppSpacing.lg), Container( key: _timeCardKey, child: Focus( focusNode: _guideStep2Focus, child: _TimeCard( selectedTime: _selectedTime, onPickTime: _pickTime, ), ), ), const SizedBox(height: AppSpacing.lg), Container( key: _yaoCardKey, child: Focus( focusNode: _guideStep3Focus, child: _YaoSelectionCard( selectedYaos: _selectedYaos, blinkAnimation: _blinkController, onSelect: _onSelectYao, onNeedTip: _showOrderTip, ), ), ), const SizedBox(height: AppSpacing.xl), Container( key: _analyzeButtonKey, child: Focus( focusNode: _guideStep4Focus, child: AnimatedBuilder( animation: _blinkController, builder: (context, _) { final base = colors.primary; return SizedBox( width: double.infinity, height: 50, child: FilledButton( onPressed: _allSelected && !_submitting ? _submitRun : null, style: FilledButton.styleFrom( backgroundColor: _allSelected ? base.withValues( alpha: 0.6 + _blinkController.value * 0.4, ) : base, ), child: _submitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Text(l10n.manualStartResolve), ), ); }, ), ), ), ], ), ), ), ); } Widget _buildInstruction() { final l10n = AppLocalizations.of(context)!; return DivinationInstructionCard( text: l10n.manualYaoInstruction, onTap: _showGuide, ); } void _showGuide() { _scrollToGuideStep(0); Future.delayed(const Duration(milliseconds: 120), () { if (!mounted) { return; } _onboardingKey.currentState?.show(); }); } void _onGuideStepChanged(int currentIndex) { _scrollToGuideStep(currentIndex + 1); } void _scrollToGuideStep(int stepIndex) { final GlobalKey? targetKey = switch (stepIndex) { 1 => _timeCardKey, 2 => _yaoCardKey, 3 => _analyzeButtonKey, _ => null, }; final targetContext = targetKey?.currentContext; if (targetContext == null) { return; } Scrollable.ensureVisible( targetContext, duration: const Duration(milliseconds: 260), curve: Curves.easeOut, alignment: 0.12, ); } 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; }); } void _onSelectYao(int index, YaoType type) { setState(() { _selectedYaos[index] = type; }); HapticFeedback.selectionClick(); } Future _showOrderTip() async { final l10n = AppLocalizations.of(context)!; await showDialog( context: context, builder: (dialogContext) { return AppModalDialog( title: l10n.manualYaoTipTitle, message: l10n.manualYaoTipContent, iconWidget: const GuaIcon(), actions: [ AppModalDialogAction( label: l10n.divinationIAcknowledge, primary: true, onPressed: () => Navigator.of(dialogContext).pop(), ), ], ); }, ); } 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: _selectedYaos.cast(), runService: widget.runService, divinationApi: widget.divinationApi, onCompleted: widget.onCompleted, ), ), ); } finally { if (mounted) { setState(() { _submitting = false; }); } } } } class _TimeCard extends StatelessWidget { const _TimeCard({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.manualSelectTime, 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 _YaoSelectionCard extends StatelessWidget { const _YaoSelectionCard({ required this.selectedYaos, required this.blinkAnimation, required this.onSelect, required this.onNeedTip, }); final List selectedYaos; final Animation blinkAnimation; final void Function(int, YaoType) onSelect; final Future Function() onNeedTip; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final rowNames = List.generate( 6, (i) => DivinationTerms.yaoName(l10n, i), ).reversed.toList(); 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.manualSpecifyYaoCombo, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: colors.primary, fontWeight: FontWeight.w700, ), ), const SizedBox(height: AppSpacing.md), ...List.generate(rowNames.length, (i) { final yaoIndex = 5 - i; final current = selectedYaos[yaoIndex]; final enabled = yaoIndex == 0 || selectedYaos[yaoIndex - 1] != null; final shouldBlink = enabled && current == null; return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.md), child: AnimatedBuilder( animation: blinkAnimation, builder: (context, _) { final borderColor = shouldBlink ? colors.primary.withValues( alpha: 0.3 + blinkAnimation.value * 0.7, ) : (enabled ? colors.primary : colors.outline); return OutlinedButton( style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(52), padding: const EdgeInsets.symmetric( horizontal: AppSpacing.sm, ), side: BorderSide( color: borderColor, width: shouldBlink ? 2 : 1, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm), ), ), onPressed: () { if (!enabled) { onNeedTip(); return; } _showOptionsDialog(context, yaoIndex, onSelect); }, child: YaoLineRow( name: rowNames[i], type: current ?? YaoType.undetermined, showChangeMark: true, enabled: enabled, ), ); }, ), ); }), const SizedBox(height: AppSpacing.xs), ], ), ), ); } Future _showOptionsDialog( BuildContext context, int yaoIndex, void Function(int, YaoType) onSelect, ) { return showDialog( context: context, builder: (context) { return _ThreeCoinSelectorDialog( onConfirm: (yaoType) { onSelect(yaoIndex, yaoType); Navigator.of(context).pop(); }, ); }, ); } } class _ThreeCoinSelectorDialog extends StatefulWidget { const _ThreeCoinSelectorDialog({required this.onConfirm}); final void Function(YaoType) onConfirm; @override State<_ThreeCoinSelectorDialog> createState() => _ThreeCoinSelectorDialogState(); } class _ThreeCoinSelectorDialogState extends State<_ThreeCoinSelectorDialog> { final List _coinStates = [false, false, false]; YaoType get _currentYaoType { final huaCount = _coinStates.where((isHua) => isHua).length; return YaoCoinConverter.fromHuaCount(huaCount); } void _toggleCoin(int index) { setState(() { _coinStates[index] = !_coinStates[index]; }); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.lg), ), child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.manualSelectYaoTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: colors.primary, fontWeight: FontWeight.w700, ), ), IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), const SizedBox(height: AppSpacing.lg), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: List.generate(3, (index) { return _FlippingCoin( isHua: _coinStates[index], onTap: () => _toggleCoin(index), ); }), ), const SizedBox(height: AppSpacing.md), Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(AppRadius.md), ), child: Text( l10n.manualCoinSelectHint, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), textAlign: TextAlign.center, ), ), const SizedBox(height: AppSpacing.lg), SizedBox( width: double.infinity, child: FilledButton( onPressed: () => widget.onConfirm(_currentYaoType), child: Text(l10n.confirm), ), ), ], ), ), ); } } class _FlippingCoin extends StatefulWidget { const _FlippingCoin({required this.isHua, required this.onTap}); final bool isHua; final VoidCallback onTap; @override State<_FlippingCoin> createState() => _FlippingCoinState(); } class _FlippingCoinState extends State<_FlippingCoin> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; bool _showHua = false; @override void initState() { super.initState(); _showHua = widget.isHua; _controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _animation = Tween( begin: 0, end: 1, ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); } @override void didUpdateWidget(_FlippingCoin oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.isHua != widget.isHua) { if (widget.isHua != _showHua) { _controller.forward().then((_) { setState(() { _showHua = widget.isHua; }); _controller.reverse(); }); } } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( onTap: widget.onTap, child: AnimatedBuilder( animation: _animation, builder: (context, child) { final angle = _animation.value * pi; return ClipOval( child: Transform( alignment: Alignment.center, transform: Matrix4.identity() ..setEntry(3, 2, 0.001) ..rotateY(angle), child: SizedBox( width: 80, height: 80, child: Image.asset( _showHua ? 'assets/images/qigua/hua.jpg' : 'assets/images/qigua/zi.jpg', fit: BoxFit.cover, ), ), ), ); }, ), ); } }