feat: 接入起卦后端流程并完善积分扣减链路

This commit is contained in:
qzl
2026-04-03 19:04:46 +08:00
parent a136e42290
commit d87b2e1e3a
56 changed files with 3310 additions and 809 deletions
@@ -7,8 +7,6 @@ import 'package:intl/intl.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:vibration/vibration.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
@@ -16,11 +14,9 @@ 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_params.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_result_screen.dart';
import 'divination_processing_screen.dart';
class AutoDivinationScreen extends StatefulWidget {
const AutoDivinationScreen({
@@ -220,35 +216,22 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
setState(() {
_submitting = true;
});
try {
final aggregate = await widget.runService.run(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _yaoStates,
);
if (!mounted) {
return;
}
Navigator.of(context).push(
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: aggregate.toViewData(
widget.params.copyWith(divinationTime: _selectedTime),
),
builder: (_) => DivinationProcessingScreen(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _yaoStates,
runService: widget.runService,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
Toast.show(context, message, type: ToastType.error);
} finally {
if (!mounted) {
return;
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.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';
import '../../data/services/divination_run_service.dart';
import 'divination_result_screen.dart';
enum _ProcessingStep { preparing, deriving, done }
class DivinationProcessingScreen extends StatefulWidget {
const DivinationProcessingScreen({
super.key,
required this.params,
required this.yaoStates,
required this.runService,
});
final DivinationParams params;
final List<YaoType> yaoStates;
final DivinationRunService runService;
@override
State<DivinationProcessingScreen> createState() =>
_DivinationProcessingScreenState();
}
class _DivinationProcessingScreenState
extends State<DivinationProcessingScreen> {
static final Logger _logger = getLogger(
'features.divination.processing_screen',
);
_ProcessingStep _step = _ProcessingStep.preparing;
DivinationResultData? _resultData;
String? _errorMessage;
@override
void initState() {
super.initState();
_startRun();
}
Future<void> _startRun() async {
try {
final aggregate = await widget.runService.run(
params: widget.params,
yaoStates: widget.yaoStates,
onDerived: () {
if (!mounted) {
return;
}
setState(() {
_step = _ProcessingStep.deriving;
});
},
onTextMessageEnd: () {
if (!mounted) {
return;
}
setState(() {
_step = _ProcessingStep.done;
});
},
);
if (!mounted) {
return;
}
setState(() {
_resultData = aggregate.toViewData(widget.params);
_step = _ProcessingStep.done;
});
} catch (error, stackTrace) {
_logger.error(
message: 'Divination processing failed while waiting result events',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{
'step': _step.name,
'method': widget.params.method.name,
'questionType': widget.params.questionType.name,
},
);
if (!mounted) {
return;
}
final l10n = AppLocalizations.of(context)!;
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
setState(() {
_errorMessage = message;
});
Toast.show(context, message, type: ToastType.error);
}
}
void _openResult() {
final data = _resultData;
if (data == null) {
return;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(data: data),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final text = switch (_step) {
_ProcessingStep.preparing => l10n.transitionPreparing,
_ProcessingStep.deriving => l10n.transitionDeriving,
_ProcessingStep.done => l10n.transitionDone,
};
final canContinue = _step == _ProcessingStep.done && _resultData != null;
return Scaffold(
backgroundColor: colors.surface,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
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: 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(
_errorMessage!,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: colors.error),
),
),
),
),
);
}
}
@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -25,41 +24,30 @@ class DivinationResultScreen extends StatefulWidget {
State<DivinationResultScreen> createState() => _DivinationResultScreenState();
}
enum _ResultTransitionStep { preparing, deriving, done }
class _DivinationResultScreenState extends State<DivinationResultScreen> {
_ResultTransitionStep _step = _ResultTransitionStep.preparing;
bool _showOverlay = true;
bool _showIntro = true;
bool _introCollapsed = false;
@override
void initState() {
super.initState();
_playSequence();
_playIntro();
}
Future<void> _playSequence() async {
await Future<void>.delayed(const Duration(milliseconds: 420));
Future<void> _playIntro() async {
await Future<void>.delayed(const Duration(milliseconds: 120));
if (!mounted) {
return;
}
setState(() {
_step = _ResultTransitionStep.deriving;
_introCollapsed = true;
});
await Future<void>.delayed(const Duration(milliseconds: 820));
await Future<void>.delayed(const Duration(milliseconds: 760));
if (!mounted) {
return;
}
setState(() {
_step = _ResultTransitionStep.done;
});
}
void _dismissOverlay() {
if (_step != _ResultTransitionStep.done) {
return;
}
setState(() {
_showOverlay = false;
_showIntro = false;
});
}
@@ -78,172 +66,106 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
),
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),
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,
),
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.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,
),
),
],
),
),
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),
),
),
),
),
),
],
),
);
}
@@ -1,17 +1,30 @@
import 'package:flutter/material.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/auth/session_store.dart';
import '../../../../data/network/api_client.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/apis/divination_api.dart';
import '../../data/models/divination_params.dart';
import '../../data/services/divination_run_service.dart';
import 'auto_divination_screen.dart';
import 'manual_divination_screen.dart';
class DivinationScreen extends StatefulWidget {
const DivinationScreen({super.key});
const DivinationScreen({
super.key,
required this.sessionStore,
required this.userId,
this.runServiceOverride,
});
final SessionStore sessionStore;
final String userId;
final DivinationRunService? runServiceOverride;
@override
State<DivinationScreen> createState() => _DivinationScreenState();
@@ -20,11 +33,26 @@ class DivinationScreen extends StatefulWidget {
class _DivinationScreenState extends State<DivinationScreen> {
late DivinationParams _params;
final TextEditingController _questionController = TextEditingController();
late final DivinationRunService _runService;
@override
void initState() {
super.initState();
_params = DivinationMockData.initial();
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: widget.sessionStore.getToken,
);
_runService =
widget.runServiceOverride ??
DivinationRunService(api: DivinationApi(apiClient: apiClient));
_params = DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '',
divinationTime: DateTime.now(),
coinBalance: 0,
userId: widget.userId,
);
_questionController.addListener(_syncQuestion);
}
@@ -58,7 +86,6 @@ class _DivinationScreenState extends State<DivinationScreen> {
}
Widget _buildBody(BuildContext context, AppLocalizations l10n) {
final palette = Theme.of(context).extension<AppColorPalette>()!;
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SingleChildScrollView(
@@ -106,15 +133,6 @@ class _DivinationScreenState extends State<DivinationScreen> {
_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),
),
),
],
),
),
@@ -132,16 +150,14 @@ class _DivinationScreenState extends State<DivinationScreen> {
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),
builder: (_) => ManualDivinationScreen(
params: nextParams,
runService: _runService,
),
),
);
return;
@@ -150,7 +166,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AutoDivinationScreen(params: nextParams),
builder: (_) =>
AutoDivinationScreen(params: nextParams, runService: _runService),
),
);
}
@@ -2,20 +2,16 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.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 '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../data/models/divination_params.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_result_screen.dart';
import 'divination_processing_screen.dart';
class ManualDivinationScreen extends StatefulWidget {
const ManualDivinationScreen({
@@ -126,12 +122,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
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',
],
guideImages: const ['lc2.jpg', 'lc3.jpg', 'lc4.jpg', 'lc5.jpg'],
instructionText: l10n.manualYaoTipContent,
);
},
@@ -180,35 +171,22 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
setState(() {
_submitting = true;
});
try {
final aggregate = await widget.runService.run(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _selectedYaos.cast<YaoType>(),
);
if (!mounted) {
return;
}
Navigator.of(context).push(
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: aggregate.toViewData(
widget.params.copyWith(divinationTime: _selectedTime),
),
builder: (_) => DivinationProcessingScreen(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _selectedYaos.cast<YaoType>(),
runService: widget.runService,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
Toast.show(context, message, type: ToastType.error);
} finally {
if (!mounted) {
return;
@@ -373,70 +351,206 @@ class _YaoSelectionCard extends StatelessWidget {
void Function(int, YaoType) onSelect,
) {
final l10n = AppLocalizations.of(context)!;
final options = <(String, YaoType)>[
final colors = Theme.of(context).colorScheme;
final options = <(String, YaoType, String)>[
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]} (\u2014)\uFF1A\u82B1\u5B57\u5B57',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]}${DivinationTerms.youngYangSymbol}',
YaoType.youngYang,
'字字字',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]} (--)\uFF1A\u5B57\u82B1\u82B1',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]}${DivinationTerms.youngYinSymbol}',
YaoType.youngYin,
'花花花',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]} (\u2014\u25CB)\uFF1A\u82B1\u82B1\u82B1',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]}${DivinationTerms.oldYangSymbol}',
YaoType.oldYang,
'字字字',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]} (--\u00D7)\uFF1A\u5B57\u5B57\u5B57',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]}${DivinationTerms.oldYinSymbol}',
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),
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,
),
),
child: Text(option.$1),
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: (_, __, ___) => 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),
),
],
),
),
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),
),
],
);
},
);
}
}
class _YaoOptionCard extends StatelessWidget {
const _YaoOptionCard({
required this.label,
required this.pattern,
required this.isSelected,
required this.onTap,
});
final String label;
final String pattern;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext 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,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: isSelected
? colors.onPrimaryContainer
: colors.onSurfaceVariant,
),
],
),
),
),
);
}
}