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

801 lines
23 KiB
Dart
Raw Normal View History

2026-04-03 16:56:47 +08:00
import 'dart:async';
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';
2026-04-03 16:56:47 +08:00
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';
2026-04-03 16:56:47 +08:00
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';
2026-04-03 16:56:47 +08:00
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';
2026-04-03 16:56:47 +08:00
class AutoDivinationScreen extends StatefulWidget {
const AutoDivinationScreen({
super.key,
required this.params,
required this.runService,
this.divinationApi,
required this.onCompleted,
});
2026-04-03 16:56:47 +08:00
final DivinationParams params;
final DivinationRunService runService;
final DivinationApi? divinationApi;
final Future<void> Function(DivinationResultData result) onCompleted;
2026-04-03 16:56:47 +08:00
@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;
late final AnimationController _blinkController;
2026-04-03 16:56:47 +08:00
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;
2026-04-03 16:56:47 +08:00
final GlobalKey<OnboardingState> _onboardingKey =
GlobalKey<OnboardingState>();
final ScrollController _scrollController = ScrollController();
final GlobalKey _step2TargetKey = GlobalKey();
final GlobalKey _step3TargetKey = GlobalKey();
final GlobalKey _step4TargetKey = GlobalKey();
final FocusNode _step1Focus = FocusNode();
final FocusNode _step2Focus = FocusNode();
final FocusNode _step3Focus = FocusNode();
final FocusNode _step4Focus = FocusNode();
2026-04-03 16:56:47 +08:00
@override
void initState() {
super.initState();
_selectedTime = widget.params.divinationTime;
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_blinkController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
)..repeat(reverse: true);
2026-04-03 16:56:47 +08:00
_listenShake();
}
@override
void dispose() {
_accSubscription?.cancel();
_scrollController.dispose();
2026-04-03 16:56:47 +08:00
_spinController.dispose();
_blinkController.dispose();
_step1Focus.dispose();
_step2Focus.dispose();
_step3Focus.dispose();
_step4Focus.dispose();
2026-04-03 16:56:47 +08:00
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) {
final steps = [
OnboardingStep(
focusNode: _step1Focus,
titleText: l10n.autoGuideStep1Title,
bodyText: l10n.autoGuideStep1Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: false,
hasLabelBox: true,
fullscreen: true,
overlayColor: Colors.black.withValues(alpha: 0.7),
),
OnboardingStep(
focusNode: _step2Focus,
titleText: l10n.autoGuideStep2Title,
bodyText: l10n.autoGuideStep2Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: true,
hasLabelBox: true,
arrowPosition: ArrowPosition.top,
overlayColor: Colors.black.withValues(alpha: 0.7),
delay: const Duration(milliseconds: 320),
),
OnboardingStep(
focusNode: _step3Focus,
titleText: l10n.autoGuideStep3Title,
bodyText: l10n.autoGuideStep3Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: true,
hasLabelBox: true,
arrowPosition: ArrowPosition.top,
overlayColor: Colors.black.withValues(alpha: 0.7),
delay: const Duration(milliseconds: 320),
),
OnboardingStep(
focusNode: _step4Focus,
titleText: l10n.autoGuideStep4Title,
bodyText: l10n.autoGuideStep4Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: true,
hasLabelBox: true,
arrowPosition: ArrowPosition.top,
overlayColor: Colors.black.withValues(alpha: 0.7),
delay: const Duration(milliseconds: 320),
),
];
return Onboarding(
key: _onboardingKey,
steps: steps,
onChanged: _onGuideStepChanged,
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
children: [
_InstructionCard(onTap: _showGuide),
const SizedBox(height: AppSpacing.lg),
_TimeSelectorCard(
selectedTime: _selectedTime,
onPickTime: _pickTime,
),
const SizedBox(height: AppSpacing.lg),
Container(
key: _step2TargetKey,
child: Focus(
focusNode: _step2Focus,
child: _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),
Container(
key: _step3TargetKey,
child: Focus(
focusNode: _step3Focus,
child: _HexagramCard(yaoStates: _yaoStates),
),
),
const SizedBox(height: AppSpacing.lg),
Container(
key: _step4TargetKey,
child: Focus(
focusNode: _step4Focus,
child: _ResolveButton(
enabled: _shakeCount >= 6 && !_submitting,
onPressed: _submitRun,
blinkAnimation: _blinkController,
),
),
),
],
),
2026-04-03 16:56:47 +08:00
),
);
}
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 ziCount = [c1, c2, c3].where((v) => v).length;
final yao = YaoCoinConverter.fromZiCount(ziCount);
2026-04-03 16:56:47 +08:00
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(
2026-04-03 16:56:47 +08:00
context: context,
initialDateTime: _selectedTime,
minDateTime: DateTime(2000),
maxDateTime: DateTime(2100),
2026-04-03 16:56:47 +08:00
);
if (result == null || !mounted) return;
2026-04-03 16:56:47 +08:00
setState(() {
_selectedTime = result;
2026-04-03 16:56:47 +08:00
});
}
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,
divinationApi: widget.divinationApi,
onCompleted: widget.onCompleted,
),
),
);
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
2026-04-03 16:56:47 +08:00
}
}
Future<void> _vibrateStrong() async {
if (!widget.params.allowVibration) {
return;
}
2026-04-03 16:56:47 +08:00
final hasVibrator = await Vibration.hasVibrator();
if (hasVibrator == true) {
await Vibration.vibrate(duration: 280, amplitude: 255);
return;
}
await HapticFeedback.heavyImpact();
}
void _showGuide() {
_scrollToGuideStep(0);
Future<void>.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) {
2 => _step3TargetKey,
3 => _step4TargetKey,
_ => null,
};
final targetContext = targetKey?.currentContext;
if (targetContext == null) {
return;
}
Scrollable.ensureVisible(
targetContext,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOut,
alignment: 0.12,
2026-04-03 16:56:47 +08:00
);
}
}
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),
2026-04-03 16:56:47 +08:00
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),
Align(
alignment: Alignment.center,
child: FilledButton(
onPressed: canShake ? onStartShake : null,
style: FilledButton.styleFrom(
backgroundColor: isSpinning
? colors.surfaceContainerHighest
: colors.primary,
minimumSize: const Size(120, 40),
),
child: Text(buttonText),
2026-04-03 16:56:47 +08:00
),
),
],
),
),
);
}
}
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(
_coinFaceLabel(context, isYang),
2026-04-03 16:56:47 +08:00
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w700,
),
),
],
);
}
String _coinFaceLabel(BuildContext context, bool isYang) {
final l10n = AppLocalizations.of(context)!;
return isYang ? l10n.autoCoinFaceZi : l10n.autoCoinFaceHua;
}
2026-04-03 16:56:47 +08:00
}
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/zi.jpg'
: 'assets/images/qigua/hua.jpg';
2026-04-03 16:56:47 +08:00
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),
],
),
),
);
}
}
class _YaoRow extends StatelessWidget {
const _YaoRow({required this.index, required this.type});
final int index;
final YaoType type;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
2026-04-03 16:56:47 +08:00
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
child: YaoLineRow(
name: DivinationTerms.yaoName(l10n, index),
2026-04-03 16:56:47 +08:00
type: type,
showChangeMark: true,
lineHeight: 8,
),
);
}
}
class _ResolveButton extends StatelessWidget {
const _ResolveButton({
required this.enabled,
required this.onPressed,
required this.blinkAnimation,
});
2026-04-03 16:56:47 +08:00
final bool enabled;
final VoidCallback onPressed;
final Animation<double> blinkAnimation;
2026-04-03 16:56:47 +08:00
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return AnimatedBuilder(
animation: blinkAnimation,
builder: (context, _) {
final base = colors.primary;
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: enabled ? onPressed : null,
style: FilledButton.styleFrom(
backgroundColor: enabled
? base.withValues(alpha: 0.6 + blinkAnimation.value * 0.4)
: base,
),
child: Text(l10n.autoStartResolve),
),
);
},
2026-04-03 16:56:47 +08:00
);
}
}