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
+54
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -181,6 +183,56 @@ class _EryaoAppState extends State<EryaoApp> {
}
}
Future<void> _handleHistorySessionDeleted(String threadId) async {
final user = _authBloc.state.user;
if (user == null) {
return;
}
final rollback = List<DivinationResultData>.from(_historyRecords);
if (!mounted) {
return;
}
setState(() {
_historyRecords = _historyRecords
.where((item) => item.threadId != threadId)
.toList(growable: false);
_loadedHistoryUserEmail = user.email;
});
unawaited(
_deleteHistorySessionRemote(
threadId: threadId,
userEmail: user.email,
rollback: rollback,
),
);
}
Future<void> _deleteHistorySessionRemote({
required String threadId,
required String userEmail,
required List<DivinationResultData> rollback,
}) async {
try {
await _divinationApi.deleteSession(threadId: threadId);
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to delete history session',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{'threadId': threadId},
);
if (!mounted) {
return;
}
setState(() {
_historyRecords = rollback;
_loadedHistoryUserEmail = userEmail;
});
}
}
List<DivinationResultData> _mergeAndSortHistory(
List<DivinationResultData> input,
) {
@@ -355,11 +407,13 @@ class _EryaoAppState extends State<EryaoApp> {
profileSettings: _profileSettings,
historyRecords: _historyRecords,
coinBalance: _creditsBalance,
divinationApi: _divinationApi,
onLocaleChanged: _handleInterfaceLanguageChanged,
onProfileSettingsChanged: _saveProfileSettings,
onSaveProfile: _saveProfile,
onUploadAvatar: _uploadAvatar,
onDivinationCompleted: _handleDivinationCompleted,
onDeleteHistorySession: _handleHistorySessionDeleted,
onLogout: _authBloc.logout,
);
}
@@ -15,8 +15,20 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) {
return l10n.toastCoinInsufficient;
case 'AGENT_SESSION_RUN_LIMIT_EXCEEDED':
return l10n.errorRunLimitExceeded;
case 'AGENT_RUNTIME_MODE_INVALID':
return l10n.errorRequestGeneric;
case 'AGENT_DIVINATION_PAYLOAD_REQUIRED':
return l10n.errorDivinationPayloadRequired;
case 'AGENT_SESSION_NOT_FOUND':
return l10n.errorRequestGeneric;
case 'AGENT_AUDIO_UNSUPPORTED_FORMAT':
return l10n.errorAudioUnsupportedFormat;
case 'AGENT_AUDIO_TOO_LARGE':
return l10n.errorAudioTooLarge;
case 'AGENT_AUDIO_EMPTY':
return l10n.errorAudioEmpty;
case 'AGENT_ASR_UNAVAILABLE':
return l10n.errorAsrUnavailable;
default:
break;
}
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
@@ -7,6 +8,7 @@ import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../models/divination_backend_models.dart';
import '../models/follow_up_message.dart';
import '../models/divination_params.dart';
import '../models/divination_result.dart';
@@ -55,6 +57,14 @@ class DivinationApi {
if (raw['role'] != 'assistant') {
continue;
}
final threadId = raw['threadId'];
if (threadId is! String || threadId.trim().isEmpty) {
_logger.warning(
message: 'Skip history item without threadId',
extra: <String, dynamic>{'messageId': raw['id']},
);
continue;
}
final agentOutputRaw = raw['agent_output'];
if (agentOutputRaw is! Map<String, dynamic>) {
continue;
@@ -75,6 +85,7 @@ class DivinationApi {
userId: userId,
);
final aggregate = DivinationRunAggregate(
threadId: threadId,
derived: derived,
signLevel: _asString(agentOutputRaw['sign_level']),
conclusion: _asStringList(agentOutputRaw['conclusion']),
@@ -98,6 +109,105 @@ class DivinationApi {
return records;
}
Future<List<FollowUpMessage>> getSessionMessages({
required String threadId,
}) async {
Map<String, dynamic> json;
try {
final response = await _apiClient.rawDio.get<Map<String, dynamic>>(
'/api/v1/agent/history',
queryParameters: <String, dynamic>{'threadId': threadId},
);
json = response.data ?? <String, dynamic>{};
} on DioException catch (error) {
throw _mapProblem(error);
}
final messagesRaw = json['messages'];
if (messagesRaw is! List<dynamic>) {
return const <FollowUpMessage>[];
}
final messages = <FollowUpMessage>[];
for (final raw in messagesRaw) {
if (raw is! Map<String, dynamic>) {
continue;
}
try {
final message = FollowUpMessage.fromJson(raw);
if (message.role != 'user' && message.role != 'assistant') {
continue;
}
messages.add(message);
} catch (error, stackTrace) {
_logger.warning(
message: 'Skip malformed follow-up history message',
extra: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
continue;
}
}
return messages;
}
Future<RunAcceptedData> enqueueFollowUp({
required String threadId,
required String runId,
required String question,
required DivinationResultData result,
}) async {
final payload = buildFollowUpRunPayload(
threadId: threadId,
runId: runId,
question: question,
result: result,
clientNow: DateTime.now(),
);
final json = await _apiClient.postJson('/api/v1/agent/runs', data: payload);
return RunAcceptedData.fromJson(json);
}
Future<void> deleteSession({required String threadId}) async {
await _apiClient.deleteNoContent('/api/v1/agent/sessions/$threadId');
}
Future<String> transcribeAudio(String audioPath) async {
final file = File(audioPath);
if (!await file.exists()) {
throw ApiProblem(
status: 400,
title: 'Audio file missing',
detail: 'Audio file does not exist',
);
}
try {
final response = await _apiClient.rawDio.post<Map<String, dynamic>>(
'/api/v1/agent/transcribe',
data: FormData.fromMap(<String, dynamic>{
'audio': await MultipartFile.fromFile(
audioPath,
filename: 'follow_up.wav',
contentType: DioMediaType('audio', 'wav'),
),
}),
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw const FormatException('Invalid transcribe response');
}
final transcript = payload['transcript'];
if (transcript is! String) {
throw const FormatException('Invalid transcribe response');
}
return transcript;
} on DioException catch (error) {
throw _mapProblem(error);
}
}
Stream<Map<String, dynamic>> streamEvents({
required String threadId,
required String runId,
@@ -107,7 +217,10 @@ class DivinationApi {
response = await _apiClient.rawDio.get<ResponseBody>(
'/api/v1/agent/runs/$threadId/events',
queryParameters: <String, dynamic>{'runId': runId},
options: Options(responseType: ResponseType.stream),
options: Options(
responseType: ResponseType.stream,
receiveTimeout: null,
),
);
} on DioException catch (error, stackTrace) {
_logger.error(
@@ -260,6 +373,45 @@ Map<String, dynamic> buildDivinationRunPayload({
};
}
Map<String, dynamic> buildFollowUpRunPayload({
required String threadId,
required String runId,
required String question,
required DivinationResultData result,
required DateTime clientNow,
}) {
final yaoStates = result.yaoLines
.map((line) => line.type)
.toList(growable: false);
return <String, dynamic>{
'threadId': threadId,
'runId': runId,
'state': <String, dynamic>{},
'messages': [
{'id': 'msg_${runId}_user_0', 'role': 'user', 'content': question},
],
'tools': const <Map<String, dynamic>>[],
'context': const <Map<String, dynamic>>[],
'forwardedProps': {
'runtime_mode': 'follow_up',
'client_time': {
'device_timezone': 'Asia/Shanghai',
'client_now_iso': _toRfc3339Utc(clientNow),
'client_epoch_ms': clientNow.millisecondsSinceEpoch,
},
'divinationPayload': {
'divinationMethod': result.params.method == DivinationMethod.manual
? '手动起卦'
: '自动起卦',
'questionType': _questionTypeToText(result.params.questionType),
'question': result.params.question,
'divinationTimeIso': _toRfc3339Utc(result.params.divinationTime),
'yaoLines': yaoStates.map(_yaoTypeToText).toList(growable: false),
},
},
};
}
String _toRfc3339Utc(DateTime value) {
return value.toUtc().toIso8601String();
}
@@ -52,6 +52,7 @@ class RunAcceptedData {
class DivinationRunAggregate {
const DivinationRunAggregate({
this.threadId,
required this.derived,
required this.signLevel,
required this.conclusion,
@@ -62,6 +63,7 @@ class DivinationRunAggregate {
});
final DerivedDivinationData derived;
final String? threadId;
final String signLevel;
final List<String> conclusion;
final List<String> focusPoints;
@@ -72,6 +74,7 @@ class DivinationRunAggregate {
DivinationResultData toViewData(DivinationParams params) {
return DivinationResultData(
params: params,
threadId: threadId,
binaryCode: derived.binaryCode,
changedBinaryCode: derived.changedBinaryCode,
guaName: derived.guaName,
@@ -2,6 +2,7 @@ import 'divination_params.dart';
class DivinationResultData {
const DivinationResultData({
this.threadId,
required this.params,
required this.binaryCode,
required this.changedBinaryCode,
@@ -22,6 +23,7 @@ class DivinationResultData {
});
final DivinationParams params;
final String? threadId;
final String binaryCode;
final String changedBinaryCode;
final String guaName;
@@ -44,6 +46,7 @@ class DivinationResultData {
Map<String, dynamic> toJson() {
return <String, dynamic>{
'params': params.toPayload(),
'threadId': threadId,
'binaryCode': binaryCode,
'changedBinaryCode': changedBinaryCode,
'guaName': guaName,
@@ -81,6 +84,7 @@ class DivinationResultData {
return DivinationResultData(
params: DivinationParams.fromPayload(paramsRaw),
threadId: json['threadId'] as String?,
binaryCode: _requiredString(json, 'binaryCode'),
changedBinaryCode: _requiredString(json, 'changedBinaryCode'),
guaName: _requiredString(json, 'guaName'),
@@ -0,0 +1,52 @@
class FollowUpMessage {
const FollowUpMessage({
required this.id,
required this.seq,
required this.role,
required this.content,
required this.timestamp,
});
final String id;
final int seq;
final String role;
final String content;
final DateTime timestamp;
factory FollowUpMessage.fromJson(Map<String, dynamic> json) {
return FollowUpMessage(
id: _requiredString(json, 'id'),
seq: _requiredInt(json, 'seq'),
role: _requiredString(json, 'role'),
content: _requiredStringAllowEmpty(json, 'content'),
timestamp: DateTime.parse(_requiredString(json, 'timestamp')).toLocal(),
);
}
}
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;
}
String _requiredStringAllowEmpty(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! String) {
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');
}
@@ -24,7 +24,7 @@ class DivinationRunService {
}) async {
final threadId = _uuidV4();
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
await _api.enqueueRun(
final accepted = await _api.enqueueRun(
params: params,
yaoStates: yaoStates,
threadId: threadId,
@@ -103,6 +103,7 @@ class DivinationRunService {
}
return DivinationRunAggregate(
threadId: accepted.threadId,
derived: derived,
signLevel: signLevel,
conclusion: conclusion,
@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:record/record.dart';
abstract class VoiceRecorder {
Future<void> start();
Future<String?> stop();
Future<void> dispose();
}
class RecordVoiceRecorder implements VoiceRecorder {
RecordVoiceRecorder({AudioRecorder? recorder})
: _recorder = recorder ?? AudioRecorder();
final AudioRecorder _recorder;
String? _currentPath;
@override
Future<void> start() async {
bool hasPermission;
try {
hasPermission = await _recorder.hasPermission();
} on MissingPluginException {
throw StateError('录音能力不可用');
}
if (!hasPermission) {
throw StateError('录音权限未授权');
}
final fileName =
'voice_${DateTime.now().millisecondsSinceEpoch}_${DateTime.now().microsecond}.wav';
final path = '${Directory.systemTemp.path}/$fileName';
_currentPath = path;
try {
await _recorder.start(
const RecordConfig(
encoder: AudioEncoder.wav,
sampleRate: 16000,
numChannels: 1,
),
path: path,
);
} on MissingPluginException {
throw StateError('录音能力不可用');
}
}
@override
Future<String?> stop() async {
try {
final stoppedPath = await _recorder.stop();
return stoppedPath ?? _currentPath;
} on MissingPluginException {
throw StateError('录音能力不可用');
}
}
@override
Future<void> dispose() async {
await _recorder.dispose();
}
}
@@ -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,
@@ -1,8 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../core/auth/session_store.dart';
import '../../../divination/presentation/screens/divination_screen.dart';
import '../../../divination/presentation/screens/divination_result_screen.dart';
import '../../../divination/data/apis/divination_api.dart';
import '../../../divination/data/models/divination_params.dart';
import '../../../divination/data/models/divination_result.dart';
import '../../../settings/data/models/profile_settings.dart';
@@ -11,6 +14,7 @@ import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/bottom_nav_bar.dart';
import '../../../../shared/widgets/divination/divination_summary_card.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
@@ -23,11 +27,13 @@ class HomeScreen extends StatefulWidget {
required this.profileSettings,
required this.historyRecords,
required this.coinBalance,
required this.divinationApi,
required this.onLocaleChanged,
required this.onProfileSettingsChanged,
required this.onSaveProfile,
required this.onUploadAvatar,
required this.onDivinationCompleted,
required this.onDeleteHistorySession,
required this.onLogout,
});
@@ -37,6 +43,7 @@ class HomeScreen extends StatefulWidget {
final ProfileSettingsV1 profileSettings;
final List<DivinationResultData> historyRecords;
final int coinBalance;
final DivinationApi divinationApi;
final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@@ -45,6 +52,7 @@ class HomeScreen extends StatefulWidget {
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function(DivinationResultData result)
onDivinationCompleted;
final Future<void> Function(String threadId) onDeleteHistorySession;
final Future<void> Function() onLogout;
@override
@@ -94,7 +102,9 @@ class _HomeScreenState extends State<HomeScreen> {
historyItems: historyItems,
sessionStore: widget.sessionStore,
userId: widget.account,
divinationApi: widget.divinationApi,
onDivinationCompleted: widget.onDivinationCompleted,
onDeleteHistorySession: widget.onDeleteHistorySession,
allowVibration: widget.profileSettings.notification.allowVibration,
),
_ProfileTab(
@@ -138,15 +148,19 @@ class _HomeTab extends StatelessWidget {
required this.historyItems,
required this.sessionStore,
required this.userId,
required this.divinationApi,
required this.onDivinationCompleted,
required this.onDeleteHistorySession,
required this.allowVibration,
});
final List<DivinationResultData> historyItems;
final SessionStore sessionStore;
final String userId;
final DivinationApi divinationApi;
final Future<void> Function(DivinationResultData result)
onDivinationCompleted;
final Future<void> Function(String threadId) onDeleteHistorySession;
final bool allowVibration;
@override
@@ -253,9 +267,29 @@ class _HomeTab extends StatelessWidget {
const SizedBox(height: AppSpacing.xl),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Text(
l10n.historyTitle,
style: Theme.of(context).textTheme.titleMedium,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.historyTitle,
style: Theme.of(context).textTheme.titleMedium,
),
if (historyItems.length > 4)
TextButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationHistoryScreen(
initialItems: historyItems,
divinationApi: divinationApi,
onDeleteHistorySession: onDeleteHistorySession,
),
),
);
},
child: Text(l10n.more),
),
],
),
),
const SizedBox(height: AppSpacing.md),
@@ -279,22 +313,66 @@ class _HomeTab extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: historyItems.take(4).map((item) {
final threadId = item.threadId;
return Padding(
padding: const EdgeInsets.only(
left: AppSpacing.md,
right: AppSpacing.md,
bottom: AppSpacing.md,
),
child: _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(data: item),
child: threadId == null
? _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: item,
divinationApi: null,
enableIntroTransition: false,
),
),
);
},
)
: Dismissible(
key: ValueKey<String>('home-history-$threadId'),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
),
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(
AppRadius.md,
),
),
child: Icon(
Icons.delete_outline,
color: colors.onErrorContainer,
),
),
confirmDismiss: (_) async => true,
onDismissed: (_) {
unawaited(onDeleteHistorySession(threadId));
},
child: _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: item,
divinationApi: divinationApi,
enableIntroTransition: false,
),
),
);
},
),
),
);
},
),
);
}).toList(),
),
@@ -305,6 +383,118 @@ class _HomeTab extends StatelessWidget {
}
}
class DivinationHistoryScreen extends StatefulWidget {
const DivinationHistoryScreen({
super.key,
required this.initialItems,
required this.divinationApi,
required this.onDeleteHistorySession,
});
final List<DivinationResultData> initialItems;
final DivinationApi divinationApi;
final Future<void> Function(String threadId) onDeleteHistorySession;
@override
State<DivinationHistoryScreen> createState() =>
_DivinationHistoryScreenState();
}
class _DivinationHistoryScreenState extends State<DivinationHistoryScreen> {
late List<DivinationResultData> _items;
@override
void initState() {
super.initState();
_items = List<DivinationResultData>.from(
widget.initialItems,
growable: true,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: Text(l10n.historyTitle)),
backgroundColor: colors.surfaceContainerLow,
body: _items.isEmpty
? Center(child: Text(l10n.noRecords))
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.md),
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
final threadId = item.threadId;
return Padding(
padding: const EdgeInsets.only(
left: AppSpacing.md,
right: AppSpacing.md,
bottom: AppSpacing.md,
),
child: threadId == null
? _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: item,
divinationApi: null,
enableIntroTransition: false,
),
),
);
},
)
: Dismissible(
key: ValueKey<String>('history-$threadId'),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
),
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(
Icons.delete_outline,
color: colors.onErrorContainer,
),
),
confirmDismiss: (_) async => true,
onDismissed: (_) {
setState(() {
_items.removeAt(index);
});
unawaited(widget.onDeleteHistorySession(threadId));
},
child: _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: item,
divinationApi: widget.divinationApi,
enableIntroTransition: false,
),
),
);
},
),
),
);
},
),
);
}
}
class _ProfileTab extends StatelessWidget {
const _ProfileTab({
required this.account,
@@ -355,9 +545,15 @@ class _HistoryCard extends StatelessWidget {
final palette = Theme.of(context).extension<AppColorPalette>()!;
final categoryLabel = switch (item.params.questionType) {
QuestionType.career || QuestionType.study => l10n.categoryCareer,
QuestionType.love => l10n.categoryLove,
_ => l10n.categoryMoney,
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,
};
final categoryStyle = switch (item.params.questionType) {
@@ -390,177 +586,39 @@ class _HistoryCard extends StatelessWidget {
? (colors.errorContainer, colors.onErrorContainer)
: (palette.historyGrayBg, palette.historyGrayText);
return Material(
color: colors.surface.withValues(alpha: 0),
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.md),
return SizedBox(
width: double.infinity,
child: DivinationSummaryCard(
question: item.params.question,
onTap: onTap,
child: Card(
margin: EdgeInsets.zero,
color: colors.surface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.params.question,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
_Tag(
label: categoryLabel,
background: categoryStyle.$1,
foreground: categoryStyle.$2,
),
_Tag(
label: item.guaName,
background: palette.historyBlueBg,
foreground: palette.historyBlueText,
),
_Tag(
label: signLabel,
background: signStyle.$1,
foreground: signStyle.$2,
),
],
),
],
),
),
leading: Icon(
Icons.auto_awesome,
color: palette.historyBlueText,
size: 22,
),
leadingBackgroundColor: palette.historyBlueBg,
tags: [
DivinationSummaryTagData(
label: categoryLabel,
background: categoryStyle.$1,
foreground: categoryStyle.$2,
),
DivinationSummaryTagData(
label: item.guaName,
background: palette.historyBlueBg,
foreground: palette.historyBlueText,
),
DivinationSummaryTagData(
label: signLabel,
background: signStyle.$1,
foreground: signStyle.$2,
),
],
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag({
required this.label,
required this.background,
required this.foreground,
});
final String label;
final Color background;
final Color foreground;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: foreground),
),
);
}
}
class _HistoryRecordsScreen extends StatelessWidget {
const _HistoryRecordsScreen({
required this.records,
required this.onOpenResult,
});
final List<DivinationResultData> records;
final ValueChanged<DivinationResultData> onOpenResult;
String _itemKey(DivinationResultData item) {
return '${item.guaName}_${item.binaryCode}_${item.params.question}';
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: Text(l10n.historyTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: records.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.noRecords,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(l10n.noRecordsSubtitle),
],
),
)
: ListView.separated(
padding: EdgeInsets.only(
top: AppSpacing.md,
bottom: AppSpacing.md,
),
itemBuilder: (context, index) {
final item = records[index];
return Dismissible(
key: ValueKey(_itemKey(item)),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: AppSpacing.lg),
decoration: BoxDecoration(
color: colors.error,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(
Icons.delete_outline_rounded,
color: colors.onError,
),
),
confirmDismiss: (direction) async {
return true;
},
onDismissed: (direction) {
// TODO: implement delete
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
),
child: _HistoryCard(
item: item,
onTap: () => onOpenResult(item),
),
),
);
},
separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.md),
itemCount: records.length,
),
);
}
}
class _WelcomeDialog extends StatefulWidget {
const _WelcomeDialog({required this.onDone});
+73 -20
View File
@@ -52,7 +52,7 @@
"noRecordsSubtitle": "You have not saved any records",
"homeTab": "Home",
"profileTab": "Me",
"notify": "Notifications",
"notify": "Message Notifications",
"featurePending": "This feature is not connected yet",
"logout": "Logout",
"defaultUserName": "User",
@@ -69,11 +69,8 @@
"warningTitle": "Important Notice",
"warningBody": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.",
"scrollHint": "Scroll down to read all",
"understood": "I Understand",
"understood": "Got It",
"readAllFirst": "Please read all first",
"categoryCareer": "Career/Study",
"categoryLove": "Love/Marriage",
"categoryMoney": "Wealth/Investment",
"signBest": "Supremely Auspicious",
"signGood": "Auspicious",
"signNormal": "Cautionary",
@@ -84,7 +81,7 @@
"settingsSectionQuickAccess": "Primary Menu",
"settingsSectionAccount": "Account",
"settingsSectionPrivacy": "Privacy",
"settingsSectionNotification": "Notifications",
"settingsSectionNotification": "Notification Settings",
"settingsInterfaceLanguage": "Interface Language",
"settingsAiLanguage": "AI Response Language",
"settingsNotificationAllow": "Allow Notifications",
@@ -195,7 +192,7 @@
"privacyContent": "Dear user,\nWelcome to MeiYao Divination. We understand that your privacy is critically important, and we take the protection of your personal information seriously. This policy explains how we collect, use, store, and share your information, as well as how you can access and manage it.\n\n1. Information We Collect\nWe may collect information you actively provide, including account registration details, profile information, and divination-related inputs and results. We may also collect device information and log data automatically to support security, compatibility, and service improvement.\n\n2. How We Use Information\nWe use your information to provide and improve divination services, manage accounts, protect account security, send service notifications, and respond to feedback or support requests.\n\n3. Storage of Information\nInformation collected in China is generally stored on servers located within China. We only retain personal information for as long as needed to meet legal obligations and service purposes, after which it will be deleted or anonymized.\n\n4. Sharing of Information\nWe do not share personal information with third parties except when you give clear consent, when we work with service providers under proper safeguards, when required by law, or in connection with mergers, acquisitions, restructuring, or bankruptcy.\n\n5. Your Rights\nYou may request access to, correction of, or deletion of your personal information, and you may request account cancellation. Please note that cancelling an account may make related data unrecoverable.\n\n6. Protection of Minors\nIf you are under the age of 14, please use the service under the guidance of a parent or legal guardian and obtain their prior consent.\n\n7. Security of Personal Information\nWe use reasonable organizational and technical measures, including encryption, access control, auditing, and monitoring, to protect personal information from unauthorized access, disclosure, use, modification, damage, or loss.\n\n8. Policy Updates\nWe may update this privacy policy from time to time because of legal, business, or service changes. Material changes will be communicated in a prominent way.\n\n9. Contact Us\nIf you have questions or suggestions about this privacy policy, please contact us at xuyunlong@xunmee.com.\n\nXunmee Technology (Shenzhen) Co., Ltd.\nJune 1, 2025",
"termsContent": "Chapter 1 General\nWelcome to MeiYao Divination. The app is developed, operated, and maintained by Xunmee Technology (Shenzhen) Co., Ltd. By downloading, installing, registering, signing in, or otherwise using the app, you confirm that you have read, understood, and accepted these terms.\n\nChapter 2 Service Description\nMeiYao Divination provides AI-based divination interpretation services, including manual and automatic casting flows. Service interruption caused by maintenance, failure, force majeure, or other reasonable causes does not constitute a breach.\n\nChapter 3 User Accounts and Information Security\nUsers must have proper legal capacity, provide true and valid registration information, and keep account credentials secure. Necessary personal information may be collected and processed according to the privacy policy.\n\nChapter 4 Intellectual Property\nAll content of MeiYao Divination, including software, text, images, audio, video, charts, trademarks, and domains, is protected by law. Reverse engineering, decompilation, disassembly, or any attempt to obtain source code without written permission is strictly prohibited.\n\nChapter 5 User Conduct\nUsers may not publish unlawful content, infringe on the rights of others, disrupt normal service operation, or conduct unauthorized commercial activity. The app may warn, restrict, suspend, or ban accounts that violate these rules and may pursue legal liability.\n\nChapter 6 Liability and Disclaimer\nUsers are responsible for losses caused by their own violations of these terms. AI-generated divination results are for reference only and must not be treated as the sole basis for real-world decisions. Users assume the related risks.\n\nChapter 7 Dispute Resolution\nThese terms are governed by the laws of the People's Republic of China. Disputes should first be resolved through friendly consultation. If consultation fails, either party may bring the dispute to the competent court where Xunmee Technology is registered.\n\nChapter 8 Miscellaneous\nNotices may be delivered through contact information, system messages, internal messages, or announcements. If you need to contact Xunmee Technology, please email xuyunlong@xunmee.com.\n\nXunmee Technology (Shenzhen) Co., Ltd.\nJune 1, 2025",
"disclaimerContent": "Placeholder content for disclaimer.",
"toastLabelInfo": "Info",
"toastLabelInfo": "Tip",
"toastLabelSuccess": "Success",
"toastLabelWarning": "Warning",
"toastLabelError": "Error",
@@ -209,12 +206,12 @@
"errorDivinationPayloadRequired": "Missing divination payload. Please cast again.",
"divinationScreenTitle": "Cast Hexagram",
"divinationSelectMethod": "Select divination method",
"divinationManualMethod": "Manual",
"divinationAutoMethod": "Auto",
"divinationManualMethod": "Manual Casting",
"divinationAutoMethod": "Auto Casting",
"divinationQuestionTypePrompt": "Select question type",
"divinationQuestionInputPrompt": "Enter your question",
"divinationQuestionInputPrompt": "Please enter your question",
"divinationQuestionInputHint": "Describe your question in detail for more accurate reading",
"divinationStartButton": "Start Casting",
"divinationStartButton": "Start Divination",
"divinationCoinBalance": "Available coins: {balance}",
"@divinationCoinBalance": {
"placeholders": {
@@ -233,6 +230,37 @@
"divinationManualGuideStep1": "Left: Pattern side. Right: Inscription side. Prepare three identical coins or similar tokens.",
"divinationManualGuideStep2": "Hold the coins in both hands, think about your question, then toss them onto a table. Record how many inscription sides and pattern sides appear.",
"divinationManualGuideStep3": "Record each result by whether the inscription side or pattern side faces up. Repeat 6 times, recording from bottom to top.",
"autoGuideStep1Title": "Auto Casting",
"autoGuideStep1Body": "No coins needed. Simply shake your phone or tap the button to cast. Each shake automatically rotates three coins and shows the result.",
"autoGuideStep2Title": "Start Casting",
"autoGuideStep2Body": "Tap the \"Start Casting\" button or shake your phone. The coins will rotate automatically for 3 seconds.",
"autoGuideStep3Title": "Auto Recording",
"autoGuideStep3Body": "Each shake automatically records the corresponding yao position. Repeat 6 times to complete all six yao.",
"autoGuideStep4Title": "Analyze Hexagram",
"autoGuideStep4Body": "After 6 shakes, the \"Analyze Hexagram\" button will blink. Tap it to view the hexagram interpretation.",
"manualGuideStep1Title": "Manual Casting",
"manualGuideStep1Body": "Prepare three identical coins. Record one yao at a time, from bottom to top, until all six yao are complete.",
"manualGuideStep2Title": "Confirm Time",
"manualGuideStep2Body": "Check the casting time first. Tap \"Modify\" on the right if you need to adjust it.",
"manualGuideStep3Title": "Fill Six Yao in Order",
"manualGuideStep3Body": "Start from the first yao and select one row at a time. The next row stays locked until the current row is completed.",
"manualGuideStep4Title": "Start Analysis",
"manualGuideStep4Body": "After all six yao are filled, the \"Analyze Hexagram\" button will blink. Tap it to start interpretation.",
"yaoNameFirst": "First Yao",
"yaoNameSecond": "Second Yao",
"yaoNameThird": "Third Yao",
"yaoNameFourth": "Fourth Yao",
"yaoNameFifth": "Fifth Yao",
"yaoNameTop": "Top Yao",
"yaoYin": "Yin",
"yaoYang": "Yang",
"yaoYoungYin": "Young Yin",
"yaoYoungYang": "Young Yang",
"yaoOldYin": "Old Yin",
"yaoOldYang": "Old Yang",
"yaoMovingSuffix": "(moving)",
"autoCoinFaceZi": "Inscription",
"autoCoinFaceHua": "Pattern",
"divinationIAcknowledge": "I Understand",
"divinationClose": "Close",
"divinationModify": "Modify",
@@ -259,7 +287,7 @@
}
}
},
"divinationCostDialogConfirm": "Start",
"divinationCostDialogConfirm": "Confirm Analysis",
"toastContentCopied": "Content copied",
"toastContentCopiedWithTitle": "{title} copied",
"@toastContentCopiedWithTitle": {
@@ -278,18 +306,43 @@
"resultAnalysis": "Analysis",
"resultSuggestion": "Suggestion",
"resultDivinationInfo": "Divination Info",
"resultDivinationTime": "Time",
"resultDivinationTime": "Casting Time",
"resultDivinationMethod": "Method",
"resultQuestionType": "Type",
"resultQuestion": "Question",
"resultAutoMethod": "Auto",
"resultManualMethod": "Manual",
"resultAutoMethod": "Auto Casting",
"resultManualMethod": "Manual Casting",
"signTypeShangShang": "Supremely Auspicious",
"signTypeZhongShang": "Auspicious",
"signTypeZhongXia": "Cautionary",
"signTypeXiaXia": "Inauspicious",
"resultCopy": "Copy",
"resultWarning": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice.",
"resultWarning": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.",
"followUpEntryHint": "You can ask one follow-up question for this reading",
"followUpEntryAction": "Ask Follow-up",
"followUpViewHistory": "View history",
"followUpScreenTitle": "Continue Follow-up",
"followUpEmpty": "No messages yet",
"followUpQuotaUsed": "Follow-up limit reached for this session",
"followUpInputHint": "Type your follow-up question",
"followUpHoldToSpeak": "Hold to speak",
"followUpRecording": "Release to send",
"followUpRecordingHint": "Slide up to cancel",
"followUpTranscribing": "Transcribing voice...",
"followUpGenerating": "Generating reply...",
"followUpStepWorker": "Analyzing divination and generating reply",
"followUpStepGeneric": "Processing: {stepName}",
"@followUpStepGeneric": {
"placeholders": {
"stepName": {
"type": "String"
}
}
},
"errorAudioUnsupportedFormat": "Unsupported audio format. Please use wav",
"errorAudioTooLarge": "Audio file too large. Please record a shorter clip",
"errorAudioEmpty": "No valid voice detected. Please try again",
"errorAsrUnavailable": "Voice transcription service is unavailable now",
"transitionPreparing": "Deriving...",
"transitionDeriving": "Analyzing...",
"transitionDone": "Complete\nTap to view",
@@ -323,7 +376,7 @@
"manualScreenTitle": "Manual Casting",
"manualSelectTime": "Select time",
"manualSpecifyYaoCombo": "Select coin combination",
"manualStartResolve": "Start Analysis",
"manualStartResolve": "Start Interpretation",
"manualSelectYaoTitle": "Select Yao",
"manualYaoInstruction": "Tap to view casting method and coin combination guide",
"manualYaoTipTitle": "Tip",
@@ -334,11 +387,11 @@
"autoCoinDivination": "Coin Casting",
"autoHexagramForming": "Forming Hexagram",
"autoShakeInstruction": "Tap to view auto casting method",
"autoStartShake": "Start",
"autoStartShake": "Start Casting",
"autoContinueShake": "Continue",
"autoFinishShake": "Finish",
"autoShaking": "Casting...",
"autoStartResolve": "Start Analysis",
"autoStartResolve": "Start Interpretation",
"autoShakeCountdown": "Stopping in {seconds}s",
"@autoShakeCountdown": {
"placeholders": {
@@ -368,7 +421,7 @@
"autoGuideTitle": "Auto Casting Tutorial",
"autoGuideInstruction": "Shake your phone or tap the button, cast 6 times to form a complete hexagram.",
"dateTab": "Date",
"timeTab": "Time",
"timeTab": "Time Picker",
"confirm": "Confirm",
"cancel": "Cancel",
"autoSelectTime": "Select Time",
+294 -18
View File
@@ -440,24 +440,6 @@ abstract class AppLocalizations {
/// **'请先阅读完整内容'**
String get readAllFirst;
/// No description provided for @categoryCareer.
///
/// In zh, this message translates to:
/// **'事业学业'**
String get categoryCareer;
/// No description provided for @categoryLove.
///
/// In zh, this message translates to:
/// **'情感婚姻'**
String get categoryLove;
/// No description provided for @categoryMoney.
///
/// In zh, this message translates to:
/// **'财富投资'**
String get categoryMoney;
/// No description provided for @signBest.
///
/// In zh, this message translates to:
@@ -1154,6 +1136,192 @@ abstract class AppLocalizations {
/// **'记录每次结果,按照「字面在上还是花面在上」记录。重复六次,从下往上记录。'**
String get divinationManualGuideStep3;
/// No description provided for @autoGuideStep1Title.
///
/// In zh, this message translates to:
/// **'自动起卦'**
String get autoGuideStep1Title;
/// No description provided for @autoGuideStep1Body.
///
/// In zh, this message translates to:
/// **'无需铜钱,摇动手机或点击按钮即可完成起卦。每次摇动后,三枚铜币将自动旋转并显示结果。'**
String get autoGuideStep1Body;
/// No description provided for @autoGuideStep2Title.
///
/// In zh, this message translates to:
/// **'开始摇卦'**
String get autoGuideStep2Title;
/// No description provided for @autoGuideStep2Body.
///
/// In zh, this message translates to:
/// **'点击「开始摇卦」按钮,或直接摇动手机。铜币将自动旋转3秒后停止。'**
String get autoGuideStep2Body;
/// No description provided for @autoGuideStep3Title.
///
/// In zh, this message translates to:
/// **'自动记录'**
String get autoGuideStep3Title;
/// No description provided for @autoGuideStep3Body.
///
/// In zh, this message translates to:
/// **'每摇一次,对应的爻位会自动记录。重复六次,即可完成全部六爻。'**
String get autoGuideStep3Body;
/// No description provided for @autoGuideStep4Title.
///
/// In zh, this message translates to:
/// **'分析卦象'**
String get autoGuideStep4Title;
/// No description provided for @autoGuideStep4Body.
///
/// In zh, this message translates to:
/// **'六次完成后,「分析卦象」按钮将闪烁提示。点击即可查看卦象解读。'**
String get autoGuideStep4Body;
/// No description provided for @manualGuideStep1Title.
///
/// In zh, this message translates to:
/// **'手动起卦'**
String get manualGuideStep1Title;
/// No description provided for @manualGuideStep1Body.
///
/// In zh, this message translates to:
/// **'准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。'**
String get manualGuideStep1Body;
/// No description provided for @manualGuideStep2Title.
///
/// In zh, this message translates to:
/// **'确认时间'**
String get manualGuideStep2Title;
/// No description provided for @manualGuideStep2Body.
///
/// In zh, this message translates to:
/// **'先确认起卦时间。如需调整,点击右侧「修改」。'**
String get manualGuideStep2Body;
/// No description provided for @manualGuideStep3Title.
///
/// In zh, this message translates to:
/// **'依次录入六爻'**
String get manualGuideStep3Title;
/// No description provided for @manualGuideStep3Body.
///
/// In zh, this message translates to:
/// **'从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。'**
String get manualGuideStep3Body;
/// No description provided for @manualGuideStep4Title.
///
/// In zh, this message translates to:
/// **'开始分析'**
String get manualGuideStep4Title;
/// No description provided for @manualGuideStep4Body.
///
/// In zh, this message translates to:
/// **'六爻都填完后,下方「分析卦象」按钮会闪烁提示,点击即可解卦。'**
String get manualGuideStep4Body;
/// No description provided for @yaoNameFirst.
///
/// In zh, this message translates to:
/// **'初爻'**
String get yaoNameFirst;
/// No description provided for @yaoNameSecond.
///
/// In zh, this message translates to:
/// **'二爻'**
String get yaoNameSecond;
/// No description provided for @yaoNameThird.
///
/// In zh, this message translates to:
/// **'三爻'**
String get yaoNameThird;
/// No description provided for @yaoNameFourth.
///
/// In zh, this message translates to:
/// **'四爻'**
String get yaoNameFourth;
/// No description provided for @yaoNameFifth.
///
/// In zh, this message translates to:
/// **'五爻'**
String get yaoNameFifth;
/// No description provided for @yaoNameTop.
///
/// In zh, this message translates to:
/// **'上爻'**
String get yaoNameTop;
/// No description provided for @yaoYin.
///
/// In zh, this message translates to:
/// **'阴'**
String get yaoYin;
/// No description provided for @yaoYang.
///
/// In zh, this message translates to:
/// **'阳'**
String get yaoYang;
/// No description provided for @yaoYoungYin.
///
/// In zh, this message translates to:
/// **'少阴'**
String get yaoYoungYin;
/// No description provided for @yaoYoungYang.
///
/// In zh, this message translates to:
/// **'少阳'**
String get yaoYoungYang;
/// No description provided for @yaoOldYin.
///
/// In zh, this message translates to:
/// **'老阴'**
String get yaoOldYin;
/// No description provided for @yaoOldYang.
///
/// In zh, this message translates to:
/// **'老阳'**
String get yaoOldYang;
/// No description provided for @yaoMovingSuffix.
///
/// In zh, this message translates to:
/// **'(变)'**
String get yaoMovingSuffix;
/// No description provided for @autoCoinFaceZi.
///
/// In zh, this message translates to:
/// **'字'**
String get autoCoinFaceZi;
/// No description provided for @autoCoinFaceHua.
///
/// In zh, this message translates to:
/// **'花'**
String get autoCoinFaceHua;
/// No description provided for @divinationIAcknowledge.
///
/// In zh, this message translates to:
@@ -1394,6 +1562,114 @@ abstract class AppLocalizations {
/// **'卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。'**
String get resultWarning;
/// No description provided for @followUpEntryHint.
///
/// In zh, this message translates to:
/// **'可针对本次解卦继续追问 1 次'**
String get followUpEntryHint;
/// No description provided for @followUpEntryAction.
///
/// In zh, this message translates to:
/// **'追问'**
String get followUpEntryAction;
/// No description provided for @followUpViewHistory.
///
/// In zh, this message translates to:
/// **'查看历史记录'**
String get followUpViewHistory;
/// No description provided for @followUpScreenTitle.
///
/// In zh, this message translates to:
/// **'继续追问'**
String get followUpScreenTitle;
/// No description provided for @followUpEmpty.
///
/// In zh, this message translates to:
/// **'暂无消息'**
String get followUpEmpty;
/// No description provided for @followUpQuotaUsed.
///
/// In zh, this message translates to:
/// **'本次会话追问次数已用完'**
String get followUpQuotaUsed;
/// No description provided for @followUpInputHint.
///
/// In zh, this message translates to:
/// **'输入你想继续追问的问题'**
String get followUpInputHint;
/// No description provided for @followUpHoldToSpeak.
///
/// In zh, this message translates to:
/// **'按住说话'**
String get followUpHoldToSpeak;
/// No description provided for @followUpRecording.
///
/// In zh, this message translates to:
/// **'松开发送'**
String get followUpRecording;
/// No description provided for @followUpRecordingHint.
///
/// In zh, this message translates to:
/// **'上滑取消'**
String get followUpRecordingHint;
/// No description provided for @followUpTranscribing.
///
/// In zh, this message translates to:
/// **'语音转文字中...'**
String get followUpTranscribing;
/// No description provided for @followUpGenerating.
///
/// In zh, this message translates to:
/// **'正在生成回复...'**
String get followUpGenerating;
/// No description provided for @followUpStepWorker.
///
/// In zh, this message translates to:
/// **'正在分析卦象并生成回复'**
String get followUpStepWorker;
/// No description provided for @followUpStepGeneric.
///
/// In zh, this message translates to:
/// **'正在处理:{stepName}'**
String followUpStepGeneric(String stepName);
/// No description provided for @errorAudioUnsupportedFormat.
///
/// In zh, this message translates to:
/// **'音频格式不支持,请使用 wav'**
String get errorAudioUnsupportedFormat;
/// No description provided for @errorAudioTooLarge.
///
/// In zh, this message translates to:
/// **'音频文件过大,请缩短录音时长'**
String get errorAudioTooLarge;
/// No description provided for @errorAudioEmpty.
///
/// In zh, this message translates to:
/// **'未检测到有效语音,请重试'**
String get errorAudioEmpty;
/// No description provided for @errorAsrUnavailable.
///
/// In zh, this message translates to:
/// **'语音识别服务暂不可用,请稍后重试'**
String get errorAsrUnavailable;
/// No description provided for @transitionPreparing.
///
/// In zh, this message translates to:
+178 -26
View File
@@ -128,7 +128,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get profileTab => 'Me';
@override
String get notify => 'Notifications';
String get notify => 'Message Notifications';
@override
String get featurePending => 'This feature is not connected yet';
@@ -184,20 +184,11 @@ class AppLocalizationsEn extends AppLocalizations {
String get scrollHint => 'Scroll down to read all';
@override
String get understood => 'I Understand';
String get understood => 'Got It';
@override
String get readAllFirst => 'Please read all first';
@override
String get categoryCareer => 'Career/Study';
@override
String get categoryLove => 'Love/Marriage';
@override
String get categoryMoney => 'Wealth/Investment';
@override
String get signBest => 'Supremely Auspicious';
@@ -229,7 +220,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsSectionPrivacy => 'Privacy';
@override
String get settingsSectionNotification => 'Notifications';
String get settingsSectionNotification => 'Notification Settings';
@override
String get settingsInterfaceLanguage => 'Interface Language';
@@ -479,7 +470,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get disclaimerContent => 'Placeholder content for disclaimer.';
@override
String get toastLabelInfo => 'Info';
String get toastLabelInfo => 'Tip';
@override
String get toastLabelSuccess => 'Success';
@@ -525,23 +516,23 @@ class AppLocalizationsEn extends AppLocalizations {
String get divinationSelectMethod => 'Select divination method';
@override
String get divinationManualMethod => 'Manual';
String get divinationManualMethod => 'Manual Casting';
@override
String get divinationAutoMethod => 'Auto';
String get divinationAutoMethod => 'Auto Casting';
@override
String get divinationQuestionTypePrompt => 'Select question type';
@override
String get divinationQuestionInputPrompt => 'Enter your question';
String get divinationQuestionInputPrompt => 'Please enter your question';
@override
String get divinationQuestionInputHint =>
'Describe your question in detail for more accurate reading';
@override
String get divinationStartButton => 'Start Casting';
String get divinationStartButton => 'Start Divination';
@override
String divinationCoinBalance(int balance) {
@@ -585,6 +576,107 @@ class AppLocalizationsEn extends AppLocalizations {
String get divinationManualGuideStep3 =>
'Record each result by whether the inscription side or pattern side faces up. Repeat 6 times, recording from bottom to top.';
@override
String get autoGuideStep1Title => 'Auto Casting';
@override
String get autoGuideStep1Body =>
'No coins needed. Simply shake your phone or tap the button to cast. Each shake automatically rotates three coins and shows the result.';
@override
String get autoGuideStep2Title => 'Start Casting';
@override
String get autoGuideStep2Body =>
'Tap the \"Start Casting\" button or shake your phone. The coins will rotate automatically for 3 seconds.';
@override
String get autoGuideStep3Title => 'Auto Recording';
@override
String get autoGuideStep3Body =>
'Each shake automatically records the corresponding yao position. Repeat 6 times to complete all six yao.';
@override
String get autoGuideStep4Title => 'Analyze Hexagram';
@override
String get autoGuideStep4Body =>
'After 6 shakes, the \"Analyze Hexagram\" button will blink. Tap it to view the hexagram interpretation.';
@override
String get manualGuideStep1Title => 'Manual Casting';
@override
String get manualGuideStep1Body =>
'Prepare three identical coins. Record one yao at a time, from bottom to top, until all six yao are complete.';
@override
String get manualGuideStep2Title => 'Confirm Time';
@override
String get manualGuideStep2Body =>
'Check the casting time first. Tap \"Modify\" on the right if you need to adjust it.';
@override
String get manualGuideStep3Title => 'Fill Six Yao in Order';
@override
String get manualGuideStep3Body =>
'Start from the first yao and select one row at a time. The next row stays locked until the current row is completed.';
@override
String get manualGuideStep4Title => 'Start Analysis';
@override
String get manualGuideStep4Body =>
'After all six yao are filled, the \"Analyze Hexagram\" button will blink. Tap it to start interpretation.';
@override
String get yaoNameFirst => 'First Yao';
@override
String get yaoNameSecond => 'Second Yao';
@override
String get yaoNameThird => 'Third Yao';
@override
String get yaoNameFourth => 'Fourth Yao';
@override
String get yaoNameFifth => 'Fifth Yao';
@override
String get yaoNameTop => 'Top Yao';
@override
String get yaoYin => 'Yin';
@override
String get yaoYang => 'Yang';
@override
String get yaoYoungYin => 'Young Yin';
@override
String get yaoYoungYang => 'Young Yang';
@override
String get yaoOldYin => 'Old Yin';
@override
String get yaoOldYang => 'Old Yang';
@override
String get yaoMovingSuffix => '(moving)';
@override
String get autoCoinFaceZi => 'Inscription';
@override
String get autoCoinFaceHua => 'Pattern';
@override
String get divinationIAcknowledge => 'I Understand';
@@ -636,7 +728,7 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get divinationCostDialogConfirm => 'Start';
String get divinationCostDialogConfirm => 'Confirm Analysis';
@override
String get toastContentCopied => 'Content copied';
@@ -674,7 +766,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get resultDivinationInfo => 'Divination Info';
@override
String get resultDivinationTime => 'Time';
String get resultDivinationTime => 'Casting Time';
@override
String get resultDivinationMethod => 'Method';
@@ -686,10 +778,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get resultQuestion => 'Question';
@override
String get resultAutoMethod => 'Auto';
String get resultAutoMethod => 'Auto Casting';
@override
String get resultManualMethod => 'Manual';
String get resultManualMethod => 'Manual Casting';
@override
String get signTypeShangShang => 'Supremely Auspicious';
@@ -708,7 +800,67 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get resultWarning =>
'All interpretations are AI-generated for entertainment only. Do not use them as professional advice.';
'All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.';
@override
String get followUpEntryHint =>
'You can ask one follow-up question for this reading';
@override
String get followUpEntryAction => 'Ask Follow-up';
@override
String get followUpViewHistory => 'View history';
@override
String get followUpScreenTitle => 'Continue Follow-up';
@override
String get followUpEmpty => 'No messages yet';
@override
String get followUpQuotaUsed => 'Follow-up limit reached for this session';
@override
String get followUpInputHint => 'Type your follow-up question';
@override
String get followUpHoldToSpeak => 'Hold to speak';
@override
String get followUpRecording => 'Release to send';
@override
String get followUpRecordingHint => 'Slide up to cancel';
@override
String get followUpTranscribing => 'Transcribing voice...';
@override
String get followUpGenerating => 'Generating reply...';
@override
String get followUpStepWorker => 'Analyzing divination and generating reply';
@override
String followUpStepGeneric(String stepName) {
return 'Processing: $stepName';
}
@override
String get errorAudioUnsupportedFormat =>
'Unsupported audio format. Please use wav';
@override
String get errorAudioTooLarge =>
'Audio file too large. Please record a shorter clip';
@override
String get errorAudioEmpty => 'No valid voice detected. Please try again';
@override
String get errorAsrUnavailable =>
'Voice transcription service is unavailable now';
@override
String get transitionPreparing => 'Deriving...';
@@ -818,7 +970,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get manualSpecifyYaoCombo => 'Select coin combination';
@override
String get manualStartResolve => 'Start Analysis';
String get manualStartResolve => 'Start Interpretation';
@override
String get manualSelectYaoTitle => 'Select Yao';
@@ -854,7 +1006,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get autoShakeInstruction => 'Tap to view auto casting method';
@override
String get autoStartShake => 'Start';
String get autoStartShake => 'Start Casting';
@override
String get autoContinueShake => 'Continue';
@@ -866,7 +1018,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get autoShaking => 'Casting...';
@override
String get autoStartResolve => 'Start Analysis';
String get autoStartResolve => 'Start Interpretation';
@override
String autoShakeCountdown(int seconds) {
@@ -900,7 +1052,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get dateTab => 'Date';
@override
String get timeTab => 'Time';
String get timeTab => 'Time Picker';
@override
String get confirm => 'Confirm';
+149 -9
View File
@@ -187,15 +187,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get readAllFirst => '请先阅读完整内容';
@override
String get categoryCareer => '事业学业';
@override
String get categoryLove => '情感婚姻';
@override
String get categoryMoney => '财富投资';
@override
String get signBest => '上上签';
@@ -568,6 +559,99 @@ class AppLocalizationsZh extends AppLocalizations {
String get divinationManualGuideStep3 =>
'记录每次结果,按照「字面在上还是花面在上」记录。重复六次,从下往上记录。';
@override
String get autoGuideStep1Title => '自动起卦';
@override
String get autoGuideStep1Body => '无需铜钱,摇动手机或点击按钮即可完成起卦。每次摇动后,三枚铜币将自动旋转并显示结果。';
@override
String get autoGuideStep2Title => '开始摇卦';
@override
String get autoGuideStep2Body => '点击「开始摇卦」按钮,或直接摇动手机。铜币将自动旋转3秒后停止。';
@override
String get autoGuideStep3Title => '自动记录';
@override
String get autoGuideStep3Body => '每摇一次,对应的爻位会自动记录。重复六次,即可完成全部六爻。';
@override
String get autoGuideStep4Title => '分析卦象';
@override
String get autoGuideStep4Body => '六次完成后,「分析卦象」按钮将闪烁提示。点击即可查看卦象解读。';
@override
String get manualGuideStep1Title => '手动起卦';
@override
String get manualGuideStep1Body => '准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。';
@override
String get manualGuideStep2Title => '确认时间';
@override
String get manualGuideStep2Body => '先确认起卦时间。如需调整,点击右侧「修改」。';
@override
String get manualGuideStep3Title => '依次录入六爻';
@override
String get manualGuideStep3Body => '从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。';
@override
String get manualGuideStep4Title => '开始分析';
@override
String get manualGuideStep4Body => '六爻都填完后,下方「分析卦象」按钮会闪烁提示,点击即可解卦。';
@override
String get yaoNameFirst => '初爻';
@override
String get yaoNameSecond => '二爻';
@override
String get yaoNameThird => '三爻';
@override
String get yaoNameFourth => '四爻';
@override
String get yaoNameFifth => '五爻';
@override
String get yaoNameTop => '上爻';
@override
String get yaoYin => '';
@override
String get yaoYang => '';
@override
String get yaoYoungYin => '少阴';
@override
String get yaoYoungYang => '少阳';
@override
String get yaoOldYin => '老阴';
@override
String get yaoOldYang => '老阳';
@override
String get yaoMovingSuffix => '(变)';
@override
String get autoCoinFaceZi => '';
@override
String get autoCoinFaceHua => '';
@override
String get divinationIAcknowledge => '我知道了';
@@ -693,6 +777,62 @@ class AppLocalizationsZh extends AppLocalizations {
String get resultWarning =>
'卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。';
@override
String get followUpEntryHint => '可针对本次解卦继续追问 1 次';
@override
String get followUpEntryAction => '追问';
@override
String get followUpViewHistory => '查看历史记录';
@override
String get followUpScreenTitle => '继续追问';
@override
String get followUpEmpty => '暂无消息';
@override
String get followUpQuotaUsed => '本次会话追问次数已用完';
@override
String get followUpInputHint => '输入你想继续追问的问题';
@override
String get followUpHoldToSpeak => '按住说话';
@override
String get followUpRecording => '松开发送';
@override
String get followUpRecordingHint => '上滑取消';
@override
String get followUpTranscribing => '语音转文字中...';
@override
String get followUpGenerating => '正在生成回复...';
@override
String get followUpStepWorker => '正在分析卦象并生成回复';
@override
String followUpStepGeneric(String stepName) {
return '正在处理:$stepName';
}
@override
String get errorAudioUnsupportedFormat => '音频格式不支持,请使用 wav';
@override
String get errorAudioTooLarge => '音频文件过大,请缩短录音时长';
@override
String get errorAudioEmpty => '未检测到有效语音,请重试';
@override
String get errorAsrUnavailable => '语音识别服务暂不可用,请稍后重试';
@override
String get transitionPreparing => '天机推演中';
+56 -3
View File
@@ -71,9 +71,6 @@
"scrollHint": "请向下滚动阅读全部内容",
"understood": "我已了解",
"readAllFirst": "请先阅读完整内容",
"categoryCareer": "事业学业",
"categoryLove": "情感婚姻",
"categoryMoney": "财富投资",
"signBest": "上上签",
"signGood": "中上签",
"signNormal": "中下签",
@@ -233,6 +230,37 @@
"divinationManualGuideStep1": "左侧为花面,右侧为字面。准备三枚相同的钱币,任何类似款式均可。",
"divinationManualGuideStep2": "双手捧起钱币,闭目心中默念所问之事,然后抛掷钱币于桌面,记录字面和花面出现的次数。",
"divinationManualGuideStep3": "记录每次结果,按照「字面在上还是花面在上」记录。重复六次,从下往上记录。",
"autoGuideStep1Title": "自动起卦",
"autoGuideStep1Body": "无需铜钱,摇动手机或点击按钮即可完成起卦。每次摇动后,三枚铜币将自动旋转并显示结果。",
"autoGuideStep2Title": "开始摇卦",
"autoGuideStep2Body": "点击「开始摇卦」按钮,或直接摇动手机。铜币将自动旋转3秒后停止。",
"autoGuideStep3Title": "自动记录",
"autoGuideStep3Body": "每摇一次,对应的爻位会自动记录。重复六次,即可完成全部六爻。",
"autoGuideStep4Title": "分析卦象",
"autoGuideStep4Body": "六次完成后,「分析卦象」按钮将闪烁提示。点击即可查看卦象解读。",
"manualGuideStep1Title": "手动起卦",
"manualGuideStep1Body": "准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。",
"manualGuideStep2Title": "确认时间",
"manualGuideStep2Body": "先确认起卦时间。如需调整,点击右侧「修改」。",
"manualGuideStep3Title": "依次录入六爻",
"manualGuideStep3Body": "从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。",
"manualGuideStep4Title": "开始分析",
"manualGuideStep4Body": "六爻都填完后,下方「分析卦象」按钮会闪烁提示,点击即可解卦。",
"yaoNameFirst": "初爻",
"yaoNameSecond": "二爻",
"yaoNameThird": "三爻",
"yaoNameFourth": "四爻",
"yaoNameFifth": "五爻",
"yaoNameTop": "上爻",
"yaoYin": "阴",
"yaoYang": "阳",
"yaoYoungYin": "少阴",
"yaoYoungYang": "少阳",
"yaoOldYin": "老阴",
"yaoOldYang": "老阳",
"yaoMovingSuffix": "(变)",
"autoCoinFaceZi": "字",
"autoCoinFaceHua": "花",
"divinationIAcknowledge": "我知道了",
"divinationClose": "关闭",
"divinationModify": "修改",
@@ -290,6 +318,31 @@
"signTypeXiaXia": "下下签",
"resultCopy": "复制",
"resultWarning": "卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。",
"followUpEntryHint": "可针对本次解卦继续追问 1 次",
"followUpEntryAction": "追问",
"followUpViewHistory": "查看历史记录",
"followUpScreenTitle": "继续追问",
"followUpEmpty": "暂无消息",
"followUpQuotaUsed": "本次会话追问次数已用完",
"followUpInputHint": "输入你想继续追问的问题",
"followUpHoldToSpeak": "按住说话",
"followUpRecording": "松开发送",
"followUpRecordingHint": "上滑取消",
"followUpTranscribing": "语音转文字中...",
"followUpGenerating": "正在生成回复...",
"followUpStepWorker": "正在分析卦象并生成回复",
"followUpStepGeneric": "正在处理:{stepName}",
"@followUpStepGeneric": {
"placeholders": {
"stepName": {
"type": "String"
}
}
},
"errorAudioUnsupportedFormat": "音频格式不支持,请使用 wav",
"errorAudioTooLarge": "音频文件过大,请缩短录音时长",
"errorAudioEmpty": "未检测到有效语音,请重试",
"errorAsrUnavailable": "语音识别服务暂不可用,请稍后重试",
"transitionPreparing": "天机推演中",
"transitionDeriving": "正在解卦",
"transitionDone": "解卦完成\n点击查看",
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart';
class DivinationSummaryTagData {
const DivinationSummaryTagData({
required this.label,
required this.background,
required this.foreground,
});
final String label;
final Color background;
final Color foreground;
}
class DivinationSummaryCard extends StatelessWidget {
const DivinationSummaryCard({
super.key,
required this.question,
required this.leading,
required this.tags,
this.leadingBackgroundColor,
this.onTap,
this.questionMaxLines = 1,
});
final String question;
final Widget leading;
final List<DivinationSummaryTagData> tags;
final Color? leadingBackgroundColor;
final VoidCallback? onTap;
final int questionMaxLines;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final card = Card(
margin: EdgeInsets.zero,
color: colors.surface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color:
leadingBackgroundColor ??
colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Center(child: leading),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
question,
maxLines: questionMaxLines,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
if (tags.isNotEmpty) ...[
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: tags
.map(
(tag) => _DivinationSummaryTag(
label: tag.label,
background: tag.background,
foreground: tag.foreground,
),
)
.toList(growable: false),
),
],
],
),
),
);
if (onTap == null) {
return SizedBox(width: double.infinity, child: card);
}
return SizedBox(
width: double.infinity,
child: Material(
color: colors.surface.withValues(alpha: 0),
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.md),
onTap: onTap,
child: card,
),
),
);
}
}
class _DivinationSummaryTag extends StatelessWidget {
const _DivinationSummaryTag({
required this.label,
required this.background,
required this.foreground,
});
final String label;
final Color background;
final Color foreground;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: foreground),
),
);
}
}
@@ -1,4 +1,5 @@
import '../../../features/divination/data/models/divination_params.dart';
import '../../../l10n/app_localizations.dart';
abstract final class DivinationTerms {
static const yaoNames = ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'];
@@ -45,6 +46,32 @@ abstract final class DivinationTerms {
static const yaoXiang = '爻象';
static const qiGua = '起卦';
static const jieGua = '解卦';
static String yaoName(AppLocalizations l10n, int index) {
return switch (index) {
0 => l10n.yaoNameFirst,
1 => l10n.yaoNameSecond,
2 => l10n.yaoNameThird,
3 => l10n.yaoNameFourth,
4 => l10n.yaoNameFifth,
5 => l10n.yaoNameTop,
_ => '',
};
}
static String yinYangLabel(AppLocalizations l10n, bool isYang) {
return isYang ? l10n.yaoYang : l10n.yaoYin;
}
static String yaoTypeLabel(AppLocalizations l10n, YaoType type) {
return switch (type) {
YaoType.youngYang => l10n.yaoYoungYang,
YaoType.youngYin => l10n.yaoYoungYin,
YaoType.oldYang => l10n.yaoOldYang,
YaoType.oldYin => l10n.yaoOldYin,
YaoType.undetermined => '',
};
}
}
enum YaoTypeLabel { youngYang, youngYin, oldYang, oldYin }
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../../../features/divination/data/models/divination_params.dart';
import '../../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart';
import 'divination_terms.dart';
@@ -8,6 +10,7 @@ class YaoLegend extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final style = Theme.of(context).textTheme.bodySmall;
final mutedTextColor = Theme.of(
context,
@@ -17,19 +20,19 @@ class YaoLegend extends StatelessWidget {
runSpacing: AppSpacing.xs,
children: [
Text(
'\u2014 ${DivinationTerms.yinYang[true]}',
'\u2014 ${DivinationTerms.yinYangLabel(l10n, true)}',
style: style?.copyWith(color: mutedTextColor),
),
Text(
'-- ${DivinationTerms.yinYang[false]}',
'-- ${DivinationTerms.yinYangLabel(l10n, false)}',
style: style?.copyWith(color: mutedTextColor),
),
Text(
'${DivinationTerms.changeMarkOldYang} ${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]}(变)',
'${DivinationTerms.changeMarkOldYang} ${DivinationTerms.yaoTypeLabel(l10n, YaoType.oldYang)}${l10n.yaoMovingSuffix}',
style: style?.copyWith(color: mutedTextColor),
),
Text(
'${DivinationTerms.changeMarkOldYin} ${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]}(变)',
'${DivinationTerms.changeMarkOldYin} ${DivinationTerms.yaoTypeLabel(l10n, YaoType.oldYin)}${l10n.yaoMovingSuffix}',
style: style?.copyWith(color: mutedTextColor),
),
],
@@ -0,0 +1,249 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
import 'app_loading_indicator.dart';
enum MessageComposerMode { text, holdToSpeak }
enum MessageComposerProcess { idle, recording, transcribing }
class MessageComposer extends StatelessWidget {
const MessageComposer({
super.key,
required this.mode,
required this.process,
required this.hasMessage,
required this.isWaitingAgent,
required this.iconSize,
required this.composerMinHeight,
required this.onTapRightAction,
required this.onHoldToSpeakStart,
required this.onHoldToSpeakEnd,
required this.onHoldToSpeakMoveUpdate,
required this.onHoldToSpeakCancel,
required this.textInputChild,
required this.holdToSpeakText,
required this.recordingText,
required this.transcribingText,
required this.recordingHintText,
this.showRecordingInlineFeedback = true,
});
final MessageComposerMode mode;
final MessageComposerProcess process;
final bool hasMessage;
final bool isWaitingAgent;
final double iconSize;
final double composerMinHeight;
final VoidCallback onTapRightAction;
final VoidCallback onHoldToSpeakStart;
final VoidCallback onHoldToSpeakEnd;
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
final VoidCallback onHoldToSpeakCancel;
final Widget textInputChild;
final String holdToSpeakText;
final String recordingText;
final String transcribingText;
final String recordingHintText;
final bool showRecordingInlineFeedback;
bool get _isHoldMode => mode == MessageComposerMode.holdToSpeak;
bool get _isRecording => process == MessageComposerProcess.recording;
bool get _isTranscribing => process == MessageComposerProcess.transcribing;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: colorScheme.outlineVariant, width: 0.5),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: _buildCenterArea(colorScheme)),
const SizedBox(width: AppSpacing.sm),
_buildRightAction(colorScheme),
],
),
);
}
Widget _buildRightAction(ColorScheme colorScheme) {
if (_isTranscribing) {
return SizedBox(
width: iconSize,
height: iconSize,
child: AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: iconSize,
strokeWidth: AppSpacing.xs / 2,
color: colorScheme.primary,
trackColor: colorScheme.primaryContainer,
),
);
}
if (_isRecording) {
return Container(
width: iconSize,
height: iconSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.error.withValues(alpha: 0.1),
),
child: Icon(
Icons.fiber_manual_record,
size: iconSize * 0.6,
color: colorScheme.error,
),
);
}
return IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: BoxConstraints(minWidth: iconSize, minHeight: iconSize),
onPressed: onTapRightAction,
icon: Icon(
_resolveRightIcon(),
size: iconSize,
color: _resolveRightIconColor(colorScheme),
),
);
}
Widget _buildCenterArea(ColorScheme colorScheme) {
return SizedBox(
height: composerMinHeight,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
child: _isHoldMode
? _buildHoldToSpeakArea(
key: const ValueKey('hold_mode'),
colorScheme: colorScheme,
)
: _buildTextInputArea(key: const ValueKey('text_mode')),
),
);
}
Widget _buildTextInputArea({required Key key}) {
return SizedBox(key: key, height: composerMinHeight, child: textInputChild);
}
Widget _buildHoldToSpeakArea({
required Key key,
required ColorScheme colorScheme,
}) {
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: {
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(
duration: const Duration(milliseconds: 120),
),
(instance) {
instance.onLongPressStart = (details) => onHoldToSpeakStart();
instance.onLongPressEnd = (details) => onHoldToSpeakEnd();
instance.onLongPressMoveUpdate = onHoldToSpeakMoveUpdate;
instance.onLongPressCancel = onHoldToSpeakCancel;
},
),
},
child: Container(
key: key,
width: double.infinity,
height: composerMinHeight,
alignment: Alignment.center,
child: _buildHoldToSpeakContent(colorScheme),
),
);
}
Widget _buildHoldToSpeakContent(ColorScheme colorScheme) {
if (_isRecording) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.error,
),
),
const SizedBox(width: AppSpacing.sm),
Text(
recordingText,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
);
}
if (_isTranscribing) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(width: AppSpacing.sm),
Text(
transcribingText,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.mic, size: 16, color: colorScheme.onSurfaceVariant),
const SizedBox(width: AppSpacing.sm),
Text(
holdToSpeakText,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
);
}
IconData _resolveRightIcon() {
if (isWaitingAgent) {
return Icons.stop_rounded;
}
if (hasMessage) {
return Icons.send_rounded;
}
return _isHoldMode ? Icons.keyboard_rounded : Icons.mic_rounded;
}
Color _resolveRightIconColor(ColorScheme colorScheme) {
if (isWaitingAgent || hasMessage) {
return colorScheme.primary;
}
return colorScheme.onSurfaceVariant;
}
}