docs: 更新协议文档,删除废弃计划文档
- 更新 http-error-codes, user-points-chat-data-protocol - 更新 divination-run-protocol, profile-protocol - 删除废弃的后端和前端设计计划文档
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user