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

This commit is contained in:
qzl
2026-04-03 19:04:46 +08:00
parent a136e42290
commit d87b2e1e3a
56 changed files with 3310 additions and 809 deletions
+6
View File
@@ -45,6 +45,12 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
- `AppTheme.light` / `AppTheme.dark` provide complete `ColorScheme` (light + dark). `MaterialApp` wires them via `theme:` / `darkTheme:`.
- If a semantic slot is missing from `ColorScheme`, add it to `AppTheme` — do not bypass `colorScheme` with hardcoded values.
## Divination Terminology (Must)
- Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels.
- Do not localize or translate canonical terms such as: 六爻、爻、动爻、静爻、六亲、六神、世爻、应爻、伏神、月建、日辰、月破、日冲、空亡、五行旺衰、上上签、中上签、中下签。
- l10n can translate explanatory copy, but must not alter canonical divination terminology semantics.
## Reuse & Composition (Must)
- Prefer `apps/lib/shared/widgets/` before adding new components.
+32 -1
View File
@@ -9,6 +9,7 @@ import '../features/auth/data/repositories/auth_repository.dart';
import '../features/auth/presentation/bloc/auth_bloc.dart';
import '../features/auth/presentation/bloc/auth_state.dart';
import '../features/auth/presentation/screens/login_screen.dart';
import '../features/divination/data/apis/divination_api.dart';
import '../features/home/presentation/screens/home_screen.dart';
import '../features/settings/data/models/profile_settings.dart';
import '../l10n/app_localizations.dart';
@@ -26,10 +27,14 @@ class EryaoApp extends StatefulWidget {
class _EryaoAppState extends State<EryaoApp> {
final SessionStore _sessionStore = SessionStore(LocalKvStore());
late final AuthBloc _authBloc;
late final DivinationApi _divinationApi;
Locale _locale = const Locale('zh');
ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
);
int _creditsBalance = 0;
bool _loadingCredits = false;
String? _loadedCreditsUserEmail;
@override
void initState() {
@@ -42,6 +47,7 @@ class _EryaoAppState extends State<EryaoApp> {
},
);
final authApi = AuthApi(apiClient: apiClient);
_divinationApi = DivinationApi(apiClient: apiClient);
final authRepository = AuthRepositoryImpl(
authApi: authApi,
sessionStore: _sessionStore,
@@ -50,6 +56,30 @@ class _EryaoAppState extends State<EryaoApp> {
_bootstrap();
}
void _ensureCreditsLoaded(String userEmail) {
if (_loadingCredits) {
return;
}
if (_loadedCreditsUserEmail == userEmail) {
return;
}
_loadingCredits = true;
_divinationApi
.getPointsBalance()
.then((balance) {
if (!mounted) {
return;
}
setState(() {
_creditsBalance = balance.availableBalance;
_loadedCreditsUserEmail = userEmail;
});
})
.whenComplete(() {
_loadingCredits = false;
});
}
@override
void dispose() {
_authBloc.dispose();
@@ -118,12 +148,13 @@ class _EryaoAppState extends State<EryaoApp> {
}
if (state.status == AuthStatus.authenticated && state.user != null) {
_ensureCreditsLoaded(state.user!.email);
return HomeScreen(
account: state.user!.email,
sessionStore: _sessionStore,
currentLocale: _locale,
profileSettings: _profileSettings,
coinBalance: 100,
coinBalance: _creditsBalance,
onLocaleChanged: _handleInterfaceLanguageChanged,
onLogout: _authBloc.logout,
);
@@ -11,6 +11,12 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) {
return l10n.errorSessionExpired;
case 'AUTH_SERVICE_UNAVAILABLE':
return l10n.errorServiceUnavailable;
case 'POINTS_INSUFFICIENT_BALANCE':
return l10n.toastCoinInsufficient;
case 'AGENT_SESSION_RUN_LIMIT_EXCEEDED':
return l10n.errorRunLimitExceeded;
case 'AGENT_DIVINATION_PAYLOAD_REQUIRED':
return l10n.errorDivinationPayloadRequired;
default:
break;
}
+16
View File
@@ -44,6 +44,8 @@ class ApiClient {
final Dio _dio;
final Logger _logger = getLogger('data.network.api_client');
Dio get rawDio => _dio;
Future<void> postNoContent(String path, {Map<String, dynamic>? data}) async {
try {
await _dio.post<void>(path, data: data);
@@ -90,6 +92,20 @@ class ApiClient {
}
}
Future<Map<String, dynamic>> getJson(String path) async {
try {
final response = await _dio.get<Map<String, dynamic>>(path);
return response.data ?? <String, dynamic>{};
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'GET json failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
ApiProblem _mapProblem(DioException error) {
final status = error.response?.statusCode ?? 500;
final data = error.response?.data;
@@ -0,0 +1,230 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
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/divination_params.dart';
class DivinationApi {
const DivinationApi({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient;
static final Logger _logger = getLogger('features.divination.api');
Future<PointsBalanceData> getPointsBalance() async {
final json = await _apiClient.getJson('/api/v1/points/balance');
return PointsBalanceData.fromJson(json);
}
Future<RunAcceptedData> enqueueRun({
required DivinationParams params,
required List<YaoType> yaoStates,
required String threadId,
required String runId,
}) async {
final payload = buildDivinationRunPayload(
params: params,
yaoStates: yaoStates,
threadId: threadId,
runId: runId,
clientNow: DateTime.now(),
);
final json = await _apiClient.postJson('/api/v1/agent/runs', data: payload);
return RunAcceptedData.fromJson(json);
}
Stream<Map<String, dynamic>> streamEvents({
required String threadId,
required String runId,
}) async* {
Response<ResponseBody> response;
try {
response = await _apiClient.rawDio.get<ResponseBody>(
'/api/v1/agent/runs/$threadId/events',
queryParameters: <String, dynamic>{'runId': runId},
options: Options(responseType: ResponseType.stream),
);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'Failed to open SSE stream for divination run',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{'threadId': threadId, 'runId': runId},
);
throw _mapProblem(error);
}
final body = response.data;
if (body == null) {
return;
}
String buffer = '';
try {
await for (final textChunk in utf8.decoder.bind(body.stream)) {
buffer += textChunk.replaceAll('\r\n', '\n');
while (true) {
final splitAt = buffer.indexOf('\n\n');
if (splitAt < 0) {
break;
}
final frame = buffer.substring(0, splitAt);
buffer = buffer.substring(splitAt + 2);
final event = _parseSseFrame(frame);
if (event != null) {
yield event;
}
}
}
} on FormatException catch (error, stackTrace) {
_logger.error(
message: 'Failed to decode SSE stream chunk',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{
'threadId': threadId,
'runId': runId,
'bufferLength': buffer.length,
},
);
throw ApiProblem(
status: 502,
title: 'SSE parse error',
detail: error.message,
);
}
if (buffer.trim().isNotEmpty) {
final event = _parseSseFrame(buffer);
if (event != null) {
yield event;
}
}
}
ApiProblem _mapProblem(DioException error) {
final status = error.response?.statusCode ?? 500;
final data = error.response?.data;
if (data is Map<String, dynamic>) {
return ApiProblem(
status: status,
title: (data['title'] as String?) ?? 'Request failed',
detail: (data['detail'] as String?) ?? '',
code: data['code'] as String?,
);
}
return ApiProblem(
status: status,
title: 'Network error',
detail: error.message ?? 'Request failed',
);
}
Map<String, dynamic>? _parseSseFrame(String frame) {
if (frame.startsWith(':')) {
return null;
}
final lines = frame.split('\n');
String eventType = '';
final dataLines = <String>[];
for (final raw in lines) {
final line = raw.trimRight();
if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.add(line.substring(5).trimLeft());
}
}
if (dataLines.isEmpty) {
return null;
}
final dataText = dataLines.join('\n');
if (dataText.trim().isEmpty) {
return null;
}
final decoded = jsonDecode(dataText);
if (decoded is! Map<String, dynamic>) {
return null;
}
if (!decoded.containsKey('type') && eventType.isNotEmpty) {
decoded['type'] = eventType;
}
return decoded;
}
}
Map<String, dynamic> buildDivinationRunPayload({
required DivinationParams params,
required List<YaoType> yaoStates,
required String threadId,
required String runId,
required DateTime clientNow,
}) {
if (yaoStates.length != 6) {
throw ArgumentError('yaoStates must contain exactly 6 items');
}
return <String, dynamic>{
'threadId': threadId,
'runId': runId,
'state': <String, dynamic>{},
'messages': [
{'id': 'msg_${runId}_user_0', 'role': 'user', 'content': params.question},
],
'tools': const <Map<String, dynamic>>[],
'context': const <Map<String, dynamic>>[],
'forwardedProps': {
'runtime_mode': 'chat',
'client_time': {
'device_timezone': 'Asia/Shanghai',
'client_now_iso': _toRfc3339Utc(clientNow),
'client_epoch_ms': clientNow.millisecondsSinceEpoch,
},
'divinationPayload': {
'divinationMethod': params.method == DivinationMethod.manual
? '手动起卦'
: '自动起卦',
'questionType': _questionTypeToText(params.questionType),
'question': params.question,
'divinationTimeIso': _toRfc3339Utc(params.divinationTime),
'yaoLines': yaoStates.map(_yaoTypeToText).toList(growable: false),
},
},
};
}
String _toRfc3339Utc(DateTime value) {
return value.toUtc().toIso8601String();
}
String _questionTypeToText(QuestionType type) {
return switch (type) {
QuestionType.career => '事业',
QuestionType.love => '情感',
QuestionType.wealth => '财富',
QuestionType.fortune => '运势',
QuestionType.dream => '解梦',
QuestionType.health => '健康',
QuestionType.study => '学业',
QuestionType.search => '寻物',
QuestionType.other => '其他',
};
}
String _yaoTypeToText(YaoType type) {
return switch (type) {
YaoType.youngYang => '少阳',
YaoType.youngYin => '少阴',
YaoType.oldYang => '老阳',
YaoType.oldYin => '老阴',
YaoType.undetermined => throw ArgumentError(
'yaoStates contains undetermined line',
),
};
}
@@ -0,0 +1,347 @@
import 'divination_params.dart';
import 'divination_result.dart';
class PointsBalanceData {
const PointsBalanceData({
required this.balance,
required this.frozenBalance,
required this.availableBalance,
required this.runCost,
required this.canRun,
});
factory PointsBalanceData.fromJson(Map<String, dynamic> json) {
return PointsBalanceData(
balance: _requiredInt(json, 'balance'),
frozenBalance: _requiredInt(json, 'frozenBalance'),
availableBalance: _requiredInt(json, 'availableBalance'),
runCost: _requiredInt(json, 'runCost'),
canRun: _requiredBool(json, 'canRun'),
);
}
final int balance;
final int frozenBalance;
final int availableBalance;
final int runCost;
final bool canRun;
}
class RunAcceptedData {
const RunAcceptedData({
required this.taskId,
required this.threadId,
required this.runId,
required this.created,
});
factory RunAcceptedData.fromJson(Map<String, dynamic> json) {
return RunAcceptedData(
taskId: _requiredString(json, 'taskId'),
threadId: _requiredString(json, 'threadId'),
runId: _requiredString(json, 'runId'),
created: _requiredBool(json, 'created'),
);
}
final String taskId;
final String threadId;
final String runId;
final bool created;
}
class DivinationRunAggregate {
const DivinationRunAggregate({
required this.derived,
required this.signLevel,
required this.summary,
required this.conclusion,
required this.focusPoints,
required this.advice,
required this.keywords,
required this.answer,
});
final DerivedDivinationData derived;
final String signLevel;
final String summary;
final List<String> conclusion;
final List<String> focusPoints;
final List<String> advice;
final List<String> keywords;
final String answer;
DivinationResultData toViewData(DivinationParams params) {
return DivinationResultData(
params: params,
binaryCode: derived.binaryCode,
changedBinaryCode: derived.changedBinaryCode,
guaName: derived.guaName,
targetGuaName: derived.targetGuaName,
upperName: derived.upperName,
lowerName: derived.lowerName,
signType: signLevel,
keywords: keywords.join(''),
conclusion: _asBullet(conclusion),
analysis: summary.isEmpty ? answer : '$summary\n\n$answer',
suggestion: _asBullet(advice),
ganzhi: GanzhiData(
yearGanZhi: derived.ganzhi.yearGanZhi,
monthGanZhi: derived.ganzhi.monthGanZhi,
dayGanZhi: derived.ganzhi.dayGanZhi,
timeGanZhi: derived.ganzhi.timeGanZhi,
yearKongWang: derived.ganzhi.yearKongWang,
monthKongWang: derived.ganzhi.monthKongWang,
dayKongWang: derived.ganzhi.dayKongWang,
timeKongWang: derived.ganzhi.timeKongWang,
yueJian: derived.ganzhi.yueJian,
riChen: derived.ganzhi.riChen,
yuePo: derived.ganzhi.yuePo,
riChong: derived.ganzhi.riChong,
),
wuXingStatus: derived.wuXingStatuses,
yaoLines: derived.yaoInfoList
.map((line) => line.toViewModel())
.toList(growable: false),
targetYaoLines: derived.targetYaoInfoList
.map((line) => line.toViewModel())
.toList(growable: false),
);
}
String _asBullet(List<String> lines) {
if (lines.isEmpty) {
return '';
}
return List<String>.generate(
lines.length,
(i) => '${i + 1}. ${lines[i]}',
growable: false,
).join('\n');
}
}
class DerivedDivinationData {
const DerivedDivinationData({
required this.question,
required this.questionType,
required this.divinationMethod,
required this.divinationTime,
required this.binaryCode,
required this.changedBinaryCode,
required this.guaName,
required this.upperName,
required this.lowerName,
required this.targetGuaName,
required this.worldPosition,
required this.responsePosition,
required this.hasChangingYao,
required this.ganzhi,
required this.wuXingStatuses,
required this.yaoInfoList,
required this.targetYaoInfoList,
});
factory DerivedDivinationData.fromJson(Map<String, dynamic> json) {
final wuXingRaw = _requiredMap(json, 'wuXingStatuses');
return DerivedDivinationData(
question: _requiredString(json, 'question'),
questionType: _requiredString(json, 'questionType'),
divinationMethod: _requiredString(json, 'divinationMethod'),
divinationTime: _requiredString(json, 'divinationTime'),
binaryCode: _requiredString(json, 'binaryCode'),
changedBinaryCode: _requiredString(json, 'changedBinaryCode'),
guaName: _requiredString(json, 'guaName'),
upperName: _requiredString(json, 'upperName'),
lowerName: _requiredString(json, 'lowerName'),
targetGuaName: _requiredString(json, 'targetGuaName'),
worldPosition: _requiredInt(json, 'worldPosition'),
responsePosition: _requiredInt(json, 'responsePosition'),
hasChangingYao: _requiredBool(json, 'hasChangingYao'),
ganzhi: GanzhiBackend.fromJson(_requiredMap(json, 'ganzhi')),
wuXingStatuses: wuXingRaw.map(
(key, value) => MapEntry(key, value.toString()),
),
yaoInfoList: _parseYaoList(json['yaoInfoList']),
targetYaoInfoList: _parseYaoList(json['targetYaoInfoList']),
);
}
final String question;
final String questionType;
final String divinationMethod;
final String divinationTime;
final String binaryCode;
final String changedBinaryCode;
final String guaName;
final String upperName;
final String lowerName;
final String targetGuaName;
final int worldPosition;
final int responsePosition;
final bool hasChangingYao;
final GanzhiBackend ganzhi;
final Map<String, String> wuXingStatuses;
final List<YaoBackendLine> yaoInfoList;
final List<YaoBackendLine> targetYaoInfoList;
static List<YaoBackendLine> _parseYaoList(Object? raw) {
final list = raw as List<dynamic>?;
if (list == null) {
throw const FormatException(
'Missing required list: yaoInfoList/targetYaoInfoList',
);
}
return list
.map((item) {
if (item is! Map<String, dynamic>) {
throw const FormatException('Invalid yao line item');
}
return YaoBackendLine.fromJson(item);
})
.toList(growable: false);
}
}
class GanzhiBackend {
const GanzhiBackend({
required this.yearGanZhi,
required this.monthGanZhi,
required this.dayGanZhi,
required this.timeGanZhi,
required this.yearKongWang,
required this.monthKongWang,
required this.dayKongWang,
required this.timeKongWang,
required this.yueJian,
required this.riChen,
required this.yuePo,
required this.riChong,
});
factory GanzhiBackend.fromJson(Map<String, dynamic> json) {
return GanzhiBackend(
yearGanZhi: _requiredString(json, 'yearGanZhi'),
monthGanZhi: _requiredString(json, 'monthGanZhi'),
dayGanZhi: _requiredString(json, 'dayGanZhi'),
timeGanZhi: _requiredString(json, 'timeGanZhi'),
yearKongWang: _requiredString(json, 'yearKongWang'),
monthKongWang: _requiredString(json, 'monthKongWang'),
dayKongWang: _requiredString(json, 'dayKongWang'),
timeKongWang: _requiredString(json, 'timeKongWang'),
yueJian: _requiredString(json, 'yueJian'),
riChen: _requiredString(json, 'riChen'),
yuePo: _requiredString(json, 'yuePo'),
riChong: _requiredString(json, 'riChong'),
);
}
final String yearGanZhi;
final String monthGanZhi;
final String dayGanZhi;
final String timeGanZhi;
final String yearKongWang;
final String monthKongWang;
final String dayKongWang;
final String timeKongWang;
final String yueJian;
final String riChen;
final String yuePo;
final String riChong;
}
class YaoBackendLine {
const YaoBackendLine({
required this.position,
required this.spiritName,
required this.relationName,
required this.tiganName,
required this.elementName,
required this.isYang,
required this.isChanging,
required this.specialMark,
});
factory YaoBackendLine.fromJson(Map<String, dynamic> json) {
return YaoBackendLine(
position: _requiredInt(json, 'position'),
spiritName: _requiredString(json, 'spiritName'),
relationName: _requiredString(json, 'relationName'),
tiganName: _requiredString(json, 'tiganName'),
elementName: _requiredString(json, 'elementName'),
isYang: _requiredBool(json, 'isYang'),
isChanging: _requiredBool(json, 'isChanging'),
specialMark: _requiredStringAllowEmpty(json, 'specialMark'),
);
}
final int position;
final String spiritName;
final String relationName;
final String tiganName;
final String elementName;
final bool isYang;
final bool isChanging;
final String specialMark;
YaoLineData toViewModel() {
final type = switch ((isYang, isChanging)) {
(true, false) => YaoType.youngYang,
(false, false) => YaoType.youngYin,
(true, true) => YaoType.oldYang,
(false, true) => YaoType.oldYin,
};
return YaoLineData(
index: position - 1,
spirit: spiritName,
relation: relationName,
branch: tiganName,
element: elementName,
type: type,
mark: specialMark,
);
}
}
String _requiredStringAllowEmpty(Map<String, dynamic> json, String key) {
if (!json.containsKey(key)) {
throw FormatException('Invalid or missing field: $key');
}
final value = json[key];
if (value is! String) {
throw FormatException('Invalid or missing field: $key');
}
return value;
}
String _requiredString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! String || value.isEmpty) {
throw FormatException('Invalid or missing field: $key');
}
return value;
}
int _requiredInt(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! num) {
throw FormatException('Invalid or missing field: $key');
}
return value.toInt();
}
bool _requiredBool(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! bool) {
throw FormatException('Invalid or missing field: $key');
}
return value;
}
Map<String, dynamic> _requiredMap(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! Map<String, dynamic>) {
throw FormatException('Invalid or missing field: $key');
}
return value;
}
@@ -85,16 +85,3 @@ class DivinationParams {
.join();
}
}
class DivinationMockData {
static DivinationParams initial() {
return DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '',
divinationTime: DateTime.now(),
coinBalance: 8,
userId: 'mock_user_10001',
);
}
}
@@ -1,222 +0,0 @@
import '../models/divination_params.dart';
import '../models/divination_result.dart';
class DivinationResultBuilder {
DivinationResultData build({
required DivinationParams params,
required List<YaoType> yaoStates,
}) {
final binaryCode = params.toBinary(yaoStates);
final changedBinaryCode = params.toChangedBinary(yaoStates);
final baseHexagram = _hexagramMap[binaryCode];
final changedHexagram = _hexagramMap[changedBinaryCode];
if (baseHexagram == null || changedHexagram == null) {
throw StateError(
'Unknown hexagram mapping for binary=$binaryCode changed=$changedBinaryCode',
);
}
final signType = _signByStates(yaoStates);
final content = _mockContent(
params.questionType,
params.question,
signType,
);
final lineData = _buildYaoLines(yaoStates, false);
final targetStates = _toChangedStates(yaoStates);
final targetLineData = _buildYaoLines(targetStates, true);
return DivinationResultData(
params: params,
binaryCode: binaryCode,
changedBinaryCode: changedBinaryCode,
guaName: baseHexagram.name,
targetGuaName: changedHexagram.name,
upperName: baseHexagram.upper,
lowerName: baseHexagram.lower,
signType: signType,
keywords: content.keywords,
conclusion: content.conclusion,
analysis: content.analysis,
suggestion: content.suggestion,
ganzhi: const GanzhiData(
yearGanZhi: '丙午',
monthGanZhi: '甲辰',
dayGanZhi: '辛亥',
timeGanZhi: '乙巳',
yearKongWang: '子丑',
monthKongWang: '申酉',
dayKongWang: '寅卯',
timeKongWang: '午未',
yueJian: '',
riChen: '',
yuePo: '',
riChong: '',
),
wuXingStatus: const {'': '', '': '', '': '', '': '', '': ''},
yaoLines: lineData,
targetYaoLines: targetLineData,
);
}
List<YaoLineData> _buildYaoLines(List<YaoType> states, bool target) {
const spirits = ['', '', '', '', '', ''];
const relations = ['父母', '兄弟', '官鬼', '妻财', '子孙', '父母'];
const branches = ['', '', '', '', '', ''];
const elements = ['', '', '', '', '', ''];
return List<YaoLineData>.generate(6, (idx) {
final mark = switch (idx) {
1 => '',
4 => '',
_ => '',
};
return YaoLineData(
index: idx,
spirit: spirits[idx],
relation: relations[idx],
branch: branches[idx],
element: elements[idx],
type: states[idx],
mark: target ? '' : mark,
);
});
}
List<YaoType> _toChangedStates(List<YaoType> source) {
return source.map((state) {
return switch (state) {
YaoType.oldYang => YaoType.youngYin,
YaoType.oldYin => YaoType.youngYang,
_ => state,
};
}).toList();
}
String _signByStates(List<YaoType> states) {
final dynamicCount = states
.where((e) => e == YaoType.oldYang || e == YaoType.oldYin)
.length;
if (dynamicCount <= 1) {
return '上上签';
}
if (dynamicCount <= 3) {
return '中上签';
}
return '中下签';
}
_MockContent _mockContent(
QuestionType type,
String question,
String signType,
) {
final domain = switch (type) {
QuestionType.career || QuestionType.study => '事业与成长',
QuestionType.love => '关系与情感',
QuestionType.wealth => '财富与资源',
QuestionType.fortune => '阶段运势',
QuestionType.dream => '潜意识信号',
QuestionType.health => '身心节律',
QuestionType.search => '寻物线索',
QuestionType.other => '综合事项',
};
return _MockContent(
keywords: '$signType · $domain',
conclusion: '这个卦象的结果为$signType。你关注的“$question”处于可推进阶段,当前节奏重在稳步而行,不宜急进。',
analysis:
'本卦显示外在条件逐步成形,内在决心也在增强。若短期遇到反复,通常是资源重组与信息修正,并非方向错误。建议将目标拆分为可验证的小节点,持续复盘。',
suggestion:
'建议一:先定三周内可执行动作并按日推进。\n建议二:重要决定留有缓冲期,避免情绪化判断。\n建议三:遇到阻滞先调整节奏,再补关键资源。',
);
}
}
class _MockContent {
const _MockContent({
required this.keywords,
required this.conclusion,
required this.analysis,
required this.suggestion,
});
final String keywords;
final String conclusion;
final String analysis;
final String suggestion;
}
class _HexagramShort {
const _HexagramShort(this.name, this.upper, this.lower);
final String name;
final String upper;
final String lower;
}
const Map<String, _HexagramShort> _hexagramMap = {
'111111': _HexagramShort('乾为天', '', ''),
'011111': _HexagramShort('天风姤', '', ''),
'001111': _HexagramShort('天山遁', '', ''),
'000111': _HexagramShort('天地否', '', ''),
'000011': _HexagramShort('风地观', '', ''),
'000001': _HexagramShort('山地剥', '', ''),
'000101': _HexagramShort('火地晋', '', ''),
'111101': _HexagramShort('火天大有', '', ''),
'010010': _HexagramShort('坎为水', '', ''),
'110010': _HexagramShort('水泽节', '', ''),
'100010': _HexagramShort('水雷屯', '', ''),
'101010': _HexagramShort('水火既济', '', ''),
'101110': _HexagramShort('泽火革', '', ''),
'101100': _HexagramShort('雷火丰', '', ''),
'101000': _HexagramShort('地火明夷', '', ''),
'010000': _HexagramShort('地水师', '', ''),
'001001': _HexagramShort('艮为山', '', ''),
'101001': _HexagramShort('山火贲', '', ''),
'111001': _HexagramShort('山天大畜', '', ''),
'110001': _HexagramShort('山泽损', '', ''),
'110101': _HexagramShort('火泽睽', '', ''),
'110111': _HexagramShort('天泽履', '', ''),
'110011': _HexagramShort('风泽中孚', '', ''),
'001011': _HexagramShort('风山渐', '', ''),
'100100': _HexagramShort('震为雷', '', ''),
'000100': _HexagramShort('雷地豫', '', ''),
'010100': _HexagramShort('雷水解', '', ''),
'011100': _HexagramShort('雷风恒', '', ''),
'011000': _HexagramShort('地风升', '', ''),
'011010': _HexagramShort('水风井', '', ''),
'011110': _HexagramShort('泽风大过', '', ''),
'100110': _HexagramShort('泽雷随', '', ''),
'011011': _HexagramShort('巽为风', '', ''),
'111011': _HexagramShort('风天小畜', '', ''),
'101011': _HexagramShort('风火家人', '', ''),
'100011': _HexagramShort('风雷益', '', ''),
'100111': _HexagramShort('天雷无妄', '', ''),
'100101': _HexagramShort('火雷噬嗑', '', ''),
'100001': _HexagramShort('山雷颐', '', ''),
'011001': _HexagramShort('山风蛊', '', ''),
'101101': _HexagramShort('离为火', '', ''),
'001101': _HexagramShort('火山旅', '', ''),
'011101': _HexagramShort('火风鼎', '', ''),
'010101': _HexagramShort('火水未济', '', ''),
'010001': _HexagramShort('山水蒙', '', ''),
'010011': _HexagramShort('风水涣', '', ''),
'010111': _HexagramShort('天水讼', '', ''),
'101111': _HexagramShort('天火同人', '', ''),
'000000': _HexagramShort('坤为地', '', ''),
'100000': _HexagramShort('地雷复', '', ''),
'110000': _HexagramShort('地泽临', '', ''),
'111000': _HexagramShort('地天泰', '', ''),
'111100': _HexagramShort('雷天大壮', '', ''),
'111110': _HexagramShort('泽天夬', '', ''),
'111010': _HexagramShort('水天需', '', ''),
'000010': _HexagramShort('水地比', '', ''),
'110110': _HexagramShort('兑为泽', '', ''),
'010110': _HexagramShort('泽水困', '', ''),
'000110': _HexagramShort('泽地萃', '', ''),
'001110': _HexagramShort('泽山咸', '', ''),
'001010': _HexagramShort('水山蹇', '', ''),
'001000': _HexagramShort('地山谦', '', ''),
'001100': _HexagramShort('雷山小过', '', ''),
'110100': _HexagramShort('雷泽归妹', '', ''),
};
@@ -0,0 +1,147 @@
import 'dart:math';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../apis/divination_api.dart';
import '../models/divination_backend_models.dart';
import '../models/divination_params.dart';
class DivinationRunService {
const DivinationRunService({required DivinationApi api}) : _api = api;
final DivinationApi _api;
static final Logger _logger = getLogger('features.divination.run_service');
Future<DivinationRunAggregate> run({
required DivinationParams params,
required List<YaoType> yaoStates,
void Function()? onDerived,
void Function()? onTextMessageEnd,
}) async {
final threadId = _uuidV4();
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
await _api.enqueueRun(
params: params,
yaoStates: yaoStates,
threadId: threadId,
runId: runId,
);
DerivedDivinationData? derived;
String signLevel = '';
String summary = '';
List<String> conclusion = const <String>[];
List<String> focusPoints = const <String>[];
List<String> advice = const <String>[];
List<String> keywords = const <String>[];
String answer = '';
await for (final event in _api.streamEvents(
threadId: threadId,
runId: runId,
)) {
final type = event['type'] as String? ?? '';
_logger.debug(
message: 'Received run SSE event',
extra: <String, dynamic>{
'threadId': threadId,
'runId': runId,
'type': type,
},
);
if (type == 'DIVINATION_DERIVED') {
final payload = event['divination'];
if (payload is! Map<String, dynamic>) {
throw const FormatException('DIVINATION_DERIVED 缺少 divination 结构');
}
derived = DerivedDivinationData.fromJson(payload);
onDerived?.call();
continue;
}
if (type == 'TEXT_MESSAGE_END') {
signLevel = _requiredString(event, 'sign_level');
summary = _requiredString(event, 'summary');
conclusion = _requiredStringList(event, 'conclusion');
focusPoints = _requiredStringList(event, 'focus_points');
advice = _requiredStringList(event, 'advice');
keywords = _requiredStringList(event, 'keywords');
answer = _requiredString(event, 'answer');
onTextMessageEnd?.call();
continue;
}
if (type == 'RUN_ERROR') {
_logger.warning(
message: 'Run ended with RUN_ERROR event',
extra: <String, dynamic>{
'threadId': threadId,
'runId': runId,
'code': event['code'],
'detail': event['detail'],
'message': event['message'],
},
);
throw ApiProblem(
status: 500,
title: 'Run failed',
detail: event['detail'] as String? ?? '解卦失败',
code: event['code'] as String?,
);
}
if (type == 'RUN_FINISHED') {
break;
}
}
if (derived == null) {
_logger.warning(
message: 'Missing DIVINATION_DERIVED before RUN_FINISHED',
extra: <String, dynamic>{'threadId': threadId, 'runId': runId},
);
throw const FormatException('未收到 DIVINATION_DERIVED 事件');
}
return DivinationRunAggregate(
derived: derived,
signLevel: signLevel,
summary: summary,
conclusion: conclusion,
focusPoints: focusPoints,
advice: advice,
keywords: keywords,
answer: answer,
);
}
String _requiredString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! String || value.isEmpty) {
throw FormatException('缺少字段: $key');
}
return value;
}
List<String> _requiredStringList(Map<String, dynamic> json, String key) {
final raw = json[key];
if (raw is! List<dynamic>) {
throw FormatException('缺少列表字段: $key');
}
return raw
.map((item) {
if (item is! String || item.isEmpty) {
throw FormatException('字段 $key 存在非法项');
}
return item;
})
.toList(growable: false);
}
String _uuidV4() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String toHex(int b) => b.toRadixString(16).padLeft(2, '0');
final b = bytes.map(toHex).toList(growable: false);
return '${b[0]}${b[1]}${b[2]}${b[3]}-${b[4]}${b[5]}-${b[6]}${b[7]}-${b[8]}${b[9]}-${b[10]}${b[11]}${b[12]}${b[13]}${b[14]}${b[15]}';
}
}
@@ -7,8 +7,6 @@ import 'package:intl/intl.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:vibration/vibration.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
@@ -16,11 +14,9 @@ import '../../../../shared/widgets/divination/divination_terms.dart';
import '../../../../shared/widgets/divination/yao_legend.dart';
import '../../../../shared/widgets/divination/yao_line_row.dart';
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/divination_params.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_result_screen.dart';
import 'divination_processing_screen.dart';
class AutoDivinationScreen extends StatefulWidget {
const AutoDivinationScreen({
@@ -220,35 +216,22 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
setState(() {
_submitting = true;
});
try {
final aggregate = await widget.runService.run(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _yaoStates,
);
if (!mounted) {
return;
}
Navigator.of(context).push(
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: aggregate.toViewData(
widget.params.copyWith(divinationTime: _selectedTime),
),
builder: (_) => DivinationProcessingScreen(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _yaoStates,
runService: widget.runService,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
Toast.show(context, message, type: ToastType.error);
} finally {
if (!mounted) {
return;
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_result_screen.dart';
enum _ProcessingStep { preparing, deriving, done }
class DivinationProcessingScreen extends StatefulWidget {
const DivinationProcessingScreen({
super.key,
required this.params,
required this.yaoStates,
required this.runService,
});
final DivinationParams params;
final List<YaoType> yaoStates;
final DivinationRunService runService;
@override
State<DivinationProcessingScreen> createState() =>
_DivinationProcessingScreenState();
}
class _DivinationProcessingScreenState
extends State<DivinationProcessingScreen> {
static final Logger _logger = getLogger(
'features.divination.processing_screen',
);
_ProcessingStep _step = _ProcessingStep.preparing;
DivinationResultData? _resultData;
String? _errorMessage;
@override
void initState() {
super.initState();
_startRun();
}
Future<void> _startRun() async {
try {
final aggregate = await widget.runService.run(
params: widget.params,
yaoStates: widget.yaoStates,
onDerived: () {
if (!mounted) {
return;
}
setState(() {
_step = _ProcessingStep.deriving;
});
},
onTextMessageEnd: () {
if (!mounted) {
return;
}
setState(() {
_step = _ProcessingStep.done;
});
},
);
if (!mounted) {
return;
}
setState(() {
_resultData = aggregate.toViewData(widget.params);
_step = _ProcessingStep.done;
});
} catch (error, stackTrace) {
_logger.error(
message: 'Divination processing failed while waiting result events',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{
'step': _step.name,
'method': widget.params.method.name,
'questionType': widget.params.questionType.name,
},
);
if (!mounted) {
return;
}
final l10n = AppLocalizations.of(context)!;
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
setState(() {
_errorMessage = message;
});
Toast.show(context, message, type: ToastType.error);
}
}
void _openResult() {
final data = _resultData;
if (data == null) {
return;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(data: data),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final text = switch (_step) {
_ProcessingStep.preparing => l10n.transitionPreparing,
_ProcessingStep.deriving => l10n.transitionDeriving,
_ProcessingStep.done => l10n.transitionDone,
};
final canContinue = _step == _ProcessingStep.done && _resultData != null;
return Scaffold(
backgroundColor: colors.surface,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: _errorMessage == null
? GestureDetector(
onTap: canContinue ? _openResult : null,
child: Container(
width: 220,
height: 320,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: colors.primary.withValues(alpha: 0.2),
),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.25),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
canContinue ? Icons.visibility : Icons.auto_awesome,
color: colors.primary,
size: 34,
),
const SizedBox(height: AppSpacing.md),
Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
)
: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: colors.error),
),
),
),
),
);
}
}
@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -25,41 +24,30 @@ class DivinationResultScreen extends StatefulWidget {
State<DivinationResultScreen> createState() => _DivinationResultScreenState();
}
enum _ResultTransitionStep { preparing, deriving, done }
class _DivinationResultScreenState extends State<DivinationResultScreen> {
_ResultTransitionStep _step = _ResultTransitionStep.preparing;
bool _showOverlay = true;
bool _showIntro = true;
bool _introCollapsed = false;
@override
void initState() {
super.initState();
_playSequence();
_playIntro();
}
Future<void> _playSequence() async {
await Future<void>.delayed(const Duration(milliseconds: 420));
Future<void> _playIntro() async {
await Future<void>.delayed(const Duration(milliseconds: 120));
if (!mounted) {
return;
}
setState(() {
_step = _ResultTransitionStep.deriving;
_introCollapsed = true;
});
await Future<void>.delayed(const Duration(milliseconds: 820));
await Future<void>.delayed(const Duration(milliseconds: 760));
if (!mounted) {
return;
}
setState(() {
_step = _ResultTransitionStep.done;
});
}
void _dismissOverlay() {
if (_step != _ResultTransitionStep.done) {
return;
}
setState(() {
_showOverlay = false;
_showIntro = false;
});
}
@@ -78,172 +66,106 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
),
body: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.xl,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ResultHeader(data: widget.data),
const SizedBox(height: AppSpacing.md),
_SignCard(signType: widget.data.signType),
const SizedBox(height: AppSpacing.md),
_KeywordCard(keywords: widget.data.keywords),
const SizedBox(height: AppSpacing.md),
_AnalysisCard(
title: l10n.resultConclusion,
content: widget.data.conclusion,
),
const SizedBox(height: AppSpacing.md),
_AnalysisCard(
title: l10n.resultAnalysis,
content: widget.data.analysis,
),
const SizedBox(height: AppSpacing.md),
_AnalysisCard(
title: l10n.resultSuggestion,
content: widget.data.suggestion,
),
const SizedBox(height: AppSpacing.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: palette.warningContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
AnimatedOpacity(
opacity: _showIntro ? 0 : 1,
duration: const Duration(milliseconds: 260),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.xl,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ResultHeader(data: widget.data),
const SizedBox(height: AppSpacing.md),
_SignCard(signType: widget.data.signType),
const SizedBox(height: AppSpacing.md),
_KeywordCard(keywords: widget.data.keywords),
const SizedBox(height: AppSpacing.md),
_AnalysisCard(
title: l10n.resultConclusion,
content: widget.data.conclusion,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning, color: palette.warning, size: 20),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
l10n.resultWarning,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: palette.warning,
fontWeight: FontWeight.w600,
height: 1.35,
),
const SizedBox(height: AppSpacing.md),
_AnalysisCard(
title: l10n.resultAnalysis,
content: widget.data.analysis,
),
const SizedBox(height: AppSpacing.md),
_AnalysisCard(
title: l10n.resultSuggestion,
content: widget.data.suggestion,
),
const SizedBox(height: AppSpacing.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: palette.warningContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning, color: palette.warning, size: 20),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
l10n.resultWarning,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: palette.warning,
fontWeight: FontWeight.w600,
height: 1.35,
),
),
),
),
],
),
),
const SizedBox(height: AppSpacing.xl),
Text(
l10n.resultBasicInfo,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: AppSpacing.md),
_InfoCard(data: widget.data),
const SizedBox(height: AppSpacing.xl),
Text(
l10n.resultHexagramDetail,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: AppSpacing.md),
_HexagramDetailCard(data: widget.data),
],
),
),
if (_showOverlay)
_ResultTransitionOverlay(step: _step, onTapDone: _dismissOverlay),
],
),
);
}
}
class _ResultTransitionOverlay extends StatelessWidget {
const _ResultTransitionOverlay({required this.step, required this.onTapDone});
final _ResultTransitionStep step;
final VoidCallback onTapDone;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final cardText = switch (step) {
_ResultTransitionStep.preparing => l10n.transitionPreparing,
_ResultTransitionStep.deriving => l10n.transitionDeriving,
_ResultTransitionStep.done => l10n.transitionDone,
};
return Positioned.fill(
child: Material(
color: colors.surface,
child: Center(
child: GestureDetector(
key: const Key('result_transition_overlay_tap'),
onTap: onTapDone,
child: TweenAnimationBuilder<double>(
key: ValueKey<_ResultTransitionStep>(step),
tween: Tween<double>(begin: pi / 2, end: 0),
duration: const Duration(milliseconds: 460),
curve: Curves.easeOutCubic,
builder: (context, angle, child) {
final opacity = (1 - angle / (pi / 2)).clamp(0.0, 1.0);
return Opacity(
opacity: opacity,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: child,
),
);
},
child: Container(
width: 220,
height: 320,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: colors.primary.withValues(alpha: 0.2),
),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.25),
blurRadius: 22,
offset: const Offset(0, 12),
],
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
step == _ResultTransitionStep.done
? Icons.visibility
: Icons.auto_awesome,
color: colors.primary,
size: 34,
),
const SizedBox(height: AppSpacing.md),
Text(
cardText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
height: 1.4,
),
),
],
),
),
const SizedBox(height: AppSpacing.xl),
Text(
l10n.resultBasicInfo,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: AppSpacing.md),
_InfoCard(data: widget.data),
const SizedBox(height: AppSpacing.xl),
Text(
l10n.resultHexagramDetail,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: AppSpacing.md),
_HexagramDetailCard(data: widget.data),
],
),
),
),
),
if (_showIntro)
Positioned.fill(
child: Material(
color: colors.surface,
child: SafeArea(
child: AnimatedAlign(
duration: const Duration(milliseconds: 760),
curve: Curves.easeInOutCubic,
alignment: _introCollapsed
? const Alignment(0, -0.86)
: Alignment.center,
child: AnimatedContainer(
duration: const Duration(milliseconds: 760),
curve: Curves.easeInOutCubic,
width: _introCollapsed ? 150 : 290,
child: _SignCard(signType: widget.data.signType),
),
),
),
),
),
],
),
);
}
@@ -1,17 +1,30 @@
import 'package:flutter/material.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/auth/session_store.dart';
import '../../../../data/network/api_client.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/apis/divination_api.dart';
import '../../data/models/divination_params.dart';
import '../../data/services/divination_run_service.dart';
import 'auto_divination_screen.dart';
import 'manual_divination_screen.dart';
class DivinationScreen extends StatefulWidget {
const DivinationScreen({super.key});
const DivinationScreen({
super.key,
required this.sessionStore,
required this.userId,
this.runServiceOverride,
});
final SessionStore sessionStore;
final String userId;
final DivinationRunService? runServiceOverride;
@override
State<DivinationScreen> createState() => _DivinationScreenState();
@@ -20,11 +33,26 @@ class DivinationScreen extends StatefulWidget {
class _DivinationScreenState extends State<DivinationScreen> {
late DivinationParams _params;
final TextEditingController _questionController = TextEditingController();
late final DivinationRunService _runService;
@override
void initState() {
super.initState();
_params = DivinationMockData.initial();
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: widget.sessionStore.getToken,
);
_runService =
widget.runServiceOverride ??
DivinationRunService(api: DivinationApi(apiClient: apiClient));
_params = DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '',
divinationTime: DateTime.now(),
coinBalance: 0,
userId: widget.userId,
);
_questionController.addListener(_syncQuestion);
}
@@ -58,7 +86,6 @@ class _DivinationScreenState extends State<DivinationScreen> {
}
Widget _buildBody(BuildContext context, AppLocalizations l10n) {
final palette = Theme.of(context).extension<AppColorPalette>()!;
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SingleChildScrollView(
@@ -106,15 +133,6 @@ class _DivinationScreenState extends State<DivinationScreen> {
_QuestionTextField(controller: _questionController, l10n: l10n),
const SizedBox(height: AppSpacing.xl),
_StartButton(onPressed: _onStart, l10n: l10n),
const SizedBox(height: AppSpacing.sm),
Center(
child: Text(
l10n.divinationCoinBalance(_params.coinBalance),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.warning),
),
),
],
),
),
@@ -132,16 +150,14 @@ class _DivinationScreenState extends State<DivinationScreen> {
return;
}
if (_params.coinBalance <= 0) {
Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning);
return;
}
if (_params.method == DivinationMethod.manual) {
final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ManualDivinationScreen(params: nextParams),
builder: (_) => ManualDivinationScreen(
params: nextParams,
runService: _runService,
),
),
);
return;
@@ -150,7 +166,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AutoDivinationScreen(params: nextParams),
builder: (_) =>
AutoDivinationScreen(params: nextParams, runService: _runService),
),
);
}
@@ -2,20 +2,16 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/divination/divination_terms.dart';
import '../../../../shared/widgets/divination/yao_legend.dart';
import '../../../../shared/widgets/divination/yao_line_row.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../data/models/divination_params.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_result_screen.dart';
import 'divination_processing_screen.dart';
class ManualDivinationScreen extends StatefulWidget {
const ManualDivinationScreen({
@@ -126,12 +122,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
builder: (_) {
return DivinationGuideDialog(
title: l10n.manualSelectYaoTitle,
guideImages: const [
'assets/images/qigua/lc2.jpg',
'assets/images/qigua/lc3.jpg',
'assets/images/qigua/lc4.jpg',
'assets/images/qigua/lc5.jpg',
],
guideImages: const ['lc2.jpg', 'lc3.jpg', 'lc4.jpg', 'lc5.jpg'],
instructionText: l10n.manualYaoTipContent,
);
},
@@ -180,35 +171,22 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
setState(() {
_submitting = true;
});
try {
final aggregate = await widget.runService.run(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _selectedYaos.cast<YaoType>(),
);
if (!mounted) {
return;
}
Navigator.of(context).push(
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: aggregate.toViewData(
widget.params.copyWith(divinationTime: _selectedTime),
),
builder: (_) => DivinationProcessingScreen(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _selectedYaos.cast<YaoType>(),
runService: widget.runService,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
Toast.show(context, message, type: ToastType.error);
} finally {
if (!mounted) {
return;
@@ -373,70 +351,206 @@ class _YaoSelectionCard extends StatelessWidget {
void Function(int, YaoType) onSelect,
) {
final l10n = AppLocalizations.of(context)!;
final options = <(String, YaoType)>[
final colors = Theme.of(context).colorScheme;
final options = <(String, YaoType, String)>[
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]} (\u2014)\uFF1A\u82B1\u5B57\u5B57',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]}${DivinationTerms.youngYangSymbol}',
YaoType.youngYang,
'字字字',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]} (--)\uFF1A\u5B57\u82B1\u82B1',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]}${DivinationTerms.youngYinSymbol}',
YaoType.youngYin,
'花花花',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]} (\u2014\u25CB)\uFF1A\u82B1\u82B1\u82B1',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]}${DivinationTerms.oldYangSymbol}',
YaoType.oldYang,
'字字字',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]} (--\u00D7)\uFF1A\u5B57\u5B57\u5B57',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]}${DivinationTerms.oldYinSymbol}',
YaoType.oldYin,
'花花花',
),
];
return showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.manualSelectYaoTitle),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final option in options)
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: OutlinedButton(
onPressed: () {
onSelect(yaoIndex, option.$2);
Navigator.of(context).pop();
},
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.manualSelectYaoTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
child: Text(option.$1),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: AppSpacing.md),
...options.map((option) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: _YaoOptionCard(
label: option.$1,
pattern: option.$3,
isSelected: false,
onTap: () {
onSelect(yaoIndex, option.$2);
Navigator.of(context).pop();
},
),
);
}),
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.coinFaceGuideTitle,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.sm),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Image.asset(
'assets/images/qigua/zihua.jpg',
width: double.infinity,
height: 120,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(
height: 120,
color: colors.errorContainer,
child: Center(
child: Text(
l10n.divinationClose,
style: TextStyle(
color: colors.onErrorContainer,
),
),
),
),
),
),
const SizedBox(height: AppSpacing.xs),
Text(
l10n.coinFaceGuideDescription,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colors.onSurfaceVariant),
),
],
),
),
const SizedBox(height: AppSpacing.sm),
const Align(
alignment: Alignment.centerLeft,
child: Text('字花图片说明:'),
),
const SizedBox(height: AppSpacing.sm),
Image.asset(
'assets/images/qigua/zihua.jpg',
width: double.infinity,
height: 180,
fit: BoxFit.contain,
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.divinationClose),
),
],
);
},
);
}
}
class _YaoOptionCard extends StatelessWidget {
const _YaoOptionCard({
required this.label,
required this.pattern,
required this.isSelected,
required this.onTap,
});
final String label;
final String pattern;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Material(
color: isSelected ? colors.primaryContainer : colors.surface,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm + 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isSelected ? colors.primary : colors.outlineVariant,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: isSelected
? colors.onPrimaryContainer
: colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
pattern,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isSelected
? colors.onPrimaryContainer.withValues(alpha: 0.7)
: colors.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: isSelected
? colors.onPrimaryContainer
: colors.onSurfaceVariant,
),
],
),
),
),
);
}
}
@@ -278,9 +278,14 @@ class _HomeScreenState extends State<HomeScreen> {
}
void _onStartDivination() {
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const DivinationScreen()));
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationScreen(
sessionStore: widget.sessionStore,
userId: widget.account,
),
),
);
}
void _showSnack(BuildContext context, String message) {
@@ -4,7 +4,6 @@ import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../data/models/profile_settings.dart';
import 'language_settings_screen.dart';
import 'settings_placeholder_screen.dart';
import '../widgets/settings_section_widgets.dart';
class GeneralSettingsScreen extends StatefulWidget {
@@ -70,50 +69,8 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
),
tint: colors.primary,
background: colors.surfaceContainerHighest,
onTap: _openLanguageSettings,
),
SettingsMenuTile(
icon: Icons.auto_awesome_rounded,
title: l10n.settingsAiLanguage,
subtitle: displayLanguageLabel(
l10n,
_settings.preferences.aiLanguage,
),
tint: colors.secondary,
background: colors.surfaceContainerHighest,
onTap: () => _openPlaceholder(
title: l10n.settingsAiLanguage,
value: displayLanguageLabel(
l10n,
_settings.preferences.aiLanguage,
),
description: l10n.settingsAiLanguageHint,
),
),
SettingsMenuTile(
icon: Icons.public_rounded,
title: l10n.settingsTimezone,
subtitle: _settings.preferences.timezone,
tint: colors.primary,
background: colors.surfaceContainerHighest,
onTap: () => _openPlaceholder(
title: l10n.settingsTimezone,
value: _settings.preferences.timezone,
description: l10n.settingsTimezoneHint,
),
),
SettingsMenuTile(
icon: Icons.flag_outlined,
title: l10n.settingsCountry,
subtitle: _settings.preferences.country,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onTap: () => _openPlaceholder(
title: l10n.settingsCountry,
value: _settings.preferences.country,
description: l10n.settingsCountryHint,
),
onTap: _openLanguageSettings,
),
],
),
@@ -144,20 +101,4 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
);
});
}
Future<void> _openPlaceholder({
required String title,
required String value,
required String description,
}) async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => SettingsPlaceholderScreen(
title: title,
value: value,
description: description,
),
),
);
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
@@ -15,9 +16,23 @@ class LegalDocumentScreen extends StatelessWidget {
final String title;
final String assetPath;
Future<String> _loadAsset() async {
try {
final data = await rootBundle.loadString(assetPath);
if (data.isEmpty) {
throw Exception('Asset file is empty: $assetPath');
}
return data;
} catch (e) {
debugPrint('Failed to load asset: $assetPath, error: $e');
rethrow;
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
@@ -27,8 +42,43 @@ class LegalDocumentScreen extends StatelessWidget {
surfaceTintColor: colors.surfaceContainerLow,
),
body: FutureBuilder<String>(
future: rootBundle.loadString(assetPath),
future: _loadAsset(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colors.error),
const SizedBox(height: AppSpacing.md),
Text(
l10n.legalDocumentLoadFailedTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(
'${l10n.legalDocumentLoadFailedPathPrefix}: $assetPath',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colors.error),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.xs),
Text(
'${l10n.legalDocumentLoadFailedErrorPrefix}: ${snapshot.error}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colors.error),
textAlign: TextAlign.center,
),
],
),
),
);
}
if (!snapshot.hasData) {
return const Center(
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
@@ -1,14 +1,12 @@
import 'package:flutter/material.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../data/models/profile_settings.dart';
import '../widgets/settings_section_widgets.dart';
import 'coin_center_screen.dart';
import 'general_settings_screen.dart';
import 'legal_center_screen.dart';
import 'privacy_notification_settings_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({
@@ -44,7 +42,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
@@ -62,10 +59,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
AppSpacing.xl,
),
children: [
ProfileHeaderCard(
account: widget.account,
version: _settings.version,
),
ProfileHeaderCard(account: widget.account),
const SizedBox(height: AppSpacing.lg),
WalletHeroCard(
balance: widget.coinBalance,
@@ -76,39 +70,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
SectionLabel(text: l10n.settingsSectionQuickAccess),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.toll_rounded,
title: l10n.settingsCoinCenterTitle,
subtitle: l10n.settingsCoinCenterSubtitle(widget.coinBalance),
tint: palette.historyGoldText,
background: palette.historyGoldBg,
onTap: _openCoinCenter,
),
SettingsMenuTile(
icon: Icons.tune_rounded,
title: l10n.settingsGeneralTitle,
subtitle: l10n.settingsGeneralSubtitle(
displayLanguageLabel(
l10n,
_settings.preferences.interfaceLanguage,
),
),
tint: colors.primary,
background: colors.surfaceContainerHighest,
onTap: _openGeneralSettings,
),
SettingsMenuTile(
icon: Icons.privacy_tip_outlined,
title: l10n.settingsPrivacyAndNotificationTitle,
subtitle: l10n.settingsPrivacyAndNotificationSubtitle,
tint: palette.warning,
background: palette.warningContainer,
onTap: _openPrivacyAndNotification,
),
SettingsMenuTile(
icon: Icons.description_outlined,
title: l10n.settingsLegalCenterTitle,
subtitle: l10n.settingsLegalCenterSubtitle,
tint: colors.secondary,
background: colors.surfaceContainerHighest,
showDivider: false,
@@ -117,21 +88,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
const SizedBox(height: AppSpacing.xl),
SectionLabel(text: l10n.settingsSectionAccount),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.logout_rounded,
title: l10n.logout,
subtitle: l10n.settingsLogoutSubtitle,
tint: colors.error,
background: colors.error.withValues(alpha: 0.08),
showDivider: false,
onTap: _confirmLogout,
),
],
),
const SizedBox(height: AppSpacing.xl),
FilledButton(
onPressed: _isLoggingOut ? null : _confirmLogout,
style: FilledButton.styleFrom(
@@ -175,14 +131,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _openPrivacyAndNotification() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => PrivacyNotificationSettingsScreen(settings: _settings),
),
);
}
Future<void> _openLegalCenter() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(builder: (_) => const LegalCenterScreen()),
@@ -55,18 +55,18 @@ class SettingsMenuTile extends StatelessWidget {
super.key,
required this.icon,
required this.title,
required this.subtitle,
required this.tint,
required this.background,
required this.onTap,
this.showDivider = true,
this.showChevron = true,
this.trailing,
this.subtitle,
});
final IconData icon;
final String title;
final String subtitle;
final String? subtitle;
final Color tint;
final Color background;
final VoidCallback onTap;
@@ -95,10 +95,12 @@ class SettingsMenuTile extends StatelessWidget {
child: Icon(icon, color: tint),
),
title: Text(title),
subtitle: Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs),
child: Text(subtitle),
),
subtitle: subtitle == null
? null
: Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs),
child: Text(subtitle!),
),
trailing:
trailing ??
(showChevron
@@ -118,18 +120,12 @@ class SettingsMenuTile extends StatelessWidget {
}
class ProfileHeaderCard extends StatelessWidget {
const ProfileHeaderCard({
super.key,
required this.account,
required this.version,
});
const ProfileHeaderCard({super.key, required this.account});
final String account;
final int version;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Card(
margin: EdgeInsets.zero,
@@ -153,14 +149,10 @@ class ProfileHeaderCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(account, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: AppSpacing.xs),
Text(
'${l10n.settingsVersionLabel}: v$version',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Icon(Icons.edit_outlined, color: colors.outline, size: 20),
],
),
),
@@ -185,17 +177,18 @@ class WalletHeroCard extends StatelessWidget {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.xl),
gradient: LinearGradient(
colors: [colors.primary, palette.accentPurple],
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.xl),
gradient: LinearGradient(
colors: [colors.primary, palette.accentPurple],
),
),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -213,6 +206,13 @@ class WalletHeroCard extends StatelessWidget {
color: colors.onPrimary,
),
),
const SizedBox(width: AppSpacing.lg),
Text(
l10n.settingsCoinBalanceValue(balance),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: colors.onPrimary,
),
),
const Spacer(),
Icon(
Icons.chevron_right_rounded,
@@ -220,18 +220,14 @@ class WalletHeroCard extends StatelessWidget {
),
],
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.settingsCoinBalanceValue(balance),
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(color: colors.onPrimary),
),
const SizedBox(height: AppSpacing.xs),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.92),
const SizedBox(height: AppSpacing.md),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.92),
),
),
),
],
+6 -1
View File
@@ -24,6 +24,9 @@
"privacyPolicySubtitle": "Learn how we protect user privacy",
"termsOfService": "Terms of Service",
"termsOfServiceSubtitle": "Learn the service agreement for users",
"legalDocumentLoadFailedTitle": "Failed to load document",
"legalDocumentLoadFailedPathPrefix": "Path",
"legalDocumentLoadFailedErrorPrefix": "Error",
"disclaimer": "Disclaimer",
"icp": "Yue ICP 2025428416-1A",
"invalidPhone": "Please enter a valid phone number",
@@ -306,5 +309,7 @@
"timeTab": "Time",
"confirm": "Confirm",
"cancel": "Cancel",
"autoSelectTime": "Select Time"
"autoSelectTime": "Select Time",
"coinFaceGuideTitle": "Coin Face Guide",
"coinFaceGuideDescription": "字 = side with inscription\n花 = side with pattern"
}
+30
View File
@@ -200,6 +200,24 @@ abstract class AppLocalizations {
/// **'了解用户服务协议'**
String get termsOfServiceSubtitle;
/// No description provided for @legalDocumentLoadFailedTitle.
///
/// In zh, this message translates to:
/// **'文档加载失败'**
String get legalDocumentLoadFailedTitle;
/// No description provided for @legalDocumentLoadFailedPathPrefix.
///
/// In zh, this message translates to:
/// **'路径'**
String get legalDocumentLoadFailedPathPrefix;
/// No description provided for @legalDocumentLoadFailedErrorPrefix.
///
/// In zh, this message translates to:
/// **'错误'**
String get legalDocumentLoadFailedErrorPrefix;
/// No description provided for @disclaimer.
///
/// In zh, this message translates to:
@@ -1429,6 +1447,18 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'取消'**
String get cancel;
/// No description provided for @coinFaceGuideTitle.
///
/// In zh, this message translates to:
/// **'字花图片说明'**
String get coinFaceGuideTitle;
/// No description provided for @coinFaceGuideDescription.
///
/// In zh, this message translates to:
/// **'字=铜钱有字的一面\n花=铜钱有花纹的一面'**
String get coinFaceGuideDescription;
}
class _AppLocalizationsDelegate
+16
View File
@@ -62,6 +62,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get termsOfServiceSubtitle => 'Learn the service agreement for users';
@override
String get legalDocumentLoadFailedTitle => 'Failed to load document';
@override
String get legalDocumentLoadFailedPathPrefix => 'Path';
@override
String get legalDocumentLoadFailedErrorPrefix => 'Error';
@override
String get disclaimer => 'Disclaimer';
@@ -731,4 +740,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cancel => 'Cancel';
@override
String get coinFaceGuideTitle => 'Coin Face Guide';
@override
String get coinFaceGuideDescription =>
'字 = side with inscription\n花 = side with pattern';
}
+15
View File
@@ -61,6 +61,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get termsOfServiceSubtitle => '了解用户服务协议';
@override
String get legalDocumentLoadFailedTitle => '文档加载失败';
@override
String get legalDocumentLoadFailedPathPrefix => '路径';
@override
String get legalDocumentLoadFailedErrorPrefix => '错误';
@override
String get disclaimer => '免责声明';
@@ -712,4 +721,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get cancel => '取消';
@override
String get coinFaceGuideTitle => '字花图片说明';
@override
String get coinFaceGuideDescription => '字=铜钱有字的一面\n花=铜钱有花纹的一面';
}
+6 -1
View File
@@ -24,6 +24,9 @@
"privacyPolicySubtitle": "了解用户隐私保护政策",
"termsOfService": "服务条款",
"termsOfServiceSubtitle": "了解用户服务协议",
"legalDocumentLoadFailedTitle": "文档加载失败",
"legalDocumentLoadFailedPathPrefix": "路径",
"legalDocumentLoadFailedErrorPrefix": "错误",
"disclaimer": "免责声明",
"icp": "粤ICP备2025428416号-1A",
"invalidPhone": "请输入正确的手机号码",
@@ -306,5 +309,7 @@
"timeTab": "时间",
"confirm": "确认",
"cancel": "取消",
"autoSelectTime": "选择时间"
"autoSelectTime": "选择时间",
"coinFaceGuideTitle": "字花图片说明",
"coinFaceGuideDescription": "字=铜钱有字的一面\n花=铜钱有花纹的一面"
}
@@ -19,6 +19,12 @@ abstract final class DivinationTerms {
static const changeMarkOldYang = '';
static const changeMarkOldYin = '×';
static const yinYangSymbol = {true: '', false: '--'};
static const youngYangSymbol = '';
static const youngYinSymbol = '--';
static const oldYangSymbol = '—○';
static const oldYinSymbol = '--×';
static const signBest = '上上签';
static const signGood = '中上签';
static const signNormal = '中下签';
+2 -1
View File
@@ -79,7 +79,8 @@ flutter:
assets:
- assets/images/logo.png
- assets/images/qigua/
- assets/legal/
- assets/legal/en/
- assets/legal/zh/
# To add assets to your application, add an assets section, like this:
# assets:
@@ -0,0 +1,108 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
void main() {
test('buildDivinationRunPayload contains required AG-UI fields', () {
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '什么时候找到工作',
divinationTime: DateTime(2026, 4, 3, 18, 0, 2),
coinBalance: 0,
userId: 'u_test',
);
final payload = buildDivinationRunPayload(
params: params,
yaoStates: const <YaoType>[
YaoType.youngYang,
YaoType.youngYang,
YaoType.youngYang,
YaoType.oldYang,
YaoType.oldYin,
YaoType.youngYang,
],
threadId: 'de44f2fb-de0a-46b9-bbf2-e99ee36f2a2d',
runId: 'run_1775210431957',
clientNow: DateTime(2026, 4, 3, 18, 0, 31, 958),
);
expect(payload['state'], isA<Map<String, dynamic>>());
expect(payload['tools'], isA<List<dynamic>>());
expect(payload['context'], isA<List<dynamic>>());
final forwardedProps = payload['forwardedProps'] as Map<String, dynamic>;
expect(forwardedProps['runtime_mode'], 'chat');
final clientTime = forwardedProps['client_time'] as Map<String, dynamic>;
expect((clientTime['client_now_iso'] as String).endsWith('Z'), isTrue);
final divinationPayload =
forwardedProps['divinationPayload'] as Map<String, dynamic>;
expect(
(divinationPayload['divinationTimeIso'] as String).endsWith('Z'),
isTrue,
);
expect((divinationPayload['yaoLines'] as List<dynamic>).length, 6);
final messages = payload['messages'] as List<dynamic>;
expect(messages.length, 1);
final userMessage = messages.first as Map<String, dynamic>;
expect(userMessage['id'], isNotEmpty);
expect(userMessage['role'], 'user');
expect(userMessage['content'], '什么时候找到工作');
});
test('buildDivinationRunPayload throws when yaoStates length is not 6', () {
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '测试',
divinationTime: DateTime(2026, 4, 3, 18, 0, 2),
coinBalance: 0,
userId: 'u_test',
);
expect(
() => buildDivinationRunPayload(
params: params,
yaoStates: const <YaoType>[YaoType.youngYang],
threadId: 'de44f2fb-de0a-46b9-bbf2-e99ee36f2a2d',
runId: 'run_1775210431957',
clientNow: DateTime(2026, 4, 3, 18, 0, 31, 958),
),
throwsArgumentError,
);
});
test(
'buildDivinationRunPayload throws when yaoStates contains undetermined',
() {
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '测试',
divinationTime: DateTime(2026, 4, 3, 18, 0, 2),
coinBalance: 0,
userId: 'u_test',
);
expect(
() => buildDivinationRunPayload(
params: params,
yaoStates: const <YaoType>[
YaoType.youngYang,
YaoType.youngYang,
YaoType.youngYang,
YaoType.oldYang,
YaoType.oldYin,
YaoType.undetermined,
],
threadId: 'de44f2fb-de0a-46b9-bbf2-e99ee36f2a2d',
runId: 'run_1775210431957',
clientNow: DateTime(2026, 4, 3, 18, 0, 31, 958),
),
throwsArgumentError,
);
},
);
}
@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_backend_models.dart';
void main() {
test('YaoBackendLine accepts empty specialMark', () {
final line = YaoBackendLine.fromJson(<String, dynamic>{
'position': 1,
'spiritName': '',
'relationName': '父母',
'tiganName': '',
'elementName': '',
'isYang': true,
'isChanging': false,
'specialMark': '',
});
expect(line.specialMark, '');
});
}
@@ -2,8 +2,15 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
void main() {
test('mock data contains valid defaults', () {
final params = DivinationMockData.initial();
test('params contains valid fields', () {
final params = DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '测试问题',
divinationTime: DateTime(2026, 4, 3, 10, 30),
coinBalance: 8,
userId: 'u_test',
);
expect(params.method, DivinationMethod.manual);
expect(params.questionType, QuestionType.career);
@@ -30,7 +37,14 @@ void main() {
});
test('toBinary and toChangedBinary mappings are correct', () {
final params = DivinationMockData.initial();
final params = DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '测试问题',
divinationTime: DateTime(2026, 4, 3, 10, 30),
coinBalance: 8,
userId: 'u_test',
);
final states = <YaoType>[
YaoType.oldYin,
YaoType.youngYang,
@@ -1,36 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
import 'package:meeyao_qianwen/features/divination/data/services/divination_result_builder.dart';
void main() {
final builder = DivinationResultBuilder();
test('build returns result with hexagram names and section text', () {
final params = DivinationMockData.initial().copyWith(
method: DivinationMethod.auto,
question: '近期工作是否会有突破',
questionType: QuestionType.career,
);
final result = builder.build(
params: params,
yaoStates: const [
YaoType.youngYang,
YaoType.youngYin,
YaoType.oldYang,
YaoType.youngYin,
YaoType.oldYin,
YaoType.youngYang,
],
);
expect(result.guaName, isNotEmpty);
expect(result.targetGuaName, isNotEmpty);
expect(result.binaryCode, hasLength(6));
expect(result.changedBinaryCode, hasLength(6));
expect(result.keywords, contains(''));
expect(result.conclusion, contains('这个卦象的结果为'));
expect(result.yaoLines.length, 6);
expect(result.targetYaoLines.length, 6);
});
}
@@ -1,43 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/app/app_theme.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
import 'package:meeyao_qianwen/features/divination/data/services/divination_result_builder.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_result.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/divination_result_screen.dart';
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
void main() {
testWidgets('result screen shows key sections', (tester) async {
final params = DivinationMockData.initial().copyWith(
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.health,
question: '近期状态是否平稳',
divinationTime: DateTime(2026, 4, 3, 20, 30),
coinBalance: 10,
userId: 'u_test',
);
final data = DivinationResultBuilder().build(
final data = DivinationResultData(
params: params,
yaoStates: const [
YaoType.oldYin,
YaoType.youngYang,
YaoType.youngYin,
YaoType.oldYang,
YaoType.youngYang,
YaoType.oldYin,
binaryCode: '101001',
changedBinaryCode: '100001',
guaName: '山火贲',
targetGuaName: '山雷颐',
upperName: '',
lowerName: '',
signType: '中上签',
keywords: '稳中求进、审时度势、蓄势待发',
conclusion: '1. 方向可行\n2. 节奏宜稳',
analysis: '当前阶段需先稳住节奏,再做关键推进。',
suggestion: '1. 控节奏\n2. 重复盘',
ganzhi: GanzhiData(
yearGanZhi: '丙午',
monthGanZhi: '辛卯',
dayGanZhi: '丁未',
timeGanZhi: '庚戌',
yearKongWang: '子丑',
monthKongWang: '戌亥',
dayKongWang: '寅卯',
timeKongWang: '寅卯',
yueJian: '卯木',
riChen: '未土',
yuePo: '酉金',
riChong: '丑土',
),
wuXingStatus: {'': '', '': '', '': '', '': '', '': ''},
yaoLines: [
YaoLineData(
index: 0,
spirit: '',
relation: '父母',
branch: '',
element: '',
type: YaoType.oldYin,
mark: '',
),
YaoLineData(
index: 1,
spirit: '',
relation: '兄弟',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 2,
spirit: '',
relation: '妻财',
branch: '',
element: '',
type: YaoType.youngYin,
mark: '',
),
YaoLineData(
index: 3,
spirit: '',
relation: '妻财',
branch: '',
element: '',
type: YaoType.oldYang,
mark: '',
),
YaoLineData(
index: 4,
spirit: '',
relation: '子孙',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 5,
spirit: '',
relation: '兄弟',
branch: '',
element: '',
type: YaoType.oldYin,
mark: '',
),
],
targetYaoLines: [
YaoLineData(
index: 0,
spirit: '',
relation: '父母',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 1,
spirit: '',
relation: '兄弟',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 2,
spirit: '',
relation: '妻财',
branch: '',
element: '',
type: YaoType.youngYin,
mark: '',
),
YaoLineData(
index: 3,
spirit: '',
relation: '妻财',
branch: '',
element: '',
type: YaoType.youngYin,
mark: '',
),
YaoLineData(
index: 4,
spirit: '',
relation: '子孙',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 5,
spirit: '',
relation: '兄弟',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
],
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: DivinationResultScreen(data: data),
),
);
await tester.pump();
expect(find.text('天机推演中'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 450));
expect(find.text('正在解卦'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 850));
expect(find.text('解卦完成\n点击查看'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1000));
await tester.pumpAndSettle();
expect(find.text('解卦结果'), findsOneWidget);
expect(find.text('AI解卦'), findsOneWidget);
@@ -1,15 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/app/app_theme.dart';
import 'package:meeyao_qianwen/core/auth/session_store.dart';
import 'package:meeyao_qianwen/data/network/api_client.dart';
import 'package:meeyao_qianwen/data/storage/local_kv_store.dart';
import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
import 'package:meeyao_qianwen/features/divination/data/services/divination_run_service.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/auto_divination_screen.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/divination_screen.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart';
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
void main() {
final runService = DivinationRunService(
api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')),
);
final sessionStore = SessionStore(LocalKvStore());
testWidgets('divination screen navigates to auto screen', (tester) async {
await tester.pumpWidget(
MaterialApp(theme: AppTheme.light(), home: const DivinationScreen()),
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: DivinationScreen(
sessionStore: sessionStore,
userId: 'user_test',
runServiceOverride: runService,
),
),
);
await tester.tap(find.text('自动起卦'));
@@ -25,15 +52,27 @@ void main() {
testWidgets('auto screen keeps resolve button disabled initially', (
tester,
) async {
final params = DivinationMockData.initial().copyWith(
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '测试问题',
divinationTime: DateTime(2026, 4, 3, 20, 30),
coinBalance: 9,
userId: 'user_test',
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AutoDivinationScreen(params: params),
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: AutoDivinationScreen(params: params, runService: runService),
),
);
@@ -49,7 +88,22 @@ void main() {
tester,
) async {
await tester.pumpWidget(
MaterialApp(theme: AppTheme.light(), home: const DivinationScreen()),
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: DivinationScreen(
sessionStore: sessionStore,
userId: 'user_test',
runServiceOverride: runService,
),
),
);
await tester.enterText(find.byType(TextField), '近期感情是否稳定');
@@ -1,20 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/app/app_theme.dart';
import 'package:meeyao_qianwen/data/network/api_client.dart';
import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
import 'package:meeyao_qianwen/features/divination/data/services/divination_run_service.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart';
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
void main() {
testWidgets('manual screen shows yao legend', (tester) async {
final params = DivinationMockData.initial().copyWith(
final params = DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '测试问题',
divinationTime: DateTime(2026, 4, 3, 20, 30),
coinBalance: 9,
userId: 'user_test',
);
final runService = DivinationRunService(
api: DivinationApi(
apiClient: ApiClient(baseUrl: 'http://localhost:5775'),
),
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: ManualDivinationScreen(params: params),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: ManualDivinationScreen(params: params, runService: runService),
),
);