docs: 更新协议文档,删除废弃计划文档

- 更新 http-error-codes, user-points-chat-data-protocol
- 更新 divination-run-protocol, profile-protocol
- 删除废弃的后端和前端设计计划文档
This commit is contained in:
qzl
2026-04-08 17:23:02 +08:00
parent 49fc9a116f
commit e80a82bef4
57 changed files with 4117 additions and 2269 deletions
@@ -4,6 +4,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:onboarding_overlay/onboarding_overlay.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:vibration/vibration.dart';
@@ -19,6 +20,7 @@ import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_shee
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/divination_backend_models.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';
@@ -29,11 +31,13 @@ class AutoDivinationScreen extends StatefulWidget {
super.key,
required this.params,
required this.runService,
this.divinationApi,
required this.onCompleted,
});
final DivinationParams params;
final DivinationRunService runService;
final DivinationApi? divinationApi;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
@@ -48,6 +52,7 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
YaoType.undetermined,
);
late final AnimationController _spinController;
late final AnimationController _blinkController;
StreamSubscription<AccelerometerEvent>? _accSubscription;
DateTime _selectedTime = DateTime.now();
bool _isSpinning = false;
@@ -60,6 +65,17 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
bool _spinLocked = false;
bool _submitting = false;
final GlobalKey<OnboardingState> _onboardingKey =
GlobalKey<OnboardingState>();
final ScrollController _scrollController = ScrollController();
final GlobalKey _step2TargetKey = GlobalKey();
final GlobalKey _step3TargetKey = GlobalKey();
final GlobalKey _step4TargetKey = GlobalKey();
final FocusNode _step1Focus = FocusNode();
final FocusNode _step2Focus = FocusNode();
final FocusNode _step3Focus = FocusNode();
final FocusNode _step4Focus = FocusNode();
@override
void initState() {
super.initState();
@@ -68,13 +84,23 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
vsync: this,
duration: const Duration(milliseconds: 500),
);
_blinkController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
)..repeat(reverse: true);
_listenShake();
}
@override
void dispose() {
_accSubscription?.cancel();
_scrollController.dispose();
_spinController.dispose();
_blinkController.dispose();
_step1Focus.dispose();
_step2Focus.dispose();
_step3Focus.dispose();
_step4Focus.dispose();
super.dispose();
}
@@ -96,35 +122,113 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
}
Widget _buildBody(BuildContext context, AppLocalizations l10n) {
return SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
children: [
_InstructionCard(onTap: () => _showGuide(context, l10n)),
const SizedBox(height: AppSpacing.lg),
_TimeSelectorCard(selectedTime: _selectedTime, onPickTime: _pickTime),
const SizedBox(height: AppSpacing.lg),
_YaoPickerCard(
isSpinning: _isSpinning,
coin1Yang: _coin1Yang,
coin2Yang: _coin2Yang,
coin3Yang: _coin3Yang,
spinController: _spinController,
countdown: _countdown,
shakeCount: _shakeCount,
canShake: _canShake,
onStartShake: _startSpin,
buttonText: _buttonText(l10n),
statusText: _statusText(l10n),
),
const SizedBox(height: AppSpacing.lg),
_HexagramCard(yaoStates: _yaoStates),
const SizedBox(height: AppSpacing.lg),
_ResolveButton(
enabled: _shakeCount >= 6 && !_submitting,
onPressed: _submitRun,
),
],
final steps = [
OnboardingStep(
focusNode: _step1Focus,
titleText: l10n.autoGuideStep1Title,
bodyText: l10n.autoGuideStep1Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: false,
hasLabelBox: true,
fullscreen: true,
overlayColor: Colors.black.withValues(alpha: 0.7),
),
OnboardingStep(
focusNode: _step2Focus,
titleText: l10n.autoGuideStep2Title,
bodyText: l10n.autoGuideStep2Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: true,
hasLabelBox: true,
arrowPosition: ArrowPosition.top,
overlayColor: Colors.black.withValues(alpha: 0.7),
delay: const Duration(milliseconds: 320),
),
OnboardingStep(
focusNode: _step3Focus,
titleText: l10n.autoGuideStep3Title,
bodyText: l10n.autoGuideStep3Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: true,
hasLabelBox: true,
arrowPosition: ArrowPosition.top,
overlayColor: Colors.black.withValues(alpha: 0.7),
delay: const Duration(milliseconds: 320),
),
OnboardingStep(
focusNode: _step4Focus,
titleText: l10n.autoGuideStep4Title,
bodyText: l10n.autoGuideStep4Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: true,
hasLabelBox: true,
arrowPosition: ArrowPosition.top,
overlayColor: Colors.black.withValues(alpha: 0.7),
delay: const Duration(milliseconds: 320),
),
];
return Onboarding(
key: _onboardingKey,
steps: steps,
onChanged: _onGuideStepChanged,
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
children: [
_InstructionCard(onTap: _showGuide),
const SizedBox(height: AppSpacing.lg),
_TimeSelectorCard(
selectedTime: _selectedTime,
onPickTime: _pickTime,
),
const SizedBox(height: AppSpacing.lg),
Container(
key: _step2TargetKey,
child: Focus(
focusNode: _step2Focus,
child: _YaoPickerCard(
isSpinning: _isSpinning,
coin1Yang: _coin1Yang,
coin2Yang: _coin2Yang,
coin3Yang: _coin3Yang,
spinController: _spinController,
countdown: _countdown,
shakeCount: _shakeCount,
canShake: _canShake,
onStartShake: _startSpin,
buttonText: _buttonText(l10n),
statusText: _statusText(l10n),
),
),
),
const SizedBox(height: AppSpacing.lg),
Container(
key: _step3TargetKey,
child: Focus(
focusNode: _step3Focus,
child: _HexagramCard(yaoStates: _yaoStates),
),
),
const SizedBox(height: AppSpacing.lg),
Container(
key: _step4TargetKey,
child: Focus(
focusNode: _step4Focus,
child: _ResolveButton(
enabled: _shakeCount >= 6 && !_submitting,
onPressed: _submitRun,
blinkAnimation: _blinkController,
),
),
),
],
),
),
);
}
@@ -286,6 +390,7 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _yaoStates,
runService: widget.runService,
divinationApi: widget.divinationApi,
onCompleted: widget.onCompleted,
),
),
@@ -311,24 +416,35 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
await HapticFeedback.heavyImpact();
}
Future<void> _showGuide(BuildContext context, AppLocalizations l10n) async {
await 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,
],
);
},
void _showGuide() {
_scrollToGuideStep(0);
Future<void>.delayed(const Duration(milliseconds: 120), () {
if (!mounted) {
return;
}
_onboardingKey.currentState?.show();
});
}
void _onGuideStepChanged(int currentIndex) {
_scrollToGuideStep(currentIndex + 1);
}
void _scrollToGuideStep(int stepIndex) {
final GlobalKey? targetKey = switch (stepIndex) {
2 => _step3TargetKey,
3 => _step4TargetKey,
_ => null,
};
final targetContext = targetKey?.currentContext;
if (targetContext == null) {
return;
}
Scrollable.ensureVisible(
targetContext,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOut,
alignment: 0.12,
);
}
}
@@ -532,7 +648,7 @@ class _CoinColumn extends StatelessWidget {
),
const SizedBox(height: AppSpacing.sm),
Text(
DivinationTerms.ziHua[isYang] ?? '',
_coinFaceLabel(context, isYang),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w700,
@@ -541,6 +657,11 @@ class _CoinColumn extends StatelessWidget {
],
);
}
String _coinFaceLabel(BuildContext context, bool isYang) {
final l10n = AppLocalizations.of(context)!;
return isYang ? l10n.autoCoinFaceZi : l10n.autoCoinFaceHua;
}
}
class _CoinFace extends StatelessWidget {
@@ -567,8 +688,8 @@ class _CoinFace extends StatelessWidget {
: (isYang ? 0 : 180);
final showingYang = isSpinning ? rotationY < 90 : isYang;
final image = showingYang
? 'assets/images/qigua/hua.jpg'
: 'assets/images/qigua/zi.jpg';
? 'assets/images/qigua/zi.jpg'
: 'assets/images/qigua/hua.jpg';
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
@@ -632,10 +753,11 @@ class _YaoRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
child: YaoLineRow(
name: DivinationTerms.yaoNames[index],
name: DivinationTerms.yaoName(l10n, index),
type: type,
showChangeMark: true,
lineHeight: 8,
@@ -645,21 +767,38 @@ class _YaoRow extends StatelessWidget {
}
class _ResolveButton extends StatelessWidget {
const _ResolveButton({required this.enabled, required this.onPressed});
const _ResolveButton({
required this.enabled,
required this.onPressed,
required this.blinkAnimation,
});
final bool enabled;
final VoidCallback onPressed;
final Animation<double> blinkAnimation;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: enabled ? onPressed : null,
child: Text(l10n.autoStartResolve),
),
final colors = Theme.of(context).colorScheme;
return AnimatedBuilder(
animation: blinkAnimation,
builder: (context, _) {
final base = colors.primary;
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: enabled ? onPressed : null,
style: FilledButton.styleFrom(
backgroundColor: enabled
? base.withValues(alpha: 0.6 + blinkAnimation.value * 0.4)
: base,
),
child: Text(l10n.autoStartResolve),
),
);
},
);
}
}
@@ -7,6 +7,7 @@ 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/apis/divination_api.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
@@ -20,12 +21,14 @@ class DivinationProcessingScreen extends StatefulWidget {
required this.params,
required this.yaoStates,
required this.runService,
this.divinationApi,
required this.onCompleted,
});
final DivinationParams params;
final List<YaoType> yaoStates;
final DivinationRunService runService;
final DivinationApi? divinationApi;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
@@ -148,7 +151,11 @@ class _DivinationProcessingScreenState extends State<DivinationProcessingScreen>
}
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(data: data),
builder: (_) => DivinationResultScreen(
data: data,
divinationApi: data.threadId == null ? null : widget.divinationApi,
enableIntroTransition: true,
),
),
);
}
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../../../../core/logging/logger.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
@@ -12,24 +13,37 @@ import '../../../../shared/widgets/divination/yao_glyph.dart';
import '../../../../shared/widgets/divination/yao_legend.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 'follow_up_chat_screen.dart';
class DivinationResultScreen extends StatefulWidget {
const DivinationResultScreen({super.key, required this.data});
const DivinationResultScreen({
super.key,
required this.data,
this.divinationApi,
this.enableIntroTransition = false,
});
final DivinationResultData data;
final DivinationApi? divinationApi;
final bool enableIntroTransition;
@override
State<DivinationResultScreen> createState() => _DivinationResultScreenState();
}
class _DivinationResultScreenState extends State<DivinationResultScreen> {
bool _showIntro = true;
static final Logger _logger = getLogger('features.divination.result_screen');
bool _showIntro = false;
bool _introCollapsed = false;
Rect? _introTargetRect;
final GlobalKey _stackKey = GlobalKey();
final GlobalKey _finalSignCardKey = GlobalKey();
bool _followUpEligibilityLoading = false;
bool _canSendFollowUp = true;
void _backToHome() {
final navigator = Navigator.of(context);
@@ -39,9 +53,48 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_prepareIntro();
if (widget.enableIntroTransition) {
_showIntro = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_prepareIntro();
});
}
_loadFollowUpEligibility();
}
Future<void> _loadFollowUpEligibility() async {
if (widget.divinationApi == null || widget.data.threadId == null) {
return;
}
setState(() {
_followUpEligibilityLoading = true;
});
try {
final messages = await widget.divinationApi!.getSessionMessages(
threadId: widget.data.threadId!,
);
final userCount = messages.where((msg) => msg.role == 'user').length;
if (!mounted) {
return;
}
setState(() {
_canSendFollowUp = userCount < 2;
_followUpEligibilityLoading = false;
});
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to load follow-up eligibility',
error: error,
stackTrace: stackTrace,
);
if (!mounted) {
return;
}
setState(() {
_canSendFollowUp = false;
_followUpEligibilityLoading = false;
});
}
}
Future<void> _prepareIntro() async {
@@ -138,6 +191,7 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
title: Text(l10n.resultScreenTitle),
centerTitle: true,
),
bottomNavigationBar: _buildFollowUpBar(context),
body: LayoutBuilder(
builder: (context, constraints) {
final stackSize = Size(constraints.maxWidth, constraints.maxHeight);
@@ -273,6 +327,24 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors.surface.withValues(alpha: 0),
colors.surface,
],
),
),
),
),
],
);
},
@@ -280,6 +352,72 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
),
);
}
Widget? _buildFollowUpBar(BuildContext context) {
if (widget.divinationApi == null || widget.data.threadId == null) {
return null;
}
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
AppSpacing.md,
),
decoration: BoxDecoration(color: colors.surface),
child: Row(
children: [
Expanded(
child: Text(
_canSendFollowUp
? l10n.followUpEntryHint
: l10n.followUpQuotaUsed,
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(width: AppSpacing.sm),
FilledButton(
onPressed: _followUpEligibilityLoading
? null
: () async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => FollowUpChatScreen(
result: widget.data,
api: widget.divinationApi!,
threadId: widget.data.threadId!,
),
),
);
if (!mounted) {
return;
}
await _loadFollowUpEligibility();
},
child: _followUpEligibilityLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.onPrimary,
),
)
: Text(
_canSendFollowUp
? l10n.followUpEntryAction
: l10n.followUpViewHistory,
),
),
],
),
),
);
}
}
class _ResultHeader extends StatelessWidget {
@@ -24,6 +24,7 @@ class DivinationScreen extends StatefulWidget {
required this.userId,
required this.onCompleted,
this.runServiceOverride,
this.divinationApiOverride,
this.allowVibration = true,
});
@@ -31,6 +32,7 @@ class DivinationScreen extends StatefulWidget {
final String userId;
final Future<void> Function(DivinationResultData result) onCompleted;
final DivinationRunService? runServiceOverride;
final DivinationApi? divinationApiOverride;
final bool allowVibration;
@override
@@ -40,6 +42,7 @@ class DivinationScreen extends StatefulWidget {
class _DivinationScreenState extends State<DivinationScreen> {
late DivinationParams _params;
final TextEditingController _questionController = TextEditingController();
late final DivinationApi _divinationApi;
late final DivinationRunService _runService;
@override
@@ -49,9 +52,10 @@ class _DivinationScreenState extends State<DivinationScreen> {
baseUrl: appDependencies.backendUrl,
tokenProvider: widget.sessionStore.getToken,
);
_divinationApi =
widget.divinationApiOverride ?? DivinationApi(apiClient: apiClient);
_runService =
widget.runServiceOverride ??
DivinationRunService(api: DivinationApi(apiClient: apiClient));
widget.runServiceOverride ?? DivinationRunService(api: _divinationApi);
_params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
@@ -164,6 +168,7 @@ class _DivinationScreenState extends State<DivinationScreen> {
builder: (_) => ManualDivinationScreen(
params: nextParams,
runService: _runService,
divinationApi: _divinationApi,
onCompleted: widget.onCompleted,
),
),
@@ -180,6 +185,7 @@ class _DivinationScreenState extends State<DivinationScreen> {
builder: (_) => AutoDivinationScreen(
params: nextParams,
runService: _runService,
divinationApi: _divinationApi,
onCompleted: widget.onCompleted,
),
),
@@ -0,0 +1,573 @@
import 'dart:math';
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/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/divination/divination_summary_card.dart';
import '../../../../shared/widgets/gua_icon.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/models/follow_up_message.dart';
class FollowUpChatScreen extends StatefulWidget {
const FollowUpChatScreen({
super.key,
required this.result,
required this.api,
required this.threadId,
});
final DivinationResultData result;
final DivinationApi api;
final String threadId;
@override
State<FollowUpChatScreen> createState() => _FollowUpChatScreenState();
}
class _FollowUpChatScreenState extends State<FollowUpChatScreen> {
static final Logger _logger = getLogger('features.divination.follow_up_chat');
final TextEditingController _inputController = TextEditingController();
final ScrollController _scrollController = ScrollController();
List<FollowUpMessage> _messages = const <FollowUpMessage>[];
bool _loading = true;
bool _sending = false;
@override
void initState() {
super.initState();
_loadHistory();
}
@override
void dispose() {
_inputController.dispose();
_scrollController.dispose();
super.dispose();
}
bool get _hasFollowUpQuota {
final userCount = _messages.where((item) => item.role == 'user').length;
return userCount < 2;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
backgroundColor: colors.surface,
surfaceTintColor: colors.surface,
title: Text(l10n.followUpScreenTitle),
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors.primaryContainer.withValues(alpha: 0.12),
colors.surface,
],
),
),
child: SafeArea(
child: Column(
children: [
_buildHeader(context, colors),
Expanded(child: _buildTimeline(context, l10n, colors)),
_buildInputArea(context, l10n, colors),
],
),
),
),
);
}
Widget _buildHeader(BuildContext context, ColorScheme colors) {
final l10n = AppLocalizations.of(context)!;
final palette = Theme.of(context).extension<AppColorPalette>()!;
final questionTypeLabel = _localizedQuestionType(
l10n,
widget.result.params.questionType,
);
final categoryStyle = switch (widget.result.params.questionType) {
QuestionType.career || QuestionType.study => (
palette.categoryCareerBg,
palette.categoryCareerText,
),
QuestionType.love => (palette.categoryLoveBg, palette.categoryLoveText),
_ => (palette.categoryMoneyBg, palette.categoryMoneyText),
};
final normalizedSignType = widget.result.signType.trim();
final isBestSign = normalizedSignType.contains('上上');
final isGoodSign = !isBestSign && normalizedSignType.contains('中上');
final isWorstSign = normalizedSignType.contains('下下');
final signLabel = _localizedSignType(l10n, widget.result.signType);
final signStyle = isBestSign
? (palette.historyGoldBg, palette.historyGoldText)
: isGoodSign
? (colors.surfaceContainerHighest, colors.primary)
: isWorstSign
? (colors.errorContainer, colors.onErrorContainer)
: (palette.historyGrayBg, palette.historyGrayText);
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
AppSpacing.sm,
),
child: DivinationSummaryCard(
question: widget.result.params.question,
leading: GuaIcon(color: colors.onPrimary, size: 22),
leadingBackgroundColor: palette.accentPurple,
tags: [
DivinationSummaryTagData(
label: questionTypeLabel,
background: categoryStyle.$1,
foreground: categoryStyle.$2,
),
DivinationSummaryTagData(
label: widget.result.guaName,
background: palette.historyBlueBg,
foreground: palette.historyBlueText,
),
DivinationSummaryTagData(
label: signLabel,
background: signStyle.$1,
foreground: signStyle.$2,
),
],
),
);
}
String _localizedSignType(AppLocalizations l10n, String rawSignType) {
final normalized = rawSignType.trim();
if (normalized.contains('上上')) {
return l10n.signTypeShangShang;
}
if (normalized.contains('中上')) {
return l10n.signTypeZhongShang;
}
if (normalized.contains('下下')) {
return l10n.signTypeXiaXia;
}
return l10n.signTypeZhongXia;
}
String _localizedQuestionType(
AppLocalizations l10n,
QuestionType questionType,
) {
return switch (questionType) {
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,
};
}
Widget _buildTimeline(
BuildContext context,
AppLocalizations l10n,
ColorScheme colors,
) {
if (_loading) {
return Center(child: CircularProgressIndicator(color: colors.primary));
}
if (_messages.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
child: Text(
l10n.followUpEmpty,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant),
),
),
);
}
return ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.sm,
AppSpacing.md,
AppSpacing.sm,
),
itemCount: _messages.length,
separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.sm),
itemBuilder: (context, index) {
final item = _messages[index];
final isUser = item.role == 'user';
final bubbleColor = isUser ? colors.primary : colors.surface;
final textColor = isUser ? colors.onPrimary : colors.onSurface;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78,
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.circular(AppRadius.md),
border: isUser
? null
: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.65),
),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(
alpha: isUser ? 0.08 : 0.05,
),
blurRadius: isUser ? 10 : 6,
offset: const Offset(0, 2),
),
],
),
child: Text(
item.content.isEmpty ? '...' : item.content,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: textColor, height: 1.5),
),
),
),
);
},
);
}
Widget _buildInputArea(
BuildContext context,
AppLocalizations l10n,
ColorScheme colors,
) {
final hintText = _hasFollowUpQuota
? l10n.followUpEntryHint
: l10n.followUpQuotaUsed;
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.xs,
AppSpacing.md,
AppSpacing.sm,
),
color: colors.surface,
child: Row(
children: [
Expanded(
child: TextField(
controller: _inputController,
minLines: 1,
maxLines: 4,
enabled: !_sending && _hasFollowUpQuota,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
fontSize: 15,
height: 1.35,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
fontSize: 12,
height: 1.35,
fontWeight: FontWeight.w400,
),
filled: false,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
borderSide: BorderSide(color: colors.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
borderSide: BorderSide(color: colors.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
borderSide: BorderSide(color: colors.primary, width: 1.4),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
borderSide: BorderSide(
color: _hasFollowUpQuota
? colors.outlineVariant
: colors.error.withValues(alpha: 0.4),
),
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submitText(),
),
),
const SizedBox(width: AppSpacing.sm),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _inputController,
builder: (context, value, _) {
final hasText = value.text.trim().isNotEmpty;
final canSend = !_sending && _hasFollowUpQuota && hasText;
return AnimatedScale(
scale: canSend ? 1 : 0.94,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: canSend
? [
BoxShadow(
color: colors.primary.withValues(alpha: 0.2),
blurRadius: 10,
offset: const Offset(0, 3),
),
]
: const <BoxShadow>[],
),
child: IconButton.filled(
onPressed: canSend ? _submitText : null,
style: IconButton.styleFrom(
backgroundColor: canSend
? colors.primary
: colors.surfaceContainerHighest,
foregroundColor: canSend
? colors.onPrimary
: colors.onSurfaceVariant,
),
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _sending
? SizedBox(
key: const ValueKey('follow_up_sending'),
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.onPrimary,
),
)
: Icon(
Icons.send_rounded,
key: ValueKey('follow_up_send_$canSend'),
),
),
),
),
);
},
),
],
),
);
}
Future<void> _loadHistory() async {
try {
final messages = await widget.api.getSessionMessages(
threadId: widget.threadId,
);
if (!mounted) {
return;
}
setState(() {
_messages = messages;
_loading = false;
});
_scrollToBottom();
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to load follow-up history',
error: error,
stackTrace: stackTrace,
);
if (!mounted) {
return;
}
setState(() {
_loading = false;
});
final l10n = AppLocalizations.of(context)!;
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
Toast.show(context, message, type: ToastType.error);
}
}
Future<void> _submitText() async {
final text = _inputController.text.trim();
if (text.isEmpty || _sending || !_hasFollowUpQuota) {
return;
}
_inputController.clear();
final l10n = AppLocalizations.of(context)!;
final runId =
'run_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(99999)}';
final now = DateTime.now();
final localUser = FollowUpMessage(
id: 'local_user_$runId',
seq: _messages.length + 1,
role: 'user',
content: text,
timestamp: now,
);
final localAssistant = FollowUpMessage(
id: 'local_assistant_$runId',
seq: _messages.length + 2,
role: 'assistant',
content: '',
timestamp: now,
);
setState(() {
_sending = true;
_messages = [..._messages, localUser, localAssistant];
});
_scrollToBottom();
String answer = '';
try {
await widget.api.enqueueFollowUp(
threadId: widget.threadId,
runId: runId,
question: text,
result: widget.result,
);
await for (final event in widget.api.streamEvents(
threadId: widget.threadId,
runId: runId,
)) {
final type = event['type'] as String? ?? '';
if (type == 'TEXT_MESSAGE_END') {
answer = (event['answer'] as String?) ?? '';
continue;
}
if (type == 'RUN_ERROR') {
throw ApiProblem(
status: 500,
title: 'Run failed',
detail: event['detail'] as String? ?? l10n.errorRequestGeneric,
code: event['code'] as String?,
);
}
if (type == 'RUN_FINISHED') {
break;
}
}
if (!mounted) {
return;
}
setState(() {
_messages = _messages
.map(
(item) => item.id == localAssistant.id
? FollowUpMessage(
id: item.id,
seq: item.seq,
role: item.role,
content: answer,
timestamp: item.timestamp,
)
: item,
)
.toList(growable: false);
});
_scrollToBottom();
await _loadHistory();
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to submit follow-up text',
error: error,
stackTrace: stackTrace,
);
if (!mounted) {
return;
}
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
Toast.show(context, message, type: ToastType.error);
await _loadHistory();
} finally {
if (mounted) {
setState(() {
_sending = false;
});
}
}
}
void _scrollToBottom([int retry = 0]) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
return;
}
final position = _scrollController.position;
if (!position.hasContentDimensions) {
if (retry < 3) {
_scrollToBottom(retry + 1);
}
return;
}
final target = position.maxScrollExtent;
if (!target.isFinite) {
return;
}
_scrollController.animateTo(
target,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
);
});
}
}
@@ -3,6 +3,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:onboarding_overlay/onboarding_overlay.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
@@ -16,6 +17,7 @@ import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_shee
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/divination_backend_models.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';
@@ -26,11 +28,13 @@ class ManualDivinationScreen extends StatefulWidget {
super.key,
required this.params,
required this.runService,
this.divinationApi,
required this.onCompleted,
});
final DivinationParams params;
final DivinationRunService runService;
final DivinationApi? divinationApi;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
@@ -43,6 +47,16 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
final List<YaoType?> _selectedYaos = List<YaoType?>.filled(6, null);
late final AnimationController _blinkController;
bool _submitting = false;
final GlobalKey<OnboardingState> _onboardingKey =
GlobalKey<OnboardingState>();
final ScrollController _scrollController = ScrollController();
final GlobalKey _timeCardKey = GlobalKey();
final GlobalKey _yaoCardKey = GlobalKey();
final GlobalKey _analyzeButtonKey = GlobalKey();
final FocusNode _guideStep1Focus = FocusNode();
final FocusNode _guideStep2Focus = FocusNode();
final FocusNode _guideStep3Focus = FocusNode();
final FocusNode _guideStep4Focus = FocusNode();
@override
void initState() {
@@ -56,7 +70,12 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
@override
void dispose() {
_scrollController.dispose();
_blinkController.dispose();
_guideStep1Focus.dispose();
_guideStep2Focus.dispose();
_guideStep3Focus.dispose();
_guideStep4Focus.dispose();
super.dispose();
}
@@ -66,6 +85,55 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final guideSteps = [
OnboardingStep(
focusNode: _guideStep1Focus,
titleText: l10n.manualGuideStep1Title,
bodyText: l10n.manualGuideStep1Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: false,
hasLabelBox: true,
fullscreen: true,
overlayColor: Colors.black.withValues(alpha: 0.7),
),
OnboardingStep(
focusNode: _guideStep2Focus,
titleText: l10n.manualGuideStep2Title,
bodyText: l10n.manualGuideStep2Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: true,
hasLabelBox: true,
arrowPosition: ArrowPosition.top,
delay: const Duration(milliseconds: 320),
overlayColor: Colors.black.withValues(alpha: 0.7),
),
OnboardingStep(
focusNode: _guideStep3Focus,
titleText: l10n.manualGuideStep3Title,
bodyText: l10n.manualGuideStep3Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: true,
hasLabelBox: true,
arrowPosition: ArrowPosition.top,
delay: const Duration(milliseconds: 320),
overlayColor: Colors.black.withValues(alpha: 0.7),
),
OnboardingStep(
focusNode: _guideStep4Focus,
titleText: l10n.manualGuideStep4Title,
bodyText: l10n.manualGuideStep4Body,
titleTextColor: Colors.white,
bodyTextColor: Colors.white,
hasArrow: true,
hasLabelBox: true,
arrowPosition: ArrowPosition.top,
delay: const Duration(milliseconds: 320),
overlayColor: Colors.black.withValues(alpha: 0.7),
),
];
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
@@ -74,49 +142,80 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
backgroundColor: colors.surface,
surfaceTintColor: colors.surface,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
children: [
_buildInstruction(),
const SizedBox(height: AppSpacing.lg),
_TimeCard(selectedTime: _selectedTime, onPickTime: _pickTime),
const SizedBox(height: AppSpacing.lg),
_YaoSelectionCard(
selectedYaos: _selectedYaos,
blinkAnimation: _blinkController,
onSelect: _onSelectYao,
onNeedTip: _showOrderTip,
),
const SizedBox(height: AppSpacing.xl),
AnimatedBuilder(
animation: _blinkController,
builder: (context, _) {
final base = colors.primary;
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: _allSelected && !_submitting ? _submitRun : null,
style: FilledButton.styleFrom(
backgroundColor: _allSelected
? base.withValues(
alpha: 0.6 + _blinkController.value * 0.4,
)
: base,
),
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.manualStartResolve),
body: Onboarding(
key: _onboardingKey,
steps: guideSteps,
onChanged: _onGuideStepChanged,
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
children: [
_buildInstruction(),
const SizedBox(height: AppSpacing.lg),
Container(
key: _timeCardKey,
child: Focus(
focusNode: _guideStep2Focus,
child: _TimeCard(
selectedTime: _selectedTime,
onPickTime: _pickTime,
),
);
},
),
],
),
),
const SizedBox(height: AppSpacing.lg),
Container(
key: _yaoCardKey,
child: Focus(
focusNode: _guideStep3Focus,
child: _YaoSelectionCard(
selectedYaos: _selectedYaos,
blinkAnimation: _blinkController,
onSelect: _onSelectYao,
onNeedTip: _showOrderTip,
),
),
),
const SizedBox(height: AppSpacing.xl),
Container(
key: _analyzeButtonKey,
child: Focus(
focusNode: _guideStep4Focus,
child: AnimatedBuilder(
animation: _blinkController,
builder: (context, _) {
final base = colors.primary;
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: _allSelected && !_submitting
? _submitRun
: null,
style: FilledButton.styleFrom(
backgroundColor: _allSelected
? base.withValues(
alpha: 0.6 + _blinkController.value * 0.4,
)
: base,
),
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(l10n.manualStartResolve),
),
);
},
),
),
),
],
),
),
),
);
@@ -126,26 +225,40 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
final l10n = AppLocalizations.of(context)!;
return DivinationInstructionCard(
text: l10n.manualYaoInstruction,
onTap: () {
showDialog<void>(
context: context,
builder: (_) {
return DivinationGuideDialog(
title: l10n.manualSelectYaoTitle,
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,
],
);
},
);
},
onTap: _showGuide,
);
}
void _showGuide() {
_scrollToGuideStep(0);
Future<void>.delayed(const Duration(milliseconds: 120), () {
if (!mounted) {
return;
}
_onboardingKey.currentState?.show();
});
}
void _onGuideStepChanged(int currentIndex) {
_scrollToGuideStep(currentIndex + 1);
}
void _scrollToGuideStep(int stepIndex) {
final GlobalKey? targetKey = switch (stepIndex) {
1 => _timeCardKey,
2 => _yaoCardKey,
3 => _analyzeButtonKey,
_ => null,
};
final targetContext = targetKey?.currentContext;
if (targetContext == null) {
return;
}
Scrollable.ensureVisible(
targetContext,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOut,
alignment: 0.12,
);
}
@@ -253,6 +366,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _selectedYaos.cast<YaoType>(),
runService: widget.runService,
divinationApi: widget.divinationApi,
onCompleted: widget.onCompleted,
),
),
@@ -337,7 +451,10 @@ class _YaoSelectionCard extends StatelessWidget {
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final rowNames = DivinationTerms.yaoNames.reversed.toList();
final rowNames = List<String>.generate(
6,
(i) => DivinationTerms.yaoName(l10n, i),
).reversed.toList();
return Card(
margin: EdgeInsets.zero,
color: colors.surface,