Files
eryao/apps/lib/features/divination/presentation/screens/divination_screen.dart
T
qzl 69b34bd723 feat: 添加起卦教程首次访问追踪和Agent时间上下文
- 后端 ProfileSettingsV1 添加 DivinationTutorialSettings 字段
- 前端三个起卦页面添加首次访问检测,自动弹出教程
- 教程展示后更新 settings 标记,避免重复弹出
- 使用本地状态管理避免并发更新覆盖问题
- Agent 系统提示添加时间上下文信息
2026-04-15 18:56:41 +08:00

594 lines
18 KiB
Dart

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/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/gua_icon.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../settings/data/models/profile_settings.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';
class DivinationScreen extends StatefulWidget {
const DivinationScreen({
super.key,
required this.sessionStore,
required this.userId,
required this.onCompleted,
this.runServiceOverride,
this.divinationApiOverride,
this.allowVibration = true,
required this.profileSettings,
required this.onProfileSettingsChanged,
});
final SessionStore sessionStore;
final String userId;
final Future<void> Function(DivinationResultData result) onCompleted;
final DivinationRunService? runServiceOverride;
final DivinationApi? divinationApiOverride;
final bool allowVibration;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override
State<DivinationScreen> createState() => _DivinationScreenState();
}
class _DivinationScreenState extends State<DivinationScreen> {
late DivinationParams _params;
final TextEditingController _questionController = TextEditingController();
late final DivinationApi _divinationApi;
late final DivinationRunService _runService;
bool _tutorialChecked = false;
late DivinationTutorialSettings _localTutorialSettings;
@override
void initState() {
super.initState();
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: widget.sessionStore.getToken,
);
_divinationApi =
widget.divinationApiOverride ?? DivinationApi(apiClient: apiClient);
_runService =
widget.runServiceOverride ?? DivinationRunService(api: _divinationApi);
_params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '',
divinationTime: DateTime.now(),
coinBalance: 0,
userId: widget.userId,
);
_questionController.addListener(_syncQuestion);
_localTutorialSettings = widget.profileSettings.divinationTutorial;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_tutorialChecked) {
_tutorialChecked = true;
_checkFirstVisit();
}
}
void _checkFirstVisit() {
if (!_localTutorialSettings.divinationEntryShown) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
_showGuide(context, l10n);
_markTutorialShown('divination_entry');
});
}
}
Future<void> _markTutorialShown(String key) async {
setState(() {
_localTutorialSettings = _localTutorialSettings.copyWith(
divinationEntryShown: key == 'divination_entry'
? true
: _localTutorialSettings.divinationEntryShown,
autoDivinationShown: key == 'auto_divination'
? true
: _localTutorialSettings.autoDivinationShown,
manualDivinationShown: key == 'manual_divination'
? true
: _localTutorialSettings.manualDivinationShown,
);
});
final updated = widget.profileSettings.copyWith(
divinationTutorial: _localTutorialSettings,
);
await widget.onProfileSettingsChanged(updated);
}
@override
void dispose() {
_questionController
..removeListener(_syncQuestion)
..dispose();
super.dispose();
}
void _syncQuestion() {
_params = _params.copyWith(question: _questionController.text.trim());
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
backgroundColor: colors.surface,
surfaceTintColor: colors.surface,
title: Text(l10n.divinationScreenTitle),
centerTitle: true,
),
body: _buildBody(context, l10n),
);
}
Widget _buildBody(BuildContext context, AppLocalizations l10n) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.xl,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_GuideEntryCard(onTap: () => _showGuide(context, l10n)),
const SizedBox(height: AppSpacing.lg),
_MethodSection(
selected: _params.method,
onChanged: (method) {
setState(() {
_params = _params.copyWith(method: method);
});
},
l10n: l10n,
),
const SizedBox(height: AppSpacing.xl),
Text(
l10n.divinationQuestionTypePrompt,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
_QuestionTypeSelector(
selected: _params.questionType,
onChanged: (type) {
setState(() {
_params = _params.copyWith(questionType: type);
});
},
l10n: l10n,
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.divinationQuestionInputPrompt,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
_QuestionTextField(controller: _questionController, l10n: l10n),
const SizedBox(height: AppSpacing.xl),
_StartButton(onPressed: _onStart, l10n: l10n),
],
),
),
);
}
void _onStart() {
final l10n = AppLocalizations.of(context)!;
if (_params.question.isEmpty) {
Toast.show(
context,
l10n.toastPleaseInputQuestion,
type: ToastType.warning,
);
return;
}
final updatedSettings = widget.profileSettings.copyWith(
divinationTutorial: _localTutorialSettings,
);
if (_params.method == DivinationMethod.manual) {
final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ManualDivinationScreen(
params: nextParams,
runService: _runService,
divinationApi: _divinationApi,
onCompleted: widget.onCompleted,
profileSettings: updatedSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
),
),
);
return;
}
final nextParams = _params.copyWith(
divinationTime: DateTime.now(),
allowVibration: widget.allowVibration,
);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AutoDivinationScreen(
params: nextParams,
runService: _runService,
divinationApi: _divinationApi,
onCompleted: widget.onCompleted,
profileSettings: updatedSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
),
),
);
}
}
class _GuideEntryCard extends StatelessWidget {
const _GuideEntryCard({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return DivinationInstructionCard(
text: l10n.divinationRecommendManual,
onTap: onTap,
);
}
}
class _MethodSection extends StatelessWidget {
const _MethodSection({
required this.selected,
required this.onChanged,
required this.l10n,
});
final DivinationMethod selected;
final ValueChanged<DivinationMethod> onChanged;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l10n.divinationSelectMethod,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(width: AppSpacing.sm),
IconButton(
onPressed: () => _showMethodTip(context, l10n),
icon: Icon(
Icons.help_outline,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
_MethodSegment(selected: selected, onChanged: onChanged, l10n: l10n),
],
);
}
}
class _MethodSegment extends StatelessWidget {
const _MethodSegment({
required this.selected,
required this.onChanged,
required this.l10n,
});
final DivinationMethod selected;
final ValueChanged<DivinationMethod> onChanged;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return SizedBox(
height: 40,
child: Row(
children: [
_SegmentButton(
text: l10n.divinationManualMethod,
selected: selected == DivinationMethod.manual,
onTap: () => onChanged(DivinationMethod.manual),
color: colors.primary,
isLeft: true,
),
_SegmentButton(
text: l10n.divinationAutoMethod,
selected: selected == DivinationMethod.auto,
onTap: () => onChanged(DivinationMethod.auto),
color: colors.primary,
isRight: true,
),
],
),
);
}
}
class _SegmentButton extends StatelessWidget {
const _SegmentButton({
required this.text,
required this.selected,
required this.onTap,
required this.color,
this.isLeft = false,
this.isRight = false,
});
final String text;
final bool selected;
final VoidCallback onTap;
final Color color;
final bool isLeft;
final bool isRight;
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.horizontal(
left: isLeft ? Radius.circular(AppRadius.sm) : Radius.zero,
right: isRight ? Radius.circular(AppRadius.sm) : Radius.zero,
),
child: Container(
decoration: BoxDecoration(
color: selected ? color : color.withValues(alpha: 0),
borderRadius: BorderRadius.horizontal(
left: isLeft ? Radius.circular(AppRadius.sm) : Radius.zero,
right: isRight ? Radius.circular(AppRadius.sm) : Radius.zero,
),
border: Border.all(color: color),
),
alignment: Alignment.center,
child: Text(
text,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: selected ? Theme.of(context).colorScheme.onPrimary : color,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
}
class _QuestionTextField extends StatelessWidget {
const _QuestionTextField({required this.controller, required this.l10n});
final TextEditingController controller;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return TextField(
controller: controller,
maxLines: 4,
decoration: InputDecoration(
hintText: l10n.divinationQuestionInputHint,
filled: true,
fillColor: colors.surface,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
borderSide: BorderSide(color: colors.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
borderSide: BorderSide(color: colors.primary),
),
),
);
}
}
class _StartButton extends StatelessWidget {
const _StartButton({required this.onPressed, required this.l10n});
final VoidCallback onPressed;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
onPressed: onPressed,
child: Text(l10n.divinationStartButton),
),
);
}
}
Future<void> _showMethodTip(BuildContext context, AppLocalizations l10n) {
return showDialog<void>(
context: context,
builder: (dialogContext) {
return AppModalDialog(
title: l10n.divinationMethodTipTitle,
message:
'${l10n.divinationMethodTipAuto}\n\n${l10n.divinationMethodTipManual}\n\n${l10n.divinationMethodTipRecommend}',
iconWidget: const GuaIcon(),
actions: [
AppModalDialogAction(
label: l10n.divinationIAcknowledge,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
);
},
);
}
Future<void> _showGuide(BuildContext context, AppLocalizations l10n) {
return showDialog<void>(
context: context,
builder: (context) {
return DivinationGuideDialog(
title: l10n.divinationManualGuideTitle,
guideImages: const [
['assets/images/tutorial/tutorial_1.png'],
['assets/images/tutorial/tutorial_2.png'],
['assets/images/tutorial/tutorial_3.png'],
],
instructions: [
l10n.divinationManualGuideStep1,
l10n.divinationManualGuideStep2,
l10n.divinationManualGuideStep3,
],
);
},
);
}
class _QuestionTypeSelector extends StatelessWidget {
const _QuestionTypeSelector({
required this.selected,
required this.onChanged,
required this.l10n,
});
final QuestionType selected;
final ValueChanged<QuestionType> onChanged;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final types = <(QuestionType, String, IconData)>[
(QuestionType.career, l10n.questionTypeCareer, Icons.work),
(QuestionType.love, l10n.questionTypeLove, Icons.favorite),
(QuestionType.wealth, l10n.questionTypeWealth, Icons.attach_money),
(QuestionType.fortune, l10n.questionTypeFortune, Icons.trending_up),
(QuestionType.dream, l10n.questionTypeDream, Icons.bedtime),
(QuestionType.health, l10n.questionTypeHealth, Icons.health_and_safety),
(QuestionType.study, l10n.questionTypeStudy, Icons.school),
(QuestionType.search, l10n.questionTypeSearch, Icons.search),
(QuestionType.other, l10n.questionTypeOther, Icons.help),
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: types.map((item) {
final isSelected = selected == item.$1;
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: _TypeChip(
label: item.$2,
icon: item.$3,
isSelected: isSelected,
onTap: () => onChanged(item.$1),
selectedColor: colors.primary,
),
);
}).toList(),
),
);
}
}
class _TypeChip extends StatelessWidget {
const _TypeChip({
required this.label,
required this.icon,
required this.isSelected,
required this.onTap,
required this.selectedColor,
});
final String label;
final IconData icon;
final bool isSelected;
final VoidCallback onTap;
final Color selectedColor;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
width: 92,
height: 45,
decoration: BoxDecoration(
color: isSelected
? selectedColor.withValues(alpha: 0.1)
: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(
color: isSelected ? selectedColor : colors.outline,
width: isSelected ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 20,
color: isSelected ? selectedColor : colors.onSurface,
),
const SizedBox(width: AppSpacing.xs),
Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: isSelected ? selectedColor : colors.onSurface,
),
),
],
),
),
);
}
}