feat: 实现用户画像、占卜历史与后端用户管理模块

This commit is contained in:
ZL-Q
2026-04-06 01:28:10 +08:00
parent d87b2e1e3a
commit 8a18b3528b
77 changed files with 5850 additions and 2604 deletions
@@ -9,12 +9,17 @@ import 'package:vibration/vibration.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/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 '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/divination_backend_models.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_processing_screen.dart';
@@ -23,10 +28,12 @@ class AutoDivinationScreen extends StatefulWidget {
super.key,
required this.params,
required this.runService,
required this.onCompleted,
});
final DivinationParams params;
final DivinationRunService runService;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
@@ -216,6 +223,55 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
PointsBalanceData points;
try {
points = await widget.runService.getPointsBalance();
} catch (_) {
if (!mounted) {
return;
}
Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error);
return;
}
if (!points.canRun || points.availableBalance < points.runCost) {
if (!mounted) {
return;
}
Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning);
return;
}
if (!mounted) {
return;
}
final shouldStart = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AppModalDialog(
title: l10n.divinationCostDialogTitle,
message: l10n.divinationCostDialogBody(
points.runCost,
points.availableBalance,
),
icon: Icons.auto_awesome_rounded,
actions: [
AppModalDialogAction(
label: l10n.cancel,
onPressed: () => Navigator.of(dialogContext).pop(false),
),
AppModalDialogAction(
label: l10n.divinationCostDialogConfirm,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(true),
),
],
);
},
);
if (shouldStart != true) {
return;
}
setState(() {
_submitting = true;
});
@@ -229,6 +285,7 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _yaoStates,
runService: widget.runService,
onCompleted: widget.onCompleted,
),
),
);
@@ -20,32 +20,57 @@ class DivinationProcessingScreen extends StatefulWidget {
required this.params,
required this.yaoStates,
required this.runService,
required this.onCompleted,
});
final DivinationParams params;
final List<YaoType> yaoStates;
final DivinationRunService runService;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
State<DivinationProcessingScreen> createState() =>
_DivinationProcessingScreenState();
}
class _DivinationProcessingScreenState
extends State<DivinationProcessingScreen> {
class _DivinationProcessingScreenState extends State<DivinationProcessingScreen>
with TickerProviderStateMixin {
static final Logger _logger = getLogger(
'features.divination.processing_screen',
);
static const int _iChingCardCount = 8;
_ProcessingStep _step = _ProcessingStep.preparing;
DivinationResultData? _resultData;
String? _errorMessage;
late final AnimationController _cardRotationController;
int _currentCardIndex = 0;
@override
void initState() {
super.initState();
_cardRotationController =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..addStatusListener((status) {
if (status != AnimationStatus.completed || !mounted) {
return;
}
setState(() {
_currentCardIndex = (_currentCardIndex + 1) % _iChingCardCount;
});
_cardRotationController.forward(from: 0);
});
_cardRotationController.forward();
_startRun();
}
@override
void dispose() {
_cardRotationController.dispose();
super.dispose();
}
Future<void> _startRun() async {
try {
final aggregate = await widget.runService.run(
@@ -75,6 +100,22 @@ class _DivinationProcessingScreenState
_resultData = aggregate.toViewData(widget.params);
_step = _ProcessingStep.done;
});
_cardRotationController.stop();
final data = _resultData;
if (data != null) {
try {
await widget.onCompleted(data);
} catch (error, stackTrace) {
_logger.warning(
message: 'Failed to persist post-run side effects',
extra: <String, dynamic>{'error': error.toString()},
);
_logger.debug(
message: 'Post-run side effect stack trace',
extra: <String, dynamic>{'stackTrace': stackTrace.toString()},
);
}
}
} catch (error, stackTrace) {
_logger.error(
message: 'Divination processing failed while waiting result events',
@@ -117,11 +158,12 @@ class _DivinationProcessingScreenState
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final text = switch (_step) {
final statusText = switch (_step) {
_ProcessingStep.preparing => l10n.transitionPreparing,
_ProcessingStep.deriving => l10n.transitionDeriving,
_ProcessingStep.done => l10n.transitionDone,
};
final cardDataList = _iChingCardData(l10n);
final canContinue = _step == _ProcessingStep.done && _resultData != null;
@@ -134,39 +176,123 @@ class _DivinationProcessingScreenState
child: _errorMessage == null
? GestureDetector(
onTap: canContinue ? _openResult : null,
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: AnimatedBuilder(
animation: _cardRotationController,
builder: (context, _) {
final angle = canContinue
? 0.0
: _rotationForProgress(
_cardRotationController.value,
);
final card = cardDataList[_currentCardIndex];
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.0011)
..rotateY(angle),
child: Container(
width: 220,
height: 320,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors.primaryContainer.withValues(
alpha: 0.55,
),
colors.secondaryContainer.withValues(
alpha: 0.38,
),
colors.surface,
],
),
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: colors.primary.withValues(alpha: 0.3),
),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.18),
blurRadius: 26,
offset: const Offset(0, 14),
),
],
),
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (canContinue)
Icon(
Icons.visibility,
color: colors.primary,
size: 34,
)
else ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: colors.surface.withValues(
alpha: 0.75,
),
borderRadius: BorderRadius.circular(
AppRadius.full,
),
),
child: Text(
'I Ching',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: colors.primary,
letterSpacing: 0.3,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: AppSpacing.md),
Text(
card.$1,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.md),
Text(
card.$2,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
height: 1.5,
color: colors.onSurface.withValues(
alpha: 0.86,
),
),
),
],
const SizedBox(height: AppSpacing.lg),
Text(
statusText,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleMedium,
),
],
),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
canContinue ? Icons.visibility : Icons.auto_awesome,
color: colors.primary,
size: 34,
),
const SizedBox(height: AppSpacing.md),
Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
},
),
)
: Text(
@@ -181,4 +307,27 @@ class _DivinationProcessingScreenState
),
);
}
double _rotationForProgress(double progress) {
if (progress < 0.25) {
return (1 - progress / 0.25) * (3.1415926 / 2);
}
if (progress < 0.75) {
return 0;
}
return ((progress - 0.75) / 0.25) * (3.1415926 / 2);
}
List<(String, String)> _iChingCardData(AppLocalizations l10n) {
return <(String, String)>[
(l10n.processingCardQianTitle, l10n.processingCardQianQuote),
(l10n.processingCardDuiTitle, l10n.processingCardDuiQuote),
(l10n.processingCardLiTitle, l10n.processingCardLiQuote),
(l10n.processingCardZhenTitle, l10n.processingCardZhenQuote),
(l10n.processingCardXunTitle, l10n.processingCardXunQuote),
(l10n.processingCardKanTitle, l10n.processingCardKanQuote),
(l10n.processingCardGenTitle, l10n.processingCardGenQuote),
(l10n.processingCardKunTitle, l10n.processingCardKunQuote),
];
}
}
@@ -27,22 +27,73 @@ class DivinationResultScreen extends StatefulWidget {
class _DivinationResultScreenState extends State<DivinationResultScreen> {
bool _showIntro = true;
bool _introCollapsed = false;
Rect? _introTargetRect;
final GlobalKey _stackKey = GlobalKey();
final GlobalKey _finalSignCardKey = GlobalKey();
void _backToHome() {
final navigator = Navigator.of(context);
navigator.popUntil((route) => route.isFirst);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_prepareIntro();
});
}
Future<void> _prepareIntro() async {
for (int i = 0; i < 12; i++) {
if (!mounted) {
return;
}
if (_measureIntroTargetRect()) {
break;
}
await Future<void>.delayed(const Duration(milliseconds: 16));
}
if (!mounted) {
return;
}
_playIntro();
}
bool _measureIntroTargetRect() {
final stackContext = _stackKey.currentContext;
final targetContext = _finalSignCardKey.currentContext;
if (stackContext == null || targetContext == null) {
return false;
}
final stackRender = stackContext.findRenderObject();
final targetRender = targetContext.findRenderObject();
if (stackRender is! RenderBox || targetRender is! RenderBox) {
return false;
}
final offset = targetRender.localToGlobal(
Offset.zero,
ancestor: stackRender,
);
final targetRect = offset & targetRender.size;
if (_introTargetRect == targetRect) {
return true;
}
setState(() {
_introTargetRect = targetRect;
});
return true;
}
Future<void> _playIntro() async {
await Future<void>.delayed(const Duration(milliseconds: 120));
await Future<void>.delayed(const Duration(milliseconds: 180));
if (!mounted) {
return;
}
setState(() {
_introCollapsed = true;
});
await Future<void>.delayed(const Duration(milliseconds: 760));
await Future<void>.delayed(const Duration(milliseconds: 1450));
if (!mounted) {
return;
}
@@ -51,121 +102,179 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
});
}
Rect _introStartRect(Size size) {
const startWidth = 332.0;
const startHeight = 234.0;
return Rect.fromLTWH(
(size.width - startWidth) / 2,
(size.height - startHeight) / 2,
startWidth,
startHeight,
);
}
@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(
return PopScope<void>(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
return;
}
_backToHome();
},
child: Scaffold(
backgroundColor: colors.surface,
surfaceTintColor: colors.surface,
title: Text(l10n.resultScreenTitle),
centerTitle: true,
),
body: Stack(
children: [
AnimatedOpacity(
opacity: _showIntro ? 0 : 1,
duration: const Duration(milliseconds: 260),
child: 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),
appBar: AppBar(
leading: IconButton(
onPressed: _backToHome,
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
backgroundColor: colors.surface,
surfaceTintColor: colors.surface,
title: Text(l10n.resultScreenTitle),
centerTitle: true,
),
body: LayoutBuilder(
builder: (context, constraints) {
final stackSize = Size(constraints.maxWidth, constraints.maxHeight);
final startRect = _introStartRect(stackSize);
final targetRect = _introTargetRect ?? startRect;
final currentRect = _introCollapsed ? targetRect : startRect;
return Stack(
key: _stackKey,
children: [
AnimatedOpacity(
opacity: _showIntro ? 0 : 1,
duration: const Duration(milliseconds: 260),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.xl,
),
child: Row(
child: Column(
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,
_ResultHeader(data: widget.data),
const SizedBox(height: AppSpacing.md),
_SignCard(
key: _finalSignCardKey,
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),
],
),
),
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 (_showIntro)
Positioned.fill(
child: Material(
color: colors.surface,
child: SafeArea(
child: AnimatedAlign(
duration: const Duration(milliseconds: 760),
curve: Curves.easeInOutCubic,
alignment: _introCollapsed
? const Alignment(0, -0.86)
: Alignment.center,
child: AnimatedContainer(
duration: const Duration(milliseconds: 760),
curve: Curves.easeInOutCubic,
width: _introCollapsed ? 150 : 290,
child: _SignCard(signType: widget.data.signType),
),
if (_showIntro)
Positioned.fill(
child: IgnorePointer(
child: ColoredBox(color: colors.surface),
),
),
),
),
),
],
if (_showIntro)
AnimatedPositioned(
duration: const Duration(milliseconds: 1450),
curve: Curves.easeInOutCubicEmphasized,
left: currentRect.left,
top: currentRect.top,
width: currentRect.width,
height: currentRect.height,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
image: DecorationImage(
image: AssetImage(
_signImageAssetForType(
context,
widget.data.signType,
),
),
fit: BoxFit.cover,
),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.24),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
),
),
),
],
);
},
),
),
);
}
@@ -217,18 +326,16 @@ class _ResultHeader extends StatelessWidget {
}
class _SignCard extends StatelessWidget {
const _SignCard({required this.signType});
const _SignCard({super.key, 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',
};
final l10n = AppLocalizations.of(context)!;
final image = _signImageAssetForType(context, signType);
final localizedSignType = _localizedSignTypeLabel(l10n, signType);
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
@@ -248,7 +355,7 @@ class _SignCard extends StatelessWidget {
),
const SizedBox(height: AppSpacing.sm),
Text(
signType,
localizedSignType,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
@@ -261,6 +368,35 @@ class _SignCard extends StatelessWidget {
}
}
String _localizedSignTypeLabel(AppLocalizations l10n, String signType) {
final normalized = signType.trim();
if (normalized.contains('上上')) {
return l10n.signTypeShangShang;
}
if (normalized.contains('中上')) {
return l10n.signTypeZhongShang;
}
if (normalized.contains('下下')) {
return l10n.signTypeXiaXia;
}
return l10n.signTypeZhongXia;
}
String _signImageAssetForType(BuildContext context, String signType) {
final l10n = AppLocalizations.of(context)!;
final normalized = _localizedSignTypeLabel(l10n, signType);
if (normalized == l10n.signTypeShangShang) {
return 'assets/images/qigua/shangshang.jpg';
}
if (normalized == l10n.signTypeZhongShang) {
return 'assets/images/qigua/zhongshang.jpg';
}
if (normalized == l10n.signTypeXiaXia) {
return 'assets/images/qigua/xiaxia.jpg';
}
return 'assets/images/qigua/zhongxia.jpg';
}
class _KeywordCard extends StatelessWidget {
const _KeywordCard({required this.keywords});
@@ -299,6 +435,7 @@ class _AnalysisCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
@@ -323,9 +460,13 @@ class _AnalysisCard extends StatelessWidget {
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: content));
Toast.show(context, '$title已复制', type: ToastType.success);
Toast.show(
context,
l10n.toastContentCopiedWithTitle(title),
type: ToastType.success,
);
},
child: const Text('复制'),
child: Text(l10n.resultCopy),
),
],
),
@@ -351,6 +492,7 @@ class _InfoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
@@ -360,32 +502,41 @@ class _InfoCard extends StatelessWidget {
),
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,
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.resultDivinationInfo,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: AppSpacing.md),
_kv(
context,
'起卦时间',
DateFormat.yMd(
Localizations.localeOf(context).toString(),
).add_Hm().format(data.params.divinationTime),
),
_kv(
context,
'起卦方式',
data.params.method == DivinationMethod.auto ? '自动起卦' : '手动起卦',
),
_kv(context, '问题类型', _typeLabel(data.params.questionType)),
_kv(context, '占卜问题', data.params.question),
],
const SizedBox(height: AppSpacing.md),
_kv(
context,
l10n.resultDivinationTime,
DateFormat.yMd(
Localizations.localeOf(context).toString(),
).add_Hm().format(data.params.divinationTime),
),
_kv(
context,
l10n.resultDivinationMethod,
data.params.method == DivinationMethod.auto
? l10n.resultAutoMethod
: l10n.resultManualMethod,
),
_kv(
context,
l10n.resultQuestionType,
_typeLabel(context, data.params.questionType),
),
_kv(context, l10n.resultQuestion, data.params.question),
],
),
),
),
);
@@ -419,17 +570,18 @@ class _InfoCard extends StatelessWidget {
);
}
String _typeLabel(QuestionType type) {
String _typeLabel(BuildContext context, QuestionType type) {
final l10n = AppLocalizations.of(context)!;
return switch (type) {
QuestionType.career => '事业',
QuestionType.love => '情感',
QuestionType.wealth => '财富',
QuestionType.fortune => '运势',
QuestionType.dream => '解梦',
QuestionType.health => '健康',
QuestionType.study => '学业',
QuestionType.search => '寻物',
QuestionType.other => '其他',
QuestionType.career => l10n.questionTypeCareer,
QuestionType.love => l10n.questionTypeLove,
QuestionType.wealth => l10n.questionTypeWealth,
QuestionType.fortune => l10n.questionTypeFortune,
QuestionType.dream => l10n.questionTypeDream,
QuestionType.health => l10n.questionTypeHealth,
QuestionType.study => l10n.questionTypeStudy,
QuestionType.search => l10n.questionTypeSearch,
QuestionType.other => l10n.questionTypeOther,
};
}
}
@@ -442,6 +594,7 @@ class _HexagramDetailCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Card(
@@ -457,7 +610,7 @@ class _HexagramDetailCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'干支信息',
l10n.ganZhiInfo,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
@@ -467,26 +620,58 @@ class _HexagramDetailCard extends StatelessWidget {
Row(
children: [
Expanded(
child: _miniKV(context, '月建', data.ganzhi.yueJian),
child: _miniKV(
context,
DivinationTerms.yueJian,
data.ganzhi.yueJian,
),
),
Expanded(
child: _miniKV(
context,
DivinationTerms.riChen,
data.ganzhi.riChen,
),
),
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),
child: _miniKV(
context,
DivinationTerms.yuePo,
data.ganzhi.yuePo,
),
),
Expanded(
child: _miniKV(
context,
DivinationTerms.riChong,
data.ganzhi.riChong,
),
),
],
),
const SizedBox(height: AppSpacing.md),
Text('五行旺衰', style: Theme.of(context).textTheme.bodyMedium),
Text(
l10n.wuXingWangShuai,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.sm),
_WuXingTable(data: data),
const SizedBox(height: AppSpacing.md),
Text('干支空亡', style: Theme.of(context).textTheme.bodyMedium),
Text(
l10n.ganZhiKongWang,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.sm),
_KongWangTable(data: data),
],
@@ -626,12 +811,65 @@ class _KongWangTable extends StatelessWidget {
@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),
final l10n = AppLocalizations.of(context)!;
final header = <String>[
l10n.resultPillarColumn,
l10n.resultYearPillar,
l10n.resultMonthPillar,
l10n.resultDayPillar,
l10n.resultTimePillar,
];
final rows = <List<String>>[
<String>[
l10n.resultGanZhiLabel,
data.ganzhi.yearGanZhi,
data.ganzhi.monthGanZhi,
data.ganzhi.dayGanZhi,
data.ganzhi.timeGanZhi,
],
<String>[
l10n.resultKongWangLabel,
data.ganzhi.yearKongWang,
data.ganzhi.monthKongWang,
data.ganzhi.dayKongWang,
data.ganzhi.timeKongWang,
],
];
Widget buildCell(
String text, {
bool isHeader = false,
bool isLast = false,
bool isFirst = false,
}) {
return Expanded(
flex: isFirst ? 2 : 3,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: isHeader ? colors.surfaceContainerHigh : colors.surface,
border: Border(
right: isLast
? BorderSide.none
: BorderSide(color: colors.outline),
),
),
child: Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: isHeader || isFirst
? FontWeight.w700
: FontWeight.w500,
),
),
),
);
}
return Container(
decoration: BoxDecoration(
border: Border.all(color: colors.outline),
@@ -639,20 +877,30 @@ class _KongWangTable extends StatelessWidget {
),
child: Column(
children: [
for (final row in rows)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
Row(
children: [
for (int i = 0; i < header.length; i++)
buildCell(
header[i],
isHeader: true,
isFirst: i == 0,
isLast: i == header.length - 1,
),
],
),
for (int r = 0; r < rows.length; r++)
Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: colors.outline)),
),
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),
),
for (int c = 0; c < rows[r].length; c++)
buildCell(
rows[r][c],
isFirst: c == 0,
isLast: c == rows[r].length - 1,
),
],
),
),
@@ -5,11 +5,13 @@ import '../../../../core/auth/session_store.dart';
import '../../../../data/network/api_client.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/apis/divination_api.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
import 'auto_divination_screen.dart';
import 'manual_divination_screen.dart';
@@ -19,11 +21,13 @@ class DivinationScreen extends StatefulWidget {
super.key,
required this.sessionStore,
required this.userId,
required this.onCompleted,
this.runServiceOverride,
});
final SessionStore sessionStore;
final String userId;
final Future<void> Function(DivinationResultData result) onCompleted;
final DivinationRunService? runServiceOverride;
@override
@@ -157,6 +161,7 @@ class _DivinationScreenState extends State<DivinationScreen> {
builder: (_) => ManualDivinationScreen(
params: nextParams,
runService: _runService,
onCompleted: widget.onCompleted,
),
),
);
@@ -166,8 +171,11 @@ class _DivinationScreenState extends State<DivinationScreen> {
final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
AutoDivinationScreen(params: nextParams, runService: _runService),
builder: (_) => AutoDivinationScreen(
params: nextParams,
runService: _runService,
onCompleted: widget.onCompleted,
),
),
);
}
@@ -372,16 +380,17 @@ class _StartButton extends StatelessWidget {
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}',
),
builder: (dialogContext) {
return AppModalDialog(
title: l10n.divinationMethodTipTitle,
message:
'${l10n.divinationMethodTipAuto}\n\n${l10n.divinationMethodTipManual}\n\n${l10n.divinationMethodTipRecommend}',
icon: Icons.lightbulb_outline_rounded,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.divinationIAcknowledge),
AppModalDialogAction(
label: l10n.divinationIAcknowledge,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
);
@@ -4,12 +4,17 @@ import 'package:intl/intl.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.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 '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/divination_backend_models.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_processing_screen.dart';
@@ -18,10 +23,12 @@ class ManualDivinationScreen extends StatefulWidget {
super.key,
required this.params,
required this.runService,
required this.onCompleted,
});
final DivinationParams params;
final DivinationRunService runService;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
State<ManualDivinationScreen> createState() => _ManualDivinationScreenState();
@@ -155,14 +162,16 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
final l10n = AppLocalizations.of(context)!;
await showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.manualYaoTipTitle),
content: Text(l10n.manualYaoTipContent),
builder: (dialogContext) {
return AppModalDialog(
title: l10n.manualYaoTipTitle,
message: l10n.manualYaoTipContent,
icon: Icons.info_outline_rounded,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.divinationIAcknowledge),
AppModalDialogAction(
label: l10n.divinationIAcknowledge,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
);
@@ -171,6 +180,55 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
PointsBalanceData points;
try {
points = await widget.runService.getPointsBalance();
} catch (_) {
if (!mounted) {
return;
}
Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error);
return;
}
if (!points.canRun || points.availableBalance < points.runCost) {
if (!mounted) {
return;
}
Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning);
return;
}
if (!mounted) {
return;
}
final shouldStart = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AppModalDialog(
title: l10n.divinationCostDialogTitle,
message: l10n.divinationCostDialogBody(
points.runCost,
points.availableBalance,
),
icon: Icons.auto_awesome_rounded,
actions: [
AppModalDialogAction(
label: l10n.cancel,
onPressed: () => Navigator.of(dialogContext).pop(false),
),
AppModalDialogAction(
label: l10n.divinationCostDialogConfirm,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(true),
),
],
);
},
);
if (shouldStart != true) {
return;
}
setState(() {
_submitting = true;
});
@@ -184,6 +242,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _selectedYaos.cast<YaoType>(),
runService: widget.runService,
onCompleted: widget.onCompleted,
),
),
);