604 lines
17 KiB
Dart
604 lines
17 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/app_color_palette.dart';
|
|
import '../../../../shared/theme/design_tokens.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 '../../data/models/divination_params.dart';
|
|
import '../../data/services/divination_result_builder.dart';
|
|
import 'divination_result_screen.dart';
|
|
|
|
class AutoDivinationScreen extends StatefulWidget {
|
|
const AutoDivinationScreen({super.key, required this.params});
|
|
|
|
final DivinationParams params;
|
|
|
|
@override
|
|
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
|
|
}
|
|
|
|
class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
|
with TickerProviderStateMixin {
|
|
final DivinationResultBuilder _resultBuilder = DivinationResultBuilder();
|
|
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;
|
|
|
|
@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) {
|
|
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
|
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,
|
|
onPressed: _showMockPayload,
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Text(
|
|
l10n.autoSimBalance(widget.params.coinBalance),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: palette.warning),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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 date = await showDatePicker(
|
|
context: context,
|
|
initialDate: _selectedTime,
|
|
firstDate: DateTime(2000),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
if (date == null || !mounted) return;
|
|
final time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.fromDateTime(_selectedTime),
|
|
);
|
|
if (time == null || !mounted) return;
|
|
setState(() {
|
|
_selectedTime = DateTime(
|
|
date.year,
|
|
date.month,
|
|
date.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<void> _showMockPayload() async {
|
|
final result = _resultBuilder.build(
|
|
params: widget.params.copyWith(divinationTime: _selectedTime),
|
|
yaoStates: _yaoStates,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => DivinationResultScreen(data: result),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _vibrateStrong() async {
|
|
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.autoGuideTitle,
|
|
guideImages: const [
|
|
'assets/images/qigua/lc1.jpg',
|
|
'assets/images/qigua/lc2.jpg',
|
|
'assets/images/qigua/lc3.jpg',
|
|
'assets/images/qigua/lc4.jpg',
|
|
'assets/images/qigua/lc5.jpg',
|
|
],
|
|
instructionText: l10n.autoGuideInstruction,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
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('yyyy年MM月dd日 HH:mm').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.yinYang[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/yangmian.jpg'
|
|
: 'assets/images/qigua/yinmian.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),
|
|
),
|
|
);
|
|
}
|
|
}
|