feat: 实现起卦、设置与积分系统
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/divination/divination_terms.dart';
|
||||
import '../../../../shared/widgets/divination/yao_glyph.dart';
|
||||
import '../../../../shared/widgets/divination/yao_legend.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/models/divination_params.dart';
|
||||
import '../../data/models/divination_result.dart';
|
||||
|
||||
class DivinationResultScreen extends StatefulWidget {
|
||||
const DivinationResultScreen({super.key, required this.data});
|
||||
|
||||
final DivinationResultData data;
|
||||
|
||||
@override
|
||||
State<DivinationResultScreen> createState() => _DivinationResultScreenState();
|
||||
}
|
||||
|
||||
enum _ResultTransitionStep { preparing, deriving, done }
|
||||
|
||||
class _DivinationResultScreenState extends State<DivinationResultScreen> {
|
||||
_ResultTransitionStep _step = _ResultTransitionStep.preparing;
|
||||
bool _showOverlay = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_playSequence();
|
||||
}
|
||||
|
||||
Future<void> _playSequence() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 420));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_step = _ResultTransitionStep.deriving;
|
||||
});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 820));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_step = _ResultTransitionStep.done;
|
||||
});
|
||||
}
|
||||
|
||||
void _dismissOverlay() {
|
||||
if (_step != _ResultTransitionStep.done) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_showOverlay = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: colors.surface,
|
||||
surfaceTintColor: colors.surface,
|
||||
title: Text(l10n.resultScreenTitle),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xl,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ResultHeader(data: widget.data),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_SignCard(signType: widget.data.signType),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_KeywordCard(keywords: widget.data.keywords),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_AnalysisCard(
|
||||
title: l10n.resultConclusion,
|
||||
content: widget.data.conclusion,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_AnalysisCard(
|
||||
title: l10n.resultAnalysis,
|
||||
content: widget.data.analysis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_AnalysisCard(
|
||||
title: l10n.resultSuggestion,
|
||||
content: widget.data.suggestion,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.warningContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning, color: palette.warning, size: 20),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.resultWarning,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: palette.warning,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
Text(
|
||||
l10n.resultBasicInfo,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_InfoCard(data: widget.data),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
Text(
|
||||
l10n.resultHexagramDetail,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_HexagramDetailCard(data: widget.data),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_showOverlay)
|
||||
_ResultTransitionOverlay(step: _step, onTapDone: _dismissOverlay),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultTransitionOverlay extends StatelessWidget {
|
||||
const _ResultTransitionOverlay({required this.step, required this.onTapDone});
|
||||
|
||||
final _ResultTransitionStep step;
|
||||
final VoidCallback onTapDone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cardText = switch (step) {
|
||||
_ResultTransitionStep.preparing => l10n.transitionPreparing,
|
||||
_ResultTransitionStep.deriving => l10n.transitionDeriving,
|
||||
_ResultTransitionStep.done => l10n.transitionDone,
|
||||
};
|
||||
|
||||
return Positioned.fill(
|
||||
child: Material(
|
||||
color: colors.surface,
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
key: const Key('result_transition_overlay_tap'),
|
||||
onTap: onTapDone,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
key: ValueKey<_ResultTransitionStep>(step),
|
||||
tween: Tween<double>(begin: pi / 2, end: 0),
|
||||
duration: const Duration(milliseconds: 460),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, angle, child) {
|
||||
final opacity = (1 - angle / (pi / 2)).clamp(0.0, 1.0);
|
||||
return Opacity(
|
||||
opacity: opacity,
|
||||
child: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..setEntry(3, 2, 0.001)
|
||||
..rotateY(angle),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 220,
|
||||
height: 320,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(
|
||||
color: colors.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colors.shadow.withValues(alpha: 0.25),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
step == _ResultTransitionStep.done
|
||||
? Icons.visibility
|
||||
: Icons.auto_awesome,
|
||||
color: colors.primary,
|
||||
size: 34,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
cardText,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultHeader extends StatelessWidget {
|
||||
const _ResultHeader({required this.data});
|
||||
|
||||
final DivinationResultData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.resultAIAnalysis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final payload = <String, dynamic>{
|
||||
'signType': data.signType,
|
||||
'question': data.params.question,
|
||||
'keywords': data.keywords,
|
||||
'conclusion': data.conclusion,
|
||||
};
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: const JsonEncoder.withIndent(' ').convert(payload),
|
||||
),
|
||||
);
|
||||
Toast.show(
|
||||
context,
|
||||
l10n.toastContentCopied,
|
||||
type: ToastType.success,
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: colors.primary),
|
||||
child: Text(l10n.resultShare),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignCard extends StatelessWidget {
|
||||
const _SignCard({required this.signType});
|
||||
|
||||
final String signType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final image = switch (signType) {
|
||||
'上上签' => 'assets/images/qigua/shangshang.jpg',
|
||||
'中上签' => 'assets/images/qigua/zhongshang.jpg',
|
||||
_ => 'assets/images/qigua/zhongxia.jpg',
|
||||
};
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: colors.surface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg),
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(
|
||||
image,
|
||||
width: double.infinity,
|
||||
height: 220,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
signType,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeywordCard extends StatelessWidget {
|
||||
const _KeywordCard({required this.keywords});
|
||||
|
||||
final String keywords;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: palette.warningContainer,
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Center(
|
||||
child: Text(
|
||||
keywords,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnalysisCard extends StatelessWidget {
|
||||
const _AnalysisCard({required this.title, required this.content});
|
||||
|
||||
final String title;
|
||||
final String content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: content));
|
||||
Toast.show(context, '$title已复制', type: ToastType.success);
|
||||
},
|
||||
child: const Text('复制'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
content,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(height: 1.65),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
const _InfoCard({required this.data});
|
||||
|
||||
final DivinationResultData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
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(
|
||||
'起卦信息',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_kv(
|
||||
context,
|
||||
'起卦时间',
|
||||
DateFormat(
|
||||
'yyyy年MM月dd日 HH:mm',
|
||||
).format(data.params.divinationTime),
|
||||
),
|
||||
_kv(
|
||||
context,
|
||||
'起卦方式',
|
||||
data.params.method == DivinationMethod.auto ? '自动起卦' : '手动起卦',
|
||||
),
|
||||
_kv(context, '问题类型', _typeLabel(data.params.questionType)),
|
||||
_kv(context, '占卜问题', data.params.question),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kv(BuildContext context, String k, String v) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$k:',
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: v,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _typeLabel(QuestionType type) {
|
||||
return switch (type) {
|
||||
QuestionType.career => '事业',
|
||||
QuestionType.love => '情感',
|
||||
QuestionType.wealth => '财富',
|
||||
QuestionType.fortune => '运势',
|
||||
QuestionType.dream => '解梦',
|
||||
QuestionType.health => '健康',
|
||||
QuestionType.study => '学业',
|
||||
QuestionType.search => '寻物',
|
||||
QuestionType.other => '其他',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _HexagramDetailCard extends StatelessWidget {
|
||||
const _HexagramDetailCard({required this.data});
|
||||
|
||||
final DivinationResultData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
children: [
|
||||
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(
|
||||
'干支信息',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _miniKV(context, '月建', data.ganzhi.yueJian),
|
||||
),
|
||||
Expanded(child: _miniKV(context, '日辰', data.ganzhi.riChen)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _miniKV(context, '月破', data.ganzhi.yuePo)),
|
||||
Expanded(
|
||||
child: _miniKV(context, '日冲', data.ganzhi.riChong),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text('五行旺衰', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_WuXingTable(data: data),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text('干支空亡', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_KongWangTable(data: data),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: colors.surfaceContainerLow,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.guaName,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (data.hasChangingYao)
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.targetGuaName,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
for (int idx = 5; idx >= 0; idx--)
|
||||
_YaoDetailRow(
|
||||
line: data.yaoLines[idx],
|
||||
target: data.targetYaoLines[idx],
|
||||
showTarget: data.hasChangingYao,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: YaoLegend(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _miniKV(BuildContext context, String key, String value) {
|
||||
return Row(
|
||||
children: [
|
||||
Text('$key:'),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WuXingTable extends StatelessWidget {
|
||||
const _WuXingTable({required this.data});
|
||||
|
||||
final DivinationResultData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colors.outline),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: DivinationTerms.wuXing
|
||||
.map(
|
||||
(k) => Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: k == DivinationTerms.wuXing.last
|
||||
? colors.surfaceContainerHigh
|
||||
: colors.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(k, textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
Row(
|
||||
children: DivinationTerms.wuXing
|
||||
.map(
|
||||
(k) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
child: Text(
|
||||
data.wuXingStatus[k] ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KongWangTable extends StatelessWidget {
|
||||
const _KongWangTable({required this.data});
|
||||
|
||||
final DivinationResultData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final rows = [
|
||||
('年', '${data.ganzhi.yearGanZhi}年', data.ganzhi.yearKongWang),
|
||||
('月', '${data.ganzhi.monthGanZhi}月', data.ganzhi.monthKongWang),
|
||||
('日', '${data.ganzhi.dayGanZhi}日', data.ganzhi.dayKongWang),
|
||||
('时', '${data.ganzhi.timeGanZhi}时', data.ganzhi.timeKongWang),
|
||||
];
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colors.outline),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final row in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 28, child: Text(row.$1)),
|
||||
Expanded(child: Text(row.$2, textAlign: TextAlign.center)),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Text(row.$3, textAlign: TextAlign.right),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _YaoDetailRow extends StatelessWidget {
|
||||
const _YaoDetailRow({
|
||||
required this.line,
|
||||
required this.target,
|
||||
required this.showTarget,
|
||||
});
|
||||
|
||||
final YaoLineData line;
|
||||
final YaoLineData target;
|
||||
final bool showTarget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _lineCell(context, line, showMark: true)),
|
||||
if (showTarget)
|
||||
Expanded(child: _lineCell(context, target, showMark: false)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _lineCell(
|
||||
BuildContext context,
|
||||
YaoLineData data, {
|
||||
required bool showMark,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
child: Text(data.spirit, textAlign: TextAlign.center),
|
||||
),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text(data.relation, textAlign: TextAlign.center),
|
||||
),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
child: Text(data.branch, textAlign: TextAlign.center),
|
||||
),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
child: Text(data.element, textAlign: TextAlign.center),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Expanded(child: YaoGlyph(type: data.type, height: 6)),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
child: Text(_changeMark(data.type), textAlign: TextAlign.center),
|
||||
),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
child: Text(showMark ? data.mark : '', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _changeMark(YaoType type) {
|
||||
return type.changeMark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import 'package:flutter/material.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/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/models/divination_params.dart';
|
||||
import 'auto_divination_screen.dart';
|
||||
import 'manual_divination_screen.dart';
|
||||
|
||||
class DivinationScreen extends StatefulWidget {
|
||||
const DivinationScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DivinationScreen> createState() => _DivinationScreenState();
|
||||
}
|
||||
|
||||
class _DivinationScreenState extends State<DivinationScreen> {
|
||||
late DivinationParams _params;
|
||||
final TextEditingController _questionController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_params = DivinationMockData.initial();
|
||||
_questionController.addListener(_syncQuestion);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_questionController
|
||||
..removeListener(_syncQuestion)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncQuestion() {
|
||||
_params = _params.copyWith(question: _questionController.text.trim());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: colors.surface,
|
||||
surfaceTintColor: colors.surface,
|
||||
title: Text(l10n.divinationScreenTitle),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _buildBody(context, l10n),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, AppLocalizations l10n) {
|
||||
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
||||
return GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xl,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_GuideEntryCard(onTap: () => _showGuide(context, l10n)),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_MethodSection(
|
||||
selected: _params.method,
|
||||
onChanged: (method) {
|
||||
setState(() {
|
||||
_params = _params.copyWith(method: method);
|
||||
});
|
||||
},
|
||||
l10n: l10n,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
Text(
|
||||
l10n.divinationQuestionTypePrompt,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_QuestionTypeSelector(
|
||||
selected: _params.questionType,
|
||||
onChanged: (type) {
|
||||
setState(() {
|
||||
_params = _params.copyWith(questionType: type);
|
||||
});
|
||||
},
|
||||
l10n: l10n,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
l10n.divinationQuestionInputPrompt,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_QuestionTextField(controller: _questionController, l10n: l10n),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
_StartButton(onPressed: _onStart, l10n: l10n),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Center(
|
||||
child: Text(
|
||||
l10n.divinationCoinBalance(_params.coinBalance),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: palette.warning),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onStart() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (_params.question.isEmpty) {
|
||||
Toast.show(
|
||||
context,
|
||||
l10n.toastPleaseInputQuestion,
|
||||
type: ToastType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_params.coinBalance <= 0) {
|
||||
Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_params.method == DivinationMethod.manual) {
|
||||
final nextParams = _params.copyWith(divinationTime: DateTime.now());
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ManualDivinationScreen(params: nextParams),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final nextParams = _params.copyWith(divinationTime: DateTime.now());
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AutoDivinationScreen(params: nextParams),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GuideEntryCard extends StatelessWidget {
|
||||
const _GuideEntryCard({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return DivinationInstructionCard(
|
||||
text: l10n.divinationRecommendManual,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MethodSection extends StatelessWidget {
|
||||
const _MethodSection({
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
required this.l10n,
|
||||
});
|
||||
|
||||
final DivinationMethod selected;
|
||||
final ValueChanged<DivinationMethod> onChanged;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.divinationSelectMethod,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
IconButton(
|
||||
onPressed: () => _showMethodTip(context, l10n),
|
||||
icon: Icon(
|
||||
Icons.help_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_MethodSegment(selected: selected, onChanged: onChanged, l10n: l10n),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MethodSegment extends StatelessWidget {
|
||||
const _MethodSegment({
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
required this.l10n,
|
||||
});
|
||||
|
||||
final DivinationMethod selected;
|
||||
final ValueChanged<DivinationMethod> onChanged;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: Row(
|
||||
children: [
|
||||
_SegmentButton(
|
||||
text: l10n.divinationManualMethod,
|
||||
selected: selected == DivinationMethod.manual,
|
||||
onTap: () => onChanged(DivinationMethod.manual),
|
||||
color: colors.primary,
|
||||
isLeft: true,
|
||||
),
|
||||
_SegmentButton(
|
||||
text: l10n.divinationAutoMethod,
|
||||
selected: selected == DivinationMethod.auto,
|
||||
onTap: () => onChanged(DivinationMethod.auto),
|
||||
color: colors.primary,
|
||||
isRight: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SegmentButton extends StatelessWidget {
|
||||
const _SegmentButton({
|
||||
required this.text,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
required this.color,
|
||||
this.isLeft = false,
|
||||
this.isRight = false,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final Color color;
|
||||
final bool isLeft;
|
||||
final bool isRight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.horizontal(
|
||||
left: isLeft ? Radius.circular(AppRadius.sm) : Radius.zero,
|
||||
right: isRight ? Radius.circular(AppRadius.sm) : Radius.zero,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? color : color.withValues(alpha: 0),
|
||||
borderRadius: BorderRadius.horizontal(
|
||||
left: isLeft ? Radius.circular(AppRadius.sm) : Radius.zero,
|
||||
right: isRight ? Radius.circular(AppRadius.sm) : Radius.zero,
|
||||
),
|
||||
border: Border.all(color: color),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: selected ? Theme.of(context).colorScheme.onPrimary : color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuestionTextField extends StatelessWidget {
|
||||
const _QuestionTextField({required this.controller, required this.l10n});
|
||||
|
||||
final TextEditingController controller;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return TextField(
|
||||
controller: controller,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.divinationQuestionInputHint,
|
||||
filled: true,
|
||||
fillColor: colors.surface,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
borderSide: BorderSide(color: colors.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
borderSide: BorderSide(color: colors.primary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StartButton extends StatelessWidget {
|
||||
const _StartButton({required this.onPressed, required this.l10n});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Text(l10n.divinationStartButton),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showMethodTip(BuildContext context, AppLocalizations l10n) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.divinationMethodTipTitle),
|
||||
content: Text(
|
||||
'${l10n.divinationMethodTipAuto}\n\n${l10n.divinationMethodTipManual}\n\n${l10n.divinationMethodTipRecommend}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.divinationIAcknowledge),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showGuide(BuildContext context, AppLocalizations l10n) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return DivinationGuideDialog(
|
||||
title: l10n.divinationManualGuideTitle,
|
||||
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.divinationManualGuideInstruction,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _QuestionTypeSelector extends StatelessWidget {
|
||||
const _QuestionTypeSelector({
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
required this.l10n,
|
||||
});
|
||||
|
||||
final QuestionType selected;
|
||||
final ValueChanged<QuestionType> onChanged;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final types = <(QuestionType, String, IconData)>[
|
||||
(QuestionType.career, l10n.questionTypeCareer, Icons.work),
|
||||
(QuestionType.love, l10n.questionTypeLove, Icons.favorite),
|
||||
(QuestionType.wealth, l10n.questionTypeWealth, Icons.attach_money),
|
||||
(QuestionType.fortune, l10n.questionTypeFortune, Icons.trending_up),
|
||||
(QuestionType.dream, l10n.questionTypeDream, Icons.bedtime),
|
||||
(QuestionType.health, l10n.questionTypeHealth, Icons.health_and_safety),
|
||||
(QuestionType.study, l10n.questionTypeStudy, Icons.school),
|
||||
(QuestionType.search, l10n.questionTypeSearch, Icons.search),
|
||||
(QuestionType.other, l10n.questionTypeOther, Icons.help),
|
||||
];
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: types.map((item) {
|
||||
final isSelected = selected == item.$1;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: _TypeChip(
|
||||
label: item.$2,
|
||||
icon: item.$3,
|
||||
isSelected: isSelected,
|
||||
onTap: () => onChanged(item.$1),
|
||||
selectedColor: colors.primary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeChip extends StatelessWidget {
|
||||
const _TypeChip({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.selectedColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final Color selectedColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
child: Container(
|
||||
width: 92,
|
||||
height: 45,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? selectedColor.withValues(alpha: 0.1)
|
||||
: colors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
border: Border.all(
|
||||
color: isSelected ? selectedColor : colors.outline,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isSelected ? selectedColor : colors.onSurface,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isSelected ? selectedColor : colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../l10n/app_localizations.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 ManualDivinationScreen extends StatefulWidget {
|
||||
const ManualDivinationScreen({super.key, required this.params});
|
||||
|
||||
final DivinationParams params;
|
||||
|
||||
@override
|
||||
State<ManualDivinationScreen> createState() => _ManualDivinationScreenState();
|
||||
}
|
||||
|
||||
class _ManualDivinationScreenState extends State<ManualDivinationScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final DivinationResultBuilder _resultBuilder = DivinationResultBuilder();
|
||||
late DateTime _selectedTime;
|
||||
final List<YaoType?> _selectedYaos = List<YaoType?>.filled(6, null);
|
||||
late final AnimationController _blinkController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedTime = widget.params.divinationTime;
|
||||
_blinkController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_blinkController.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)!;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.manualScreenTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surface,
|
||||
surfaceTintColor: colors.surface,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInstruction(),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_TimeCard(selectedTime: _selectedTime, onPickTime: _pickTime),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_YaoSelectionCard(
|
||||
selectedYaos: _selectedYaos,
|
||||
blinkAnimation: _blinkController,
|
||||
onSelect: _onSelectYao,
|
||||
onNeedTip: _showOrderTip,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
AnimatedBuilder(
|
||||
animation: _blinkController,
|
||||
builder: (context, _) {
|
||||
final base = colors.primary;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: _allSelected ? _showMockPayload : null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _allSelected
|
||||
? base.withValues(
|
||||
alpha: 0.6 + _blinkController.value * 0.4,
|
||||
)
|
||||
: base,
|
||||
),
|
||||
child: Text(l10n.manualStartResolve),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInstruction() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return DivinationInstructionCard(
|
||||
text: l10n.manualYaoInstruction,
|
||||
onTap: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return DivinationGuideDialog(
|
||||
title: l10n.manualSelectYaoTitle,
|
||||
guideImages: const [
|
||||
'assets/images/qigua/lc2.jpg',
|
||||
'assets/images/qigua/lc3.jpg',
|
||||
'assets/images/qigua/lc4.jpg',
|
||||
'assets/images/qigua/lc5.jpg',
|
||||
],
|
||||
instructionText: l10n.manualYaoTipContent,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSelectYao(int index, YaoType type) {
|
||||
setState(() {
|
||||
_selectedYaos[index] = type;
|
||||
});
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
|
||||
Future<void> _showOrderTip() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.manualYaoTipTitle),
|
||||
content: Text(l10n.manualYaoTipContent),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.divinationIAcknowledge),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showMockPayload() async {
|
||||
final result = _resultBuilder.build(
|
||||
params: widget.params.copyWith(divinationTime: _selectedTime),
|
||||
yaoStates: _selectedYaos.cast<YaoType>(),
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => DivinationResultScreen(data: result),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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('yyyy年MM月dd日 HH:mm').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<YaoType?> selectedYaos;
|
||||
final Animation<double> blinkAnimation;
|
||||
final void Function(int, YaoType) onSelect;
|
||||
final Future<void> Function() onNeedTip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final rowNames = DivinationTerms.yaoNames.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),
|
||||
const Align(alignment: Alignment.centerLeft, child: YaoLegend()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showOptionsDialog(
|
||||
BuildContext context,
|
||||
int yaoIndex,
|
||||
void Function(int, YaoType) onSelect,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final options = <(String, YaoType)>[
|
||||
(
|
||||
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]} (\u2014)\uFF1A\u82B1\u5B57\u5B57',
|
||||
YaoType.youngYang,
|
||||
),
|
||||
(
|
||||
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]} (--)\uFF1A\u5B57\u82B1\u82B1',
|
||||
YaoType.youngYin,
|
||||
),
|
||||
(
|
||||
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]} (\u2014\u25CB)\uFF1A\u82B1\u82B1\u82B1',
|
||||
YaoType.oldYang,
|
||||
),
|
||||
(
|
||||
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]} (--\u00D7)\uFF1A\u5B57\u5B57\u5B57',
|
||||
YaoType.oldYin,
|
||||
),
|
||||
];
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.manualSelectYaoTitle),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final option in options)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
onSelect(yaoIndex, option.$2);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
child: Text(option.$1),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('字花图片说明:'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Image.asset(
|
||||
'assets/images/qigua/zihua.jpg',
|
||||
width: double.infinity,
|
||||
height: 180,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.divinationClose),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user