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
@@ -8,6 +8,7 @@ import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../models/divination_backend_models.dart';
import '../models/divination_params.dart';
import '../models/divination_result.dart';
class DivinationApi {
const DivinationApi({required ApiClient apiClient}) : _apiClient = apiClient;
@@ -37,6 +38,67 @@ class DivinationApi {
return RunAcceptedData.fromJson(json);
}
Future<List<DivinationResultData>> getHistoryRecords({
required String userId,
}) async {
final json = await _apiClient.getJson('/api/v1/agent/history');
final messagesRaw = json['messages'];
if (messagesRaw is! List<dynamic>) {
return const <DivinationResultData>[];
}
final records = <DivinationResultData>[];
for (final raw in messagesRaw) {
if (raw is! Map<String, dynamic>) {
continue;
}
if (raw['role'] != 'assistant') {
continue;
}
final agentOutputRaw = raw['agent_output'];
if (agentOutputRaw is! Map<String, dynamic>) {
continue;
}
final derivedRaw = agentOutputRaw['divination_derived'];
if (derivedRaw is! Map<String, dynamic>) {
continue;
}
try {
final derived = DerivedDivinationData.fromJson(derivedRaw);
final divinationTime = _resolveHistoryTime(raw, derived);
final params = DivinationParams(
method: _methodFromText(derived.divinationMethod),
questionType: _questionTypeFromText(derived.questionType),
question: derived.question,
divinationTime: divinationTime,
coinBalance: 0,
userId: userId,
);
final aggregate = DivinationRunAggregate(
derived: derived,
signLevel: _asString(agentOutputRaw['sign_level']),
summary: _asString(agentOutputRaw['summary']),
conclusion: _asStringList(agentOutputRaw['conclusion']),
focusPoints: _asStringList(agentOutputRaw['focus_points']),
advice: _asStringList(agentOutputRaw['advice']),
keywords: _asStringList(agentOutputRaw['keywords']),
answer: _asString(agentOutputRaw['answer']),
);
records.add(aggregate.toViewData(params));
} catch (error, stackTrace) {
_logger.warning(
message: 'Skip malformed history assistant message',
extra: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
continue;
}
}
return records;
}
Stream<Map<String, dynamic>> streamEvents({
required String threadId,
required String runId,
@@ -217,6 +279,55 @@ String _questionTypeToText(QuestionType type) {
};
}
QuestionType _questionTypeFromText(String raw) {
return switch (raw) {
'事业' => QuestionType.career,
'情感' => QuestionType.love,
'财富' => QuestionType.wealth,
'运势' => QuestionType.fortune,
'解梦' => QuestionType.dream,
'健康' => QuestionType.health,
'学业' => QuestionType.study,
'寻物' => QuestionType.search,
_ => QuestionType.other,
};
}
DivinationMethod _methodFromText(String raw) {
return raw == '自动起卦' ? DivinationMethod.auto : DivinationMethod.manual;
}
DateTime _resolveHistoryTime(
Map<String, dynamic> message,
DerivedDivinationData derived,
) {
final timestamp = message['timestamp'];
if (timestamp is String) {
final parsed = DateTime.tryParse(timestamp);
if (parsed != null) {
return parsed.toLocal();
}
}
final derivedTime = DateTime.tryParse(derived.divinationTime);
if (derivedTime != null) {
return derivedTime.toLocal();
}
return DateTime.now();
}
String _asString(Object? value) {
return value is String ? value : '';
}
List<String> _asStringList(Object? value) {
if (value is! List<dynamic>) {
return const <String>[];
}
return value.whereType<String>().toList(growable: false);
}
String _yaoTypeToText(YaoType type) {
return switch (type) {
YaoType.youngYang => '少阳',
@@ -60,6 +60,21 @@ class DivinationParams {
};
}
factory DivinationParams.fromPayload(Map<String, dynamic> payload) {
return DivinationParams(
method: divinationMethodFromName(_requiredString(payload, 'method')),
questionType: questionTypeFromName(
_requiredString(payload, 'questionType'),
),
question: _requiredString(payload, 'question'),
divinationTime: DateTime.parse(
_requiredString(payload, 'divinationTime'),
),
coinBalance: _requiredInt(payload, 'coinBalance'),
userId: _requiredString(payload, 'userId'),
);
}
String toBinary(List<YaoType> yaoStates) {
return yaoStates
.map(
@@ -85,3 +100,43 @@ class DivinationParams {
.join();
}
}
DivinationMethod divinationMethodFromName(String raw) {
return DivinationMethod.values.firstWhere(
(value) => value.name == raw,
orElse: () => DivinationMethod.manual,
);
}
QuestionType questionTypeFromName(String raw) {
return QuestionType.values.firstWhere(
(value) => value.name == raw,
orElse: () => QuestionType.other,
);
}
YaoType yaoTypeFromName(String raw) {
return YaoType.values.firstWhere(
(value) => value.name == raw,
orElse: () => YaoType.undetermined,
);
}
String _requiredString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! String || value.isEmpty) {
throw FormatException('Missing required string: $key');
}
return value;
}
int _requiredInt(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
throw FormatException('Missing required int: $key');
}
@@ -38,6 +38,79 @@ class DivinationResultData {
final List<YaoLineData> targetYaoLines;
bool get hasChangingYao => binaryCode != changedBinaryCode;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'params': params.toPayload(),
'binaryCode': binaryCode,
'changedBinaryCode': changedBinaryCode,
'guaName': guaName,
'targetGuaName': targetGuaName,
'upperName': upperName,
'lowerName': lowerName,
'signType': signType,
'keywords': keywords,
'conclusion': conclusion,
'analysis': analysis,
'suggestion': suggestion,
'ganzhi': ganzhi.toJson(),
'wuXingStatus': wuXingStatus,
'yaoLines': yaoLines.map((line) => line.toJson()).toList(growable: false),
'targetYaoLines': targetYaoLines
.map((line) => line.toJson())
.toList(growable: false),
};
}
factory DivinationResultData.fromJson(Map<String, dynamic> json) {
final paramsRaw = json['params'];
final ganzhiRaw = json['ganzhi'];
final wuXingRaw = json['wuXingStatus'];
final yaoLinesRaw = json['yaoLines'];
final targetYaoLinesRaw = json['targetYaoLines'];
if (paramsRaw is! Map<String, dynamic> ||
ganzhiRaw is! Map<String, dynamic> ||
wuXingRaw is! Map<String, dynamic> ||
yaoLinesRaw is! List<dynamic> ||
targetYaoLinesRaw is! List<dynamic>) {
throw const FormatException('Invalid divination result payload');
}
return DivinationResultData(
params: DivinationParams.fromPayload(paramsRaw),
binaryCode: _requiredString(json, 'binaryCode'),
changedBinaryCode: _requiredString(json, 'changedBinaryCode'),
guaName: _requiredString(json, 'guaName'),
targetGuaName: _requiredString(json, 'targetGuaName'),
upperName: _requiredString(json, 'upperName'),
lowerName: _requiredString(json, 'lowerName'),
signType: _requiredString(json, 'signType'),
keywords: _requiredString(json, 'keywords'),
conclusion: _requiredString(json, 'conclusion'),
analysis: _requiredString(json, 'analysis'),
suggestion: _requiredString(json, 'suggestion'),
ganzhi: GanzhiData.fromJson(ganzhiRaw),
wuXingStatus: wuXingRaw.map(
(key, value) => MapEntry(key, value.toString()),
),
yaoLines: yaoLinesRaw
.map((raw) {
if (raw is! Map<String, dynamic>) {
throw const FormatException('Invalid yao line payload');
}
return YaoLineData.fromJson(raw);
})
.toList(growable: false),
targetYaoLines: targetYaoLinesRaw
.map((raw) {
if (raw is! Map<String, dynamic>) {
throw const FormatException('Invalid target yao line payload');
}
return YaoLineData.fromJson(raw);
})
.toList(growable: false),
);
}
}
class GanzhiData {
@@ -68,6 +141,40 @@ class GanzhiData {
final String riChen;
final String yuePo;
final String riChong;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'yearGanZhi': yearGanZhi,
'monthGanZhi': monthGanZhi,
'dayGanZhi': dayGanZhi,
'timeGanZhi': timeGanZhi,
'yearKongWang': yearKongWang,
'monthKongWang': monthKongWang,
'dayKongWang': dayKongWang,
'timeKongWang': timeKongWang,
'yueJian': yueJian,
'riChen': riChen,
'yuePo': yuePo,
'riChong': riChong,
};
}
factory GanzhiData.fromJson(Map<String, dynamic> json) {
return GanzhiData(
yearGanZhi: _requiredString(json, 'yearGanZhi'),
monthGanZhi: _requiredString(json, 'monthGanZhi'),
dayGanZhi: _requiredString(json, 'dayGanZhi'),
timeGanZhi: _requiredString(json, 'timeGanZhi'),
yearKongWang: _requiredString(json, 'yearKongWang'),
monthKongWang: _requiredString(json, 'monthKongWang'),
dayKongWang: _requiredString(json, 'dayKongWang'),
timeKongWang: _requiredString(json, 'timeKongWang'),
yueJian: _requiredString(json, 'yueJian'),
riChen: _requiredString(json, 'riChen'),
yuePo: _requiredString(json, 'yuePo'),
riChong: _requiredString(json, 'riChong'),
);
}
}
class YaoLineData {
@@ -88,4 +195,47 @@ class YaoLineData {
final String element;
final YaoType type;
final String mark;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'index': index,
'spirit': spirit,
'relation': relation,
'branch': branch,
'element': element,
'type': type.name,
'mark': mark,
};
}
factory YaoLineData.fromJson(Map<String, dynamic> json) {
return YaoLineData(
index: _requiredInt(json, 'index'),
spirit: _requiredString(json, 'spirit'),
relation: _requiredString(json, 'relation'),
branch: _requiredString(json, 'branch'),
element: _requiredString(json, 'element'),
type: yaoTypeFromName(_requiredString(json, 'type')),
mark: _requiredString(json, 'mark'),
);
}
}
String _requiredString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! String || value.isEmpty) {
throw FormatException('Missing required string: $key');
}
return value;
}
int _requiredInt(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
throw FormatException('Missing required int: $key');
}
@@ -12,6 +12,10 @@ class DivinationRunService {
final DivinationApi _api;
static final Logger _logger = getLogger('features.divination.run_service');
Future<PointsBalanceData> getPointsBalance() {
return _api.getPointsBalance();
}
Future<DivinationRunAggregate> run({
required DivinationParams params,
required List<YaoType> yaoStates,
@@ -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,
),
),
);