feat: 实现起卦、设置与积分系统

This commit is contained in:
qzl
2026-04-03 16:56:47 +08:00
parent 31594558eb
commit f245eec5f6
170 changed files with 20728 additions and 328 deletions
@@ -0,0 +1,100 @@
enum DivinationMethod { manual, auto }
enum QuestionType {
career,
love,
wealth,
fortune,
dream,
health,
study,
search,
other,
}
enum YaoType { undetermined, youngYang, youngYin, oldYang, oldYin }
class DivinationParams {
const DivinationParams({
required this.method,
required this.questionType,
required this.question,
required this.divinationTime,
required this.coinBalance,
required this.userId,
});
final DivinationMethod method;
final QuestionType questionType;
final String question;
final DateTime divinationTime;
final int coinBalance;
final String userId;
DivinationParams copyWith({
DivinationMethod? method,
QuestionType? questionType,
String? question,
DateTime? divinationTime,
int? coinBalance,
String? userId,
}) {
return DivinationParams(
method: method ?? this.method,
questionType: questionType ?? this.questionType,
question: question ?? this.question,
divinationTime: divinationTime ?? this.divinationTime,
coinBalance: coinBalance ?? this.coinBalance,
userId: userId ?? this.userId,
);
}
Map<String, dynamic> toPayload() {
return <String, dynamic>{
'method': method.name,
'questionType': questionType.name,
'question': question,
'divinationTime': divinationTime.toIso8601String(),
'coinBalance': coinBalance,
'userId': userId,
};
}
String toBinary(List<YaoType> yaoStates) {
return yaoStates
.map(
(v) => switch (v) {
YaoType.youngYang || YaoType.oldYang => '1',
_ => '0',
},
)
.join();
}
String toChangedBinary(List<YaoType> yaoStates) {
return yaoStates
.map(
(v) => switch (v) {
YaoType.youngYang => '1',
YaoType.youngYin => '0',
YaoType.oldYang => '0',
YaoType.oldYin => '1',
YaoType.undetermined => '0',
},
)
.join();
}
}
class DivinationMockData {
static DivinationParams initial() {
return DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '',
divinationTime: DateTime.now(),
coinBalance: 8,
userId: 'mock_user_10001',
);
}
}
@@ -0,0 +1,91 @@
import 'divination_params.dart';
class DivinationResultData {
const DivinationResultData({
required this.params,
required this.binaryCode,
required this.changedBinaryCode,
required this.guaName,
required this.targetGuaName,
required this.upperName,
required this.lowerName,
required this.signType,
required this.keywords,
required this.conclusion,
required this.analysis,
required this.suggestion,
required this.ganzhi,
required this.wuXingStatus,
required this.yaoLines,
required this.targetYaoLines,
});
final DivinationParams params;
final String binaryCode;
final String changedBinaryCode;
final String guaName;
final String targetGuaName;
final String upperName;
final String lowerName;
final String signType;
final String keywords;
final String conclusion;
final String analysis;
final String suggestion;
final GanzhiData ganzhi;
final Map<String, String> wuXingStatus;
final List<YaoLineData> yaoLines;
final List<YaoLineData> targetYaoLines;
bool get hasChangingYao => binaryCode != changedBinaryCode;
}
class GanzhiData {
const GanzhiData({
required this.yearGanZhi,
required this.monthGanZhi,
required this.dayGanZhi,
required this.timeGanZhi,
required this.yearKongWang,
required this.monthKongWang,
required this.dayKongWang,
required this.timeKongWang,
required this.yueJian,
required this.riChen,
required this.yuePo,
required this.riChong,
});
final String yearGanZhi;
final String monthGanZhi;
final String dayGanZhi;
final String timeGanZhi;
final String yearKongWang;
final String monthKongWang;
final String dayKongWang;
final String timeKongWang;
final String yueJian;
final String riChen;
final String yuePo;
final String riChong;
}
class YaoLineData {
const YaoLineData({
required this.index,
required this.spirit,
required this.relation,
required this.branch,
required this.element,
required this.type,
required this.mark,
});
final int index;
final String spirit;
final String relation;
final String branch;
final String element;
final YaoType type;
final String mark;
}
@@ -0,0 +1,222 @@
import '../models/divination_params.dart';
import '../models/divination_result.dart';
class DivinationResultBuilder {
DivinationResultData build({
required DivinationParams params,
required List<YaoType> yaoStates,
}) {
final binaryCode = params.toBinary(yaoStates);
final changedBinaryCode = params.toChangedBinary(yaoStates);
final baseHexagram = _hexagramMap[binaryCode];
final changedHexagram = _hexagramMap[changedBinaryCode];
if (baseHexagram == null || changedHexagram == null) {
throw StateError(
'Unknown hexagram mapping for binary=$binaryCode changed=$changedBinaryCode',
);
}
final signType = _signByStates(yaoStates);
final content = _mockContent(
params.questionType,
params.question,
signType,
);
final lineData = _buildYaoLines(yaoStates, false);
final targetStates = _toChangedStates(yaoStates);
final targetLineData = _buildYaoLines(targetStates, true);
return DivinationResultData(
params: params,
binaryCode: binaryCode,
changedBinaryCode: changedBinaryCode,
guaName: baseHexagram.name,
targetGuaName: changedHexagram.name,
upperName: baseHexagram.upper,
lowerName: baseHexagram.lower,
signType: signType,
keywords: content.keywords,
conclusion: content.conclusion,
analysis: content.analysis,
suggestion: content.suggestion,
ganzhi: const GanzhiData(
yearGanZhi: '丙午',
monthGanZhi: '甲辰',
dayGanZhi: '辛亥',
timeGanZhi: '乙巳',
yearKongWang: '子丑',
monthKongWang: '申酉',
dayKongWang: '寅卯',
timeKongWang: '午未',
yueJian: '',
riChen: '',
yuePo: '',
riChong: '',
),
wuXingStatus: const {'': '', '': '', '': '', '': '', '': ''},
yaoLines: lineData,
targetYaoLines: targetLineData,
);
}
List<YaoLineData> _buildYaoLines(List<YaoType> states, bool target) {
const spirits = ['', '', '', '', '', ''];
const relations = ['父母', '兄弟', '官鬼', '妻财', '子孙', '父母'];
const branches = ['', '', '', '', '', ''];
const elements = ['', '', '', '', '', ''];
return List<YaoLineData>.generate(6, (idx) {
final mark = switch (idx) {
1 => '',
4 => '',
_ => '',
};
return YaoLineData(
index: idx,
spirit: spirits[idx],
relation: relations[idx],
branch: branches[idx],
element: elements[idx],
type: states[idx],
mark: target ? '' : mark,
);
});
}
List<YaoType> _toChangedStates(List<YaoType> source) {
return source.map((state) {
return switch (state) {
YaoType.oldYang => YaoType.youngYin,
YaoType.oldYin => YaoType.youngYang,
_ => state,
};
}).toList();
}
String _signByStates(List<YaoType> states) {
final dynamicCount = states
.where((e) => e == YaoType.oldYang || e == YaoType.oldYin)
.length;
if (dynamicCount <= 1) {
return '上上签';
}
if (dynamicCount <= 3) {
return '中上签';
}
return '中下签';
}
_MockContent _mockContent(
QuestionType type,
String question,
String signType,
) {
final domain = switch (type) {
QuestionType.career || QuestionType.study => '事业与成长',
QuestionType.love => '关系与情感',
QuestionType.wealth => '财富与资源',
QuestionType.fortune => '阶段运势',
QuestionType.dream => '潜意识信号',
QuestionType.health => '身心节律',
QuestionType.search => '寻物线索',
QuestionType.other => '综合事项',
};
return _MockContent(
keywords: '$signType · $domain',
conclusion: '这个卦象的结果为$signType。你关注的“$question”处于可推进阶段,当前节奏重在稳步而行,不宜急进。',
analysis:
'本卦显示外在条件逐步成形,内在决心也在增强。若短期遇到反复,通常是资源重组与信息修正,并非方向错误。建议将目标拆分为可验证的小节点,持续复盘。',
suggestion:
'建议一:先定三周内可执行动作并按日推进。\n建议二:重要决定留有缓冲期,避免情绪化判断。\n建议三:遇到阻滞先调整节奏,再补关键资源。',
);
}
}
class _MockContent {
const _MockContent({
required this.keywords,
required this.conclusion,
required this.analysis,
required this.suggestion,
});
final String keywords;
final String conclusion;
final String analysis;
final String suggestion;
}
class _HexagramShort {
const _HexagramShort(this.name, this.upper, this.lower);
final String name;
final String upper;
final String lower;
}
const Map<String, _HexagramShort> _hexagramMap = {
'111111': _HexagramShort('乾为天', '', ''),
'011111': _HexagramShort('天风姤', '', ''),
'001111': _HexagramShort('天山遁', '', ''),
'000111': _HexagramShort('天地否', '', ''),
'000011': _HexagramShort('风地观', '', ''),
'000001': _HexagramShort('山地剥', '', ''),
'000101': _HexagramShort('火地晋', '', ''),
'111101': _HexagramShort('火天大有', '', ''),
'010010': _HexagramShort('坎为水', '', ''),
'110010': _HexagramShort('水泽节', '', ''),
'100010': _HexagramShort('水雷屯', '', ''),
'101010': _HexagramShort('水火既济', '', ''),
'101110': _HexagramShort('泽火革', '', ''),
'101100': _HexagramShort('雷火丰', '', ''),
'101000': _HexagramShort('地火明夷', '', ''),
'010000': _HexagramShort('地水师', '', ''),
'001001': _HexagramShort('艮为山', '', ''),
'101001': _HexagramShort('山火贲', '', ''),
'111001': _HexagramShort('山天大畜', '', ''),
'110001': _HexagramShort('山泽损', '', ''),
'110101': _HexagramShort('火泽睽', '', ''),
'110111': _HexagramShort('天泽履', '', ''),
'110011': _HexagramShort('风泽中孚', '', ''),
'001011': _HexagramShort('风山渐', '', ''),
'100100': _HexagramShort('震为雷', '', ''),
'000100': _HexagramShort('雷地豫', '', ''),
'010100': _HexagramShort('雷水解', '', ''),
'011100': _HexagramShort('雷风恒', '', ''),
'011000': _HexagramShort('地风升', '', ''),
'011010': _HexagramShort('水风井', '', ''),
'011110': _HexagramShort('泽风大过', '', ''),
'100110': _HexagramShort('泽雷随', '', ''),
'011011': _HexagramShort('巽为风', '', ''),
'111011': _HexagramShort('风天小畜', '', ''),
'101011': _HexagramShort('风火家人', '', ''),
'100011': _HexagramShort('风雷益', '', ''),
'100111': _HexagramShort('天雷无妄', '', ''),
'100101': _HexagramShort('火雷噬嗑', '', ''),
'100001': _HexagramShort('山雷颐', '', ''),
'011001': _HexagramShort('山风蛊', '', ''),
'101101': _HexagramShort('离为火', '', ''),
'001101': _HexagramShort('火山旅', '', ''),
'011101': _HexagramShort('火风鼎', '', ''),
'010101': _HexagramShort('火水未济', '', ''),
'010001': _HexagramShort('山水蒙', '', ''),
'010011': _HexagramShort('风水涣', '', ''),
'010111': _HexagramShort('天水讼', '', ''),
'101111': _HexagramShort('天火同人', '', ''),
'000000': _HexagramShort('坤为地', '', ''),
'100000': _HexagramShort('地雷复', '', ''),
'110000': _HexagramShort('地泽临', '', ''),
'111000': _HexagramShort('地天泰', '', ''),
'111100': _HexagramShort('雷天大壮', '', ''),
'111110': _HexagramShort('泽天夬', '', ''),
'111010': _HexagramShort('水天需', '', ''),
'000010': _HexagramShort('水地比', '', ''),
'110110': _HexagramShort('兑为泽', '', ''),
'010110': _HexagramShort('泽水困', '', ''),
'000110': _HexagramShort('泽地萃', '', ''),
'001110': _HexagramShort('泽山咸', '', ''),
'001010': _HexagramShort('水山蹇', '', ''),
'001000': _HexagramShort('地山谦', '', ''),
'001100': _HexagramShort('雷山小过', '', ''),
'110100': _HexagramShort('雷泽归妹', '', ''),
};
@@ -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),
),
],
);
},
);
}
}