feat(divination): 重构手动起卦教程,支持三硬币交互选择

This commit is contained in:
qzl
2026-04-07 18:41:08 +08:00
parent f904286ba7
commit f394df9362
29 changed files with 873 additions and 326 deletions
@@ -300,6 +300,9 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
}
Future<void> _vibrateStrong() async {
if (!widget.params.allowVibration) {
return;
}
final hasVibrator = await Vibration.hasVibrator();
if (hasVibrator == true) {
await Vibration.vibrate(duration: 280, amplitude: 255);
@@ -313,15 +316,17 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
context: context,
builder: (context) {
return DivinationGuideDialog(
title: l10n.autoGuideTitle,
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',
['assets/images/tutorial/tutorial_1.png'],
['assets/images/tutorial/tutorial_2.png'],
['assets/images/tutorial/tutorial_3.png'],
],
instructions: [
l10n.divinationManualGuideStep1,
l10n.divinationManualGuideStep2,
l10n.divinationManualGuideStep3,
],
instructionText: l10n.autoGuideInstruction,
);
},
);
@@ -527,7 +532,7 @@ class _CoinColumn extends StatelessWidget {
),
const SizedBox(height: AppSpacing.sm),
Text(
DivinationTerms.yinYang[isYang] ?? '',
DivinationTerms.ziHua[isYang] ?? '',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w700,
@@ -562,8 +567,8 @@ class _CoinFace extends StatelessWidget {
: (isYang ? 0 : 180);
final showingYang = isSpinning ? rotationY < 90 : isYang;
final image = showingYang
? 'assets/images/qigua/yangmian.jpg'
: 'assets/images/qigua/yinmian.jpg';
? 'assets/images/qigua/hua.jpg'
: 'assets/images/qigua/zi.jpg';
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
@@ -244,7 +244,7 @@ class _DivinationProcessingScreenState extends State<DivinationProcessingScreen>
),
),
child: Text(
'I Ching',
l10n.iChingTitle,
style: Theme.of(context)
.textTheme
.labelSmall
@@ -175,6 +175,8 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
content: widget.data.conclusion,
),
const SizedBox(height: AppSpacing.md),
_FocusPointsCard(points: widget.data.focusPoints),
const SizedBox(height: AppSpacing.md),
_AnalysisCard(
title: l10n.resultAnalysis,
content: widget.data.analysis,
@@ -426,6 +428,71 @@ class _KeywordCard extends StatelessWidget {
}
}
class _FocusPointsCard extends StatelessWidget {
const _FocusPointsCard({required this.points});
final List<String> points;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final languageCode = Localizations.localeOf(context).languageCode;
final title = languageCode == 'en' ? 'Focus Points' : '断卦要点';
if (points.isEmpty) {
return const SizedBox.shrink();
}
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(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.sm),
...List<Widget>.generate(points.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${index + 1}. ',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
Expanded(
child: Text(
points[index],
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(height: 1.55),
),
),
],
),
);
}),
],
),
),
);
}
}
class _AnalysisCard extends StatelessWidget {
const _AnalysisCard({required this.title, required this.content});
@@ -715,8 +782,11 @@ class _HexagramDetailCard extends StatelessWidget {
for (int idx = 5; idx >= 0; idx--)
_YaoDetailRow(
line: data.yaoLines[idx],
target: data.targetYaoLines[idx],
showTarget: data.hasChangingYao,
target: idx < data.targetYaoLines.length
? data.targetYaoLines[idx]
: data.yaoLines[idx],
showTarget:
data.hasChangingYao && idx < data.targetYaoLines.length,
),
const SizedBox(height: AppSpacing.sm),
const Align(
@@ -24,12 +24,14 @@ class DivinationScreen extends StatefulWidget {
required this.userId,
required this.onCompleted,
this.runServiceOverride,
this.allowVibration = true,
});
final SessionStore sessionStore;
final String userId;
final Future<void> Function(DivinationResultData result) onCompleted;
final DivinationRunService? runServiceOverride;
final bool allowVibration;
@override
State<DivinationScreen> createState() => _DivinationScreenState();
@@ -51,7 +53,7 @@ class _DivinationScreenState extends State<DivinationScreen> {
widget.runServiceOverride ??
DivinationRunService(api: DivinationApi(apiClient: apiClient));
_params = DivinationParams(
method: DivinationMethod.manual,
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '',
divinationTime: DateTime.now(),
@@ -169,7 +171,10 @@ class _DivinationScreenState extends State<DivinationScreen> {
return;
}
final nextParams = _params.copyWith(divinationTime: DateTime.now());
final nextParams = _params.copyWith(
divinationTime: DateTime.now(),
allowVibration: widget.allowVibration,
);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AutoDivinationScreen(
@@ -406,13 +411,15 @@ Future<void> _showGuide(BuildContext context, AppLocalizations l10n) {
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',
['assets/images/tutorial/tutorial_1.png'],
['assets/images/tutorial/tutorial_2.png'],
['assets/images/tutorial/tutorial_3.png'],
],
instructions: [
l10n.divinationManualGuideStep1,
l10n.divinationManualGuideStep2,
l10n.divinationManualGuideStep3,
],
instructionText: l10n.divinationManualGuideInstruction,
);
},
);
@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
@@ -130,8 +132,16 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
builder: (_) {
return DivinationGuideDialog(
title: l10n.manualSelectYaoTitle,
guideImages: const ['lc2.jpg', 'lc3.jpg', 'lc4.jpg', 'lc5.jpg'],
instructionText: l10n.manualYaoTipContent,
guideImages: const [
['assets/images/tutorial/tutorial_1.png'],
['assets/images/tutorial/tutorial_2.png'],
['assets/images/tutorial/tutorial_3.png'],
],
instructions: [
l10n.divinationManualGuideStep1,
l10n.divinationManualGuideStep2,
l10n.divinationManualGuideStep3,
],
);
},
);
@@ -409,208 +419,199 @@ class _YaoSelectionCard extends StatelessWidget {
int yaoIndex,
void Function(int, YaoType) onSelect,
) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final options = <(String, YaoType, String)>[
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]}${DivinationTerms.youngYangSymbol}',
YaoType.youngYang,
'字字字',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]}${DivinationTerms.youngYinSymbol}',
YaoType.youngYin,
'花花花',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]}${DivinationTerms.oldYangSymbol}',
YaoType.oldYang,
'字字字',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]}${DivinationTerms.oldYinSymbol}',
YaoType.oldYin,
'花花花',
),
];
return showDialog<void>(
context: context,
builder: (context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.manualSelectYaoTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: AppSpacing.md),
...options.map((option) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: _YaoOptionCard(
label: option.$1,
pattern: option.$3,
isSelected: false,
onTap: () {
onSelect(yaoIndex, option.$2);
Navigator.of(context).pop();
},
),
);
}),
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.coinFaceGuideTitle,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.sm),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Image.asset(
'assets/images/qigua/zihua.jpg',
width: double.infinity,
height: 120,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) =>
Container(
height: 120,
color: colors.errorContainer,
child: Center(
child: Text(
l10n.divinationClose,
style: TextStyle(
color: colors.onErrorContainer,
),
),
),
),
),
),
const SizedBox(height: AppSpacing.xs),
Text(
l10n.coinFaceGuideDescription,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colors.onSurfaceVariant),
),
],
),
),
],
),
),
),
return _ThreeCoinSelectorDialog(
onConfirm: (yaoType) {
onSelect(yaoIndex, yaoType);
Navigator.of(context).pop();
},
);
},
);
}
}
class _YaoOptionCard extends StatelessWidget {
const _YaoOptionCard({
required this.label,
required this.pattern,
required this.isSelected,
required this.onTap,
});
class _ThreeCoinSelectorDialog extends StatefulWidget {
const _ThreeCoinSelectorDialog({required this.onConfirm});
final String label;
final String pattern;
final bool isSelected;
final VoidCallback onTap;
final void Function(YaoType) onConfirm;
@override
State<_ThreeCoinSelectorDialog> createState() =>
_ThreeCoinSelectorDialogState();
}
class _ThreeCoinSelectorDialogState extends State<_ThreeCoinSelectorDialog> {
final List<bool> _coinStates = [false, false, false];
YaoType get _currentYaoType {
final huaCount = _coinStates.where((isHua) => isHua).length;
return switch (huaCount) {
0 => YaoType.oldYin,
1 => YaoType.youngYang,
2 => YaoType.youngYin,
3 => YaoType.oldYang,
_ => YaoType.undetermined,
};
}
void _toggleCoin(int index) {
setState(() {
_coinStates[index] = !_coinStates[index];
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Material(
color: isSelected ? colors.primaryContainer : colors.surface,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm + 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isSelected ? colors.primary : colors.outlineVariant,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: isSelected
? colors.onPrimaryContainer
: colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
pattern,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isSelected
? colors.onPrimaryContainer.withValues(alpha: 0.7)
: colors.onSurfaceVariant,
),
),
],
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.manualSelectYaoTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: AppSpacing.lg),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(3, (index) {
return _FlippingCoin(
isHua: _coinStates[index],
onTap: () => _toggleCoin(index),
);
}),
),
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.md),
),
Icon(
Icons.chevron_right,
color: isSelected
? colors.onPrimaryContainer
: colors.onSurfaceVariant,
child: Text(
l10n.manualCoinSelectHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => widget.onConfirm(_currentYaoType),
child: Text(l10n.confirm),
),
),
],
),
),
);
}
}
class _FlippingCoin extends StatefulWidget {
const _FlippingCoin({required this.isHua, required this.onTap});
final bool isHua;
final VoidCallback onTap;
@override
State<_FlippingCoin> createState() => _FlippingCoinState();
}
class _FlippingCoinState extends State<_FlippingCoin>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
bool _showHua = false;
@override
void initState() {
super.initState();
_showHua = widget.isHua;
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_animation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
}
@override
void didUpdateWidget(_FlippingCoin oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isHua != widget.isHua) {
if (widget.isHua != _showHua) {
_controller.forward().then((_) {
setState(() {
_showHua = widget.isHua;
});
_controller.reverse();
});
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
final angle = _animation.value * pi;
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: ClipOval(
child: Image.asset(
_showHua
? 'assets/images/qigua/hua.jpg'
: 'assets/images/qigua/zi.jpg',
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
);
},
),
);
}
}