feat: 实现用户画像、占卜历史与后端用户管理模块

This commit is contained in:
ZL-Q
2026-04-06 01:28:10 +08:00
parent d87b2e1e3a
commit 8a18b3528b
77 changed files with 5850 additions and 2604 deletions
+192 -5
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import '../core/auth/session_store.dart';
import '../core/logging/logger.dart';
import '../data/network/api_client.dart';
import '../data/storage/local_kv_store.dart';
import '../features/auth/data/apis/auth_api.dart';
@@ -10,7 +11,9 @@ 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/divination/data/models/divination_result.dart';
import '../features/home/presentation/screens/home_screen.dart';
import '../features/settings/data/apis/profile_api.dart';
import '../features/settings/data/models/profile_settings.dart';
import '../l10n/app_localizations.dart';
import '../shared/widgets/app_loading_indicator.dart';
@@ -25,9 +28,11 @@ class EryaoApp extends StatefulWidget {
}
class _EryaoAppState extends State<EryaoApp> {
static final Logger _logger = getLogger('app.eryao_app');
final SessionStore _sessionStore = SessionStore(LocalKvStore());
late final AuthBloc _authBloc;
late final DivinationApi _divinationApi;
late final ProfileApi _profileApi;
Locale _locale = const Locale('zh');
ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
@@ -35,6 +40,11 @@ class _EryaoAppState extends State<EryaoApp> {
int _creditsBalance = 0;
bool _loadingCredits = false;
String? _loadedCreditsUserEmail;
bool _loadingHistory = false;
String? _loadedHistoryUserEmail;
List<DivinationResultData> _historyRecords = const <DivinationResultData>[];
bool _loadingProfile = false;
String? _loadedProfileUserEmail;
@override
void initState() {
@@ -48,6 +58,7 @@ class _EryaoAppState extends State<EryaoApp> {
);
final authApi = AuthApi(apiClient: apiClient);
_divinationApi = DivinationApi(apiClient: apiClient);
_profileApi = ProfileApi(apiClient: apiClient);
final authRepository = AuthRepositoryImpl(
authApi: authApi,
sessionStore: _sessionStore,
@@ -64,22 +75,192 @@ class _EryaoAppState extends State<EryaoApp> {
return;
}
_loadingCredits = true;
_refreshCredits(userEmail: userEmail).whenComplete(() {
_loadingCredits = false;
});
}
void _ensureHistoryLoaded(String userEmail) {
if (_loadingHistory) {
return;
}
if (_loadedHistoryUserEmail == userEmail) {
return;
}
_loadingHistory = true;
_divinationApi
.getPointsBalance()
.then((balance) {
.getHistoryRecords(userId: userEmail)
.then((records) {
if (!mounted) {
return;
}
setState(() {
_creditsBalance = balance.availableBalance;
_loadedCreditsUserEmail = userEmail;
_historyRecords = records;
_loadedHistoryUserEmail = userEmail;
});
})
.catchError((Object error, StackTrace stackTrace) {
_logger.warning(
message: 'Failed to load divination history',
extra: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
})
.whenComplete(() {
_loadingCredits = false;
_loadingHistory = false;
});
}
Future<void> _refreshCredits({required String userEmail}) async {
final balance = await _divinationApi.getPointsBalance();
if (!mounted) {
return;
}
setState(() {
_creditsBalance = balance.availableBalance;
_loadedCreditsUserEmail = userEmail;
});
}
Future<void> _handleDivinationCompleted(DivinationResultData result) async {
final user = _authBloc.state.user;
if (user == null) {
return;
}
final optimisticRecords = _mergeAndSortHistory(<DivinationResultData>[
result,
..._historyRecords,
]);
if (!mounted) {
return;
}
setState(() {
_historyRecords = optimisticRecords;
_loadedHistoryUserEmail = user.email;
});
try {
final records = await _divinationApi.getHistoryRecords(
userId: user.email,
);
if (!mounted) {
return;
}
setState(() {
_historyRecords = _mergeAndSortHistory(<DivinationResultData>[
...records,
...optimisticRecords,
]);
_loadedHistoryUserEmail = user.email;
});
} catch (error, stackTrace) {
_logger.warning(
message: 'Failed to refresh history after divination completion',
extra: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
}
try {
await _refreshCredits(userEmail: user.email);
} catch (error, stackTrace) {
_logger.warning(
message: 'Failed to refresh credits after divination completion',
extra: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
}
}
List<DivinationResultData> _mergeAndSortHistory(
List<DivinationResultData> input,
) {
final seen = <String>{};
final deduped = <DivinationResultData>[];
for (final item in input) {
final key = _historyKey(item);
if (seen.add(key)) {
deduped.add(item);
}
}
deduped.sort(
(a, b) => b.params.divinationTime.compareTo(a.params.divinationTime),
);
return deduped;
}
String _historyKey(DivinationResultData item) {
return [
item.params.question,
item.binaryCode,
item.changedBinaryCode,
item.guaName,
item.targetGuaName,
item.signType,
].join('|');
}
Future<void> _refreshProfile({required String userEmail}) async {
if (_loadingProfile) {
return;
}
if (_loadedProfileUserEmail == userEmail) {
return;
}
_loadingProfile = true;
try {
final profile = await _profileApi.getProfile();
if (!mounted) {
return;
}
setState(() {
_profileSettings = profile;
_loadedProfileUserEmail = userEmail;
});
} finally {
_loadingProfile = false;
}
}
Future<ProfileSettingsV1> _uploadAvatar(String filePath) async {
final updated = await _profileApi.uploadAvatar(filePath);
if (!mounted) {
return updated;
}
setState(() {
_profileSettings = updated;
});
return updated;
}
Future<void> _saveProfileSettings(ProfileSettingsV1 next) async {
try {
final saved = await _profileApi.updateProfile(next);
if (!mounted) {
return;
}
setState(() {
_profileSettings = saved;
});
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to save profile settings via API',
error: error,
stackTrace: stackTrace,
);
rethrow;
}
}
@override
void dispose() {
_authBloc.dispose();
@@ -149,13 +330,19 @@ class _EryaoAppState extends State<EryaoApp> {
if (state.status == AuthStatus.authenticated && state.user != null) {
_ensureCreditsLoaded(state.user!.email);
_ensureHistoryLoaded(state.user!.email);
_refreshProfile(userEmail: state.user!.email);
return HomeScreen(
account: state.user!.email,
sessionStore: _sessionStore,
currentLocale: _locale,
profileSettings: _profileSettings,
historyRecords: _historyRecords,
coinBalance: _creditsBalance,
onLocaleChanged: _handleInterfaceLanguageChanged,
onProfileSettingsChanged: _saveProfileSettings,
onUploadAvatar: _uploadAvatar,
onDivinationCompleted: _handleDivinationCompleted,
onLogout: _authBloc.logout,
);
}
@@ -56,13 +56,9 @@ class AuthBloc extends ChangeNotifier {
}
Future<void> logout() async {
Object? caughtError;
StackTrace? caughtStackTrace;
try {
await _repository.logout();
} catch (error, stackTrace) {
caughtError = error;
caughtStackTrace = stackTrace;
_logger.error(
message: 'User logout failed: ${error.runtimeType}',
error: error.runtimeType.toString(),
@@ -72,9 +68,6 @@ class AuthBloc extends ChangeNotifier {
_logger.info(message: 'User logged out');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
if (caughtError != null) {
Error.throwWithStackTrace(caughtError, caughtStackTrace!);
}
}
Future<void> handleUnauthorized401() async {
@@ -11,6 +11,7 @@ import '../../../settings/presentation/screens/legal_document_screen.dart';
import '../../../settings/presentation/utils/legal_document_assets.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
@@ -156,17 +157,48 @@ class _LoginScreenState extends State<LoginScreen> {
return l10n.errorRequestGeneric;
}
InputDecoration _inputDecoration({
required String hintText,
required IconData icon,
}) {
final colors = Theme.of(context).colorScheme;
return InputDecoration(
hintText: hintText,
filled: true,
fillColor: colors.surface.withValues(alpha: 0.92),
prefixIcon: Icon(icon, color: colors.primary),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide(color: colors.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide(color: colors.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide(color: colors.primary, width: 1.6),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
);
}
void _showPolicyDialog(String title, String content) {
showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(content),
builder: (dialogContext) {
return AppModalDialog(
title: title,
message: content,
icon: Icons.description_outlined,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context)!.dialogConfirm),
AppModalDialogAction(
label: AppLocalizations.of(dialogContext)!.dialogConfirm,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
);
@@ -197,214 +229,271 @@ class _LoginScreenState extends State<LoginScreen> {
_isValidEmail && _codeController.text.length == 6 && _agreementChecked;
return Scaffold(
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: AppSpacing.xxl),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.welcomeLogin,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.loginSubtitleEmail,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
const SizedBox(height: AppSpacing.xxl),
Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: l10n.emailHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
),
),
),
const SizedBox(height: AppSpacing.lg),
Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: TextField(
controller: _codeController,
keyboardType: TextInputType.number,
maxLength: 6,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
counterText: '',
hintText: l10n.codeHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
),
),
),
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
width: 130,
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.surfaceContainerHighest,
foregroundColor: colors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
onPressed: _sendCode,
child: Text(
_isSending
? l10n.sending
: _countdown > 0
? l10n.retryAfter(_countdown)
: l10n.sendCode,
),
),
),
],
),
const SizedBox(height: AppSpacing.xl),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.md,
),
),
onPressed: canLogin ? _login : null,
child: Text(
l10n.login,
style: const TextStyle(fontSize: 16),
),
),
),
const SizedBox(height: AppSpacing.md),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: _agreementChecked,
onChanged: (value) {
setState(() {
_agreementChecked = value ?? false;
});
},
),
Flexible(
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodySmall,
children: [
TextSpan(text: l10n.agreementPrefix),
TextSpan(
text: l10n.privacyPolicy,
style: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _openLegalDocument(
LegalDocumentType.privacyPolicy,
),
),
TextSpan(text: l10n.agreementSeparator),
TextSpan(
text: l10n.termsOfService,
style: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _openLegalDocument(
LegalDocumentType.termsOfService,
),
),
TextSpan(text: l10n.agreementAnd),
TextSpan(
text: l10n.disclaimer,
style: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _showPolicyDialog(
l10n.disclaimer,
l10n.disclaimerContent,
),
),
],
),
),
),
],
),
),
const Spacer(),
Center(
child: Text(
l10n.icp,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: AppSpacing.sm),
],
),
resizeToAvoidBottomInset: true,
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors.secondaryContainer.withValues(alpha: 0.55),
colors.primaryContainer.withValues(alpha: 0.42),
colors.surfaceContainerLow,
],
),
),
child: Stack(
children: [
Positioned(
top: -86,
right: -42,
child: Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.primary.withValues(alpha: 0.1),
),
),
),
Positioned(
bottom: -110,
left: -34,
child: Container(
width: 210,
height: 210,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.secondary.withValues(alpha: 0.08),
),
),
),
GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final bottomInset = MediaQuery.of(
context,
).viewInsets.bottom;
return SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.lg + bottomInset,
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: AppSpacing.xxxl),
Center(
child: Column(
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: colors.surface.withValues(
alpha: 0.9,
),
borderRadius: BorderRadius.circular(
AppRadius.full,
),
border: Border.all(
color: colors.primary.withValues(
alpha: 0.2,
),
),
),
padding: const EdgeInsets.all(
AppSpacing.md,
),
child: Image.asset(
'assets/images/logo.png',
),
),
const SizedBox(height: AppSpacing.md),
Text(
l10n.appTitle,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
],
),
),
const SizedBox(height: AppSpacing.xxxl),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
onChanged: (_) => setState(() {}),
decoration: _inputDecoration(
hintText: l10n.emailHint,
icon: Icons.alternate_email,
),
),
const SizedBox(height: AppSpacing.lg),
Row(
children: [
Expanded(
child: TextField(
controller: _codeController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
maxLength: 6,
onChanged: (_) => setState(() {}),
decoration: _inputDecoration(
hintText: l10n.codeHint,
icon: Icons.lock_outline,
).copyWith(counterText: ''),
),
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
width: 128,
height: 52,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.full,
),
),
),
onPressed: _sendCode,
child: Text(
_isSending
? l10n.sending
: _countdown > 0
? l10n.retryAfter(_countdown)
: l10n.sendCode,
textAlign: TextAlign.center,
),
),
),
],
),
const SizedBox(height: AppSpacing.xl),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.full,
),
),
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.md,
),
),
onPressed: canLogin ? _login : null,
child: Text(
l10n.login,
style: const TextStyle(fontSize: 16),
),
),
),
const SizedBox(height: AppSpacing.md),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: _agreementChecked,
onChanged: (value) {
setState(() {
_agreementChecked = value ?? false;
});
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
top: AppSpacing.sm,
),
child: RichText(
text: TextSpan(
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: colors.onSurface),
children: [
TextSpan(text: l10n.agreementPrefix),
TextSpan(
text: l10n.privacyPolicy,
style: TextStyle(
color: colors.primary,
decoration:
TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () =>
_openLegalDocument(
LegalDocumentType
.privacyPolicy,
),
),
TextSpan(
text: l10n.agreementSeparator,
),
TextSpan(
text: l10n.termsOfService,
style: TextStyle(
color: colors.primary,
decoration:
TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () =>
_openLegalDocument(
LegalDocumentType
.termsOfService,
),
),
TextSpan(text: l10n.agreementAnd),
TextSpan(
text: l10n.disclaimer,
style: TextStyle(
color: colors.primary,
decoration:
TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () => _showPolicyDialog(
l10n.disclaimer,
l10n.disclaimerContent,
),
),
],
),
),
),
),
],
),
],
),
),
);
},
),
),
),
],
),
),
);
}
@@ -8,6 +8,7 @@ import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../models/divination_backend_models.dart';
import '../models/divination_params.dart';
import '../models/divination_result.dart';
class DivinationApi {
const DivinationApi({required ApiClient apiClient}) : _apiClient = apiClient;
@@ -37,6 +38,67 @@ class DivinationApi {
return RunAcceptedData.fromJson(json);
}
Future<List<DivinationResultData>> getHistoryRecords({
required String userId,
}) async {
final json = await _apiClient.getJson('/api/v1/agent/history');
final messagesRaw = json['messages'];
if (messagesRaw is! List<dynamic>) {
return const <DivinationResultData>[];
}
final records = <DivinationResultData>[];
for (final raw in messagesRaw) {
if (raw is! Map<String, dynamic>) {
continue;
}
if (raw['role'] != 'assistant') {
continue;
}
final agentOutputRaw = raw['agent_output'];
if (agentOutputRaw is! Map<String, dynamic>) {
continue;
}
final derivedRaw = agentOutputRaw['divination_derived'];
if (derivedRaw is! Map<String, dynamic>) {
continue;
}
try {
final derived = DerivedDivinationData.fromJson(derivedRaw);
final divinationTime = _resolveHistoryTime(raw, derived);
final params = DivinationParams(
method: _methodFromText(derived.divinationMethod),
questionType: _questionTypeFromText(derived.questionType),
question: derived.question,
divinationTime: divinationTime,
coinBalance: 0,
userId: userId,
);
final aggregate = DivinationRunAggregate(
derived: derived,
signLevel: _asString(agentOutputRaw['sign_level']),
summary: _asString(agentOutputRaw['summary']),
conclusion: _asStringList(agentOutputRaw['conclusion']),
focusPoints: _asStringList(agentOutputRaw['focus_points']),
advice: _asStringList(agentOutputRaw['advice']),
keywords: _asStringList(agentOutputRaw['keywords']),
answer: _asString(agentOutputRaw['answer']),
);
records.add(aggregate.toViewData(params));
} catch (error, stackTrace) {
_logger.warning(
message: 'Skip malformed history assistant message',
extra: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
continue;
}
}
return records;
}
Stream<Map<String, dynamic>> streamEvents({
required String threadId,
required String runId,
@@ -217,6 +279,55 @@ String _questionTypeToText(QuestionType type) {
};
}
QuestionType _questionTypeFromText(String raw) {
return switch (raw) {
'事业' => QuestionType.career,
'情感' => QuestionType.love,
'财富' => QuestionType.wealth,
'运势' => QuestionType.fortune,
'解梦' => QuestionType.dream,
'健康' => QuestionType.health,
'学业' => QuestionType.study,
'寻物' => QuestionType.search,
_ => QuestionType.other,
};
}
DivinationMethod _methodFromText(String raw) {
return raw == '自动起卦' ? DivinationMethod.auto : DivinationMethod.manual;
}
DateTime _resolveHistoryTime(
Map<String, dynamic> message,
DerivedDivinationData derived,
) {
final timestamp = message['timestamp'];
if (timestamp is String) {
final parsed = DateTime.tryParse(timestamp);
if (parsed != null) {
return parsed.toLocal();
}
}
final derivedTime = DateTime.tryParse(derived.divinationTime);
if (derivedTime != null) {
return derivedTime.toLocal();
}
return DateTime.now();
}
String _asString(Object? value) {
return value is String ? value : '';
}
List<String> _asStringList(Object? value) {
if (value is! List<dynamic>) {
return const <String>[];
}
return value.whereType<String>().toList(growable: false);
}
String _yaoTypeToText(YaoType type) {
return switch (type) {
YaoType.youngYang => '少阳',
@@ -60,6 +60,21 @@ class DivinationParams {
};
}
factory DivinationParams.fromPayload(Map<String, dynamic> payload) {
return DivinationParams(
method: divinationMethodFromName(_requiredString(payload, 'method')),
questionType: questionTypeFromName(
_requiredString(payload, 'questionType'),
),
question: _requiredString(payload, 'question'),
divinationTime: DateTime.parse(
_requiredString(payload, 'divinationTime'),
),
coinBalance: _requiredInt(payload, 'coinBalance'),
userId: _requiredString(payload, 'userId'),
);
}
String toBinary(List<YaoType> yaoStates) {
return yaoStates
.map(
@@ -85,3 +100,43 @@ class DivinationParams {
.join();
}
}
DivinationMethod divinationMethodFromName(String raw) {
return DivinationMethod.values.firstWhere(
(value) => value.name == raw,
orElse: () => DivinationMethod.manual,
);
}
QuestionType questionTypeFromName(String raw) {
return QuestionType.values.firstWhere(
(value) => value.name == raw,
orElse: () => QuestionType.other,
);
}
YaoType yaoTypeFromName(String raw) {
return YaoType.values.firstWhere(
(value) => value.name == raw,
orElse: () => YaoType.undetermined,
);
}
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;
}
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');
}
@@ -38,6 +38,79 @@ class DivinationResultData {
final List<YaoLineData> targetYaoLines;
bool get hasChangingYao => binaryCode != changedBinaryCode;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'params': params.toPayload(),
'binaryCode': binaryCode,
'changedBinaryCode': changedBinaryCode,
'guaName': guaName,
'targetGuaName': targetGuaName,
'upperName': upperName,
'lowerName': lowerName,
'signType': signType,
'keywords': keywords,
'conclusion': conclusion,
'analysis': analysis,
'suggestion': suggestion,
'ganzhi': ganzhi.toJson(),
'wuXingStatus': wuXingStatus,
'yaoLines': yaoLines.map((line) => line.toJson()).toList(growable: false),
'targetYaoLines': targetYaoLines
.map((line) => line.toJson())
.toList(growable: false),
};
}
factory DivinationResultData.fromJson(Map<String, dynamic> json) {
final paramsRaw = json['params'];
final ganzhiRaw = json['ganzhi'];
final wuXingRaw = json['wuXingStatus'];
final yaoLinesRaw = json['yaoLines'];
final targetYaoLinesRaw = json['targetYaoLines'];
if (paramsRaw is! Map<String, dynamic> ||
ganzhiRaw is! Map<String, dynamic> ||
wuXingRaw is! Map<String, dynamic> ||
yaoLinesRaw is! List<dynamic> ||
targetYaoLinesRaw is! List<dynamic>) {
throw const FormatException('Invalid divination result payload');
}
return DivinationResultData(
params: DivinationParams.fromPayload(paramsRaw),
binaryCode: _requiredString(json, 'binaryCode'),
changedBinaryCode: _requiredString(json, 'changedBinaryCode'),
guaName: _requiredString(json, 'guaName'),
targetGuaName: _requiredString(json, 'targetGuaName'),
upperName: _requiredString(json, 'upperName'),
lowerName: _requiredString(json, 'lowerName'),
signType: _requiredString(json, 'signType'),
keywords: _requiredString(json, 'keywords'),
conclusion: _requiredString(json, 'conclusion'),
analysis: _requiredString(json, 'analysis'),
suggestion: _requiredString(json, 'suggestion'),
ganzhi: GanzhiData.fromJson(ganzhiRaw),
wuXingStatus: wuXingRaw.map(
(key, value) => MapEntry(key, value.toString()),
),
yaoLines: yaoLinesRaw
.map((raw) {
if (raw is! Map<String, dynamic>) {
throw const FormatException('Invalid yao line payload');
}
return YaoLineData.fromJson(raw);
})
.toList(growable: false),
targetYaoLines: targetYaoLinesRaw
.map((raw) {
if (raw is! Map<String, dynamic>) {
throw const FormatException('Invalid target yao line payload');
}
return YaoLineData.fromJson(raw);
})
.toList(growable: false),
);
}
}
class GanzhiData {
@@ -68,6 +141,40 @@ class GanzhiData {
final String riChen;
final String yuePo;
final String riChong;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'yearGanZhi': yearGanZhi,
'monthGanZhi': monthGanZhi,
'dayGanZhi': dayGanZhi,
'timeGanZhi': timeGanZhi,
'yearKongWang': yearKongWang,
'monthKongWang': monthKongWang,
'dayKongWang': dayKongWang,
'timeKongWang': timeKongWang,
'yueJian': yueJian,
'riChen': riChen,
'yuePo': yuePo,
'riChong': riChong,
};
}
factory GanzhiData.fromJson(Map<String, dynamic> json) {
return GanzhiData(
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'),
);
}
}
class YaoLineData {
@@ -88,4 +195,47 @@ class YaoLineData {
final String element;
final YaoType type;
final String mark;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'index': index,
'spirit': spirit,
'relation': relation,
'branch': branch,
'element': element,
'type': type.name,
'mark': mark,
};
}
factory YaoLineData.fromJson(Map<String, dynamic> json) {
return YaoLineData(
index: _requiredInt(json, 'index'),
spirit: _requiredString(json, 'spirit'),
relation: _requiredString(json, 'relation'),
branch: _requiredString(json, 'branch'),
element: _requiredString(json, 'element'),
type: yaoTypeFromName(_requiredString(json, 'type')),
mark: _requiredString(json, 'mark'),
);
}
}
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;
}
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');
}
@@ -12,6 +12,10 @@ class DivinationRunService {
final DivinationApi _api;
static final Logger _logger = getLogger('features.divination.run_service');
Future<PointsBalanceData> getPointsBalance() {
return _api.getPointsBalance();
}
Future<DivinationRunAggregate> run({
required DivinationParams params,
required List<YaoType> yaoStates,
@@ -9,12 +9,17 @@ import 'package:vibration/vibration.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.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/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_backend_models.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_processing_screen.dart';
@@ -23,10 +28,12 @@ class AutoDivinationScreen extends StatefulWidget {
super.key,
required this.params,
required this.runService,
required this.onCompleted,
});
final DivinationParams params;
final DivinationRunService runService;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
@@ -216,6 +223,55 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
PointsBalanceData points;
try {
points = await widget.runService.getPointsBalance();
} catch (_) {
if (!mounted) {
return;
}
Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error);
return;
}
if (!points.canRun || points.availableBalance < points.runCost) {
if (!mounted) {
return;
}
Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning);
return;
}
if (!mounted) {
return;
}
final shouldStart = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AppModalDialog(
title: l10n.divinationCostDialogTitle,
message: l10n.divinationCostDialogBody(
points.runCost,
points.availableBalance,
),
icon: Icons.auto_awesome_rounded,
actions: [
AppModalDialogAction(
label: l10n.cancel,
onPressed: () => Navigator.of(dialogContext).pop(false),
),
AppModalDialogAction(
label: l10n.divinationCostDialogConfirm,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(true),
),
],
);
},
);
if (shouldStart != true) {
return;
}
setState(() {
_submitting = true;
});
@@ -229,6 +285,7 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _yaoStates,
runService: widget.runService,
onCompleted: widget.onCompleted,
),
),
);
@@ -20,32 +20,57 @@ class DivinationProcessingScreen extends StatefulWidget {
required this.params,
required this.yaoStates,
required this.runService,
required this.onCompleted,
});
final DivinationParams params;
final List<YaoType> yaoStates;
final DivinationRunService runService;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
State<DivinationProcessingScreen> createState() =>
_DivinationProcessingScreenState();
}
class _DivinationProcessingScreenState
extends State<DivinationProcessingScreen> {
class _DivinationProcessingScreenState extends State<DivinationProcessingScreen>
with TickerProviderStateMixin {
static final Logger _logger = getLogger(
'features.divination.processing_screen',
);
static const int _iChingCardCount = 8;
_ProcessingStep _step = _ProcessingStep.preparing;
DivinationResultData? _resultData;
String? _errorMessage;
late final AnimationController _cardRotationController;
int _currentCardIndex = 0;
@override
void initState() {
super.initState();
_cardRotationController =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..addStatusListener((status) {
if (status != AnimationStatus.completed || !mounted) {
return;
}
setState(() {
_currentCardIndex = (_currentCardIndex + 1) % _iChingCardCount;
});
_cardRotationController.forward(from: 0);
});
_cardRotationController.forward();
_startRun();
}
@override
void dispose() {
_cardRotationController.dispose();
super.dispose();
}
Future<void> _startRun() async {
try {
final aggregate = await widget.runService.run(
@@ -75,6 +100,22 @@ class _DivinationProcessingScreenState
_resultData = aggregate.toViewData(widget.params);
_step = _ProcessingStep.done;
});
_cardRotationController.stop();
final data = _resultData;
if (data != null) {
try {
await widget.onCompleted(data);
} catch (error, stackTrace) {
_logger.warning(
message: 'Failed to persist post-run side effects',
extra: <String, dynamic>{'error': error.toString()},
);
_logger.debug(
message: 'Post-run side effect stack trace',
extra: <String, dynamic>{'stackTrace': stackTrace.toString()},
);
}
}
} catch (error, stackTrace) {
_logger.error(
message: 'Divination processing failed while waiting result events',
@@ -117,11 +158,12 @@ class _DivinationProcessingScreenState
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final text = switch (_step) {
final statusText = switch (_step) {
_ProcessingStep.preparing => l10n.transitionPreparing,
_ProcessingStep.deriving => l10n.transitionDeriving,
_ProcessingStep.done => l10n.transitionDone,
};
final cardDataList = _iChingCardData(l10n);
final canContinue = _step == _ProcessingStep.done && _resultData != null;
@@ -134,39 +176,123 @@ class _DivinationProcessingScreenState
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: AnimatedBuilder(
animation: _cardRotationController,
builder: (context, _) {
final angle = canContinue
? 0.0
: _rotationForProgress(
_cardRotationController.value,
);
final card = cardDataList[_currentCardIndex];
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.0011)
..rotateY(angle),
child: Container(
width: 220,
height: 320,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors.primaryContainer.withValues(
alpha: 0.55,
),
colors.secondaryContainer.withValues(
alpha: 0.38,
),
colors.surface,
],
),
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: colors.primary.withValues(alpha: 0.3),
),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.18),
blurRadius: 26,
offset: const Offset(0, 14),
),
],
),
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (canContinue)
Icon(
Icons.visibility,
color: colors.primary,
size: 34,
)
else ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: colors.surface.withValues(
alpha: 0.75,
),
borderRadius: BorderRadius.circular(
AppRadius.full,
),
),
child: Text(
'I Ching',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: colors.primary,
letterSpacing: 0.3,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: AppSpacing.md),
Text(
card.$1,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.md),
Text(
card.$2,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
height: 1.5,
color: colors.onSurface.withValues(
alpha: 0.86,
),
),
),
],
const SizedBox(height: AppSpacing.lg),
Text(
statusText,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleMedium,
),
],
),
),
],
),
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(
@@ -181,4 +307,27 @@ class _DivinationProcessingScreenState
),
);
}
double _rotationForProgress(double progress) {
if (progress < 0.25) {
return (1 - progress / 0.25) * (3.1415926 / 2);
}
if (progress < 0.75) {
return 0;
}
return ((progress - 0.75) / 0.25) * (3.1415926 / 2);
}
List<(String, String)> _iChingCardData(AppLocalizations l10n) {
return <(String, String)>[
(l10n.processingCardQianTitle, l10n.processingCardQianQuote),
(l10n.processingCardDuiTitle, l10n.processingCardDuiQuote),
(l10n.processingCardLiTitle, l10n.processingCardLiQuote),
(l10n.processingCardZhenTitle, l10n.processingCardZhenQuote),
(l10n.processingCardXunTitle, l10n.processingCardXunQuote),
(l10n.processingCardKanTitle, l10n.processingCardKanQuote),
(l10n.processingCardGenTitle, l10n.processingCardGenQuote),
(l10n.processingCardKunTitle, l10n.processingCardKunQuote),
];
}
}
@@ -27,22 +27,73 @@ class DivinationResultScreen extends StatefulWidget {
class _DivinationResultScreenState extends State<DivinationResultScreen> {
bool _showIntro = true;
bool _introCollapsed = false;
Rect? _introTargetRect;
final GlobalKey _stackKey = GlobalKey();
final GlobalKey _finalSignCardKey = GlobalKey();
void _backToHome() {
final navigator = Navigator.of(context);
navigator.popUntil((route) => route.isFirst);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_prepareIntro();
});
}
Future<void> _prepareIntro() async {
for (int i = 0; i < 12; i++) {
if (!mounted) {
return;
}
if (_measureIntroTargetRect()) {
break;
}
await Future<void>.delayed(const Duration(milliseconds: 16));
}
if (!mounted) {
return;
}
_playIntro();
}
bool _measureIntroTargetRect() {
final stackContext = _stackKey.currentContext;
final targetContext = _finalSignCardKey.currentContext;
if (stackContext == null || targetContext == null) {
return false;
}
final stackRender = stackContext.findRenderObject();
final targetRender = targetContext.findRenderObject();
if (stackRender is! RenderBox || targetRender is! RenderBox) {
return false;
}
final offset = targetRender.localToGlobal(
Offset.zero,
ancestor: stackRender,
);
final targetRect = offset & targetRender.size;
if (_introTargetRect == targetRect) {
return true;
}
setState(() {
_introTargetRect = targetRect;
});
return true;
}
Future<void> _playIntro() async {
await Future<void>.delayed(const Duration(milliseconds: 120));
await Future<void>.delayed(const Duration(milliseconds: 180));
if (!mounted) {
return;
}
setState(() {
_introCollapsed = true;
});
await Future<void>.delayed(const Duration(milliseconds: 760));
await Future<void>.delayed(const Duration(milliseconds: 1450));
if (!mounted) {
return;
}
@@ -51,121 +102,179 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
});
}
Rect _introStartRect(Size size) {
const startWidth = 332.0;
const startHeight = 234.0;
return Rect.fromLTWH(
(size.width - startWidth) / 2,
(size.height - startHeight) / 2,
startWidth,
startHeight,
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
return PopScope<void>(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
return;
}
_backToHome();
},
child: Scaffold(
backgroundColor: colors.surface,
surfaceTintColor: colors.surface,
title: Text(l10n.resultScreenTitle),
centerTitle: true,
),
body: Stack(
children: [
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,
),
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),
appBar: AppBar(
leading: IconButton(
onPressed: _backToHome,
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
backgroundColor: colors.surface,
surfaceTintColor: colors.surface,
title: Text(l10n.resultScreenTitle),
centerTitle: true,
),
body: LayoutBuilder(
builder: (context, constraints) {
final stackSize = Size(constraints.maxWidth, constraints.maxHeight);
final startRect = _introStartRect(stackSize);
final targetRect = _introTargetRect ?? startRect;
final currentRect = _introCollapsed ? targetRect : startRect;
return Stack(
key: _stackKey,
children: [
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: Row(
child: Column(
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,
_ResultHeader(data: widget.data),
const SizedBox(height: AppSpacing.md),
_SignCard(
key: _finalSignCardKey,
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),
),
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),
],
),
),
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),
),
if (_showIntro)
Positioned.fill(
child: IgnorePointer(
child: ColoredBox(color: colors.surface),
),
),
),
),
),
],
if (_showIntro)
AnimatedPositioned(
duration: const Duration(milliseconds: 1450),
curve: Curves.easeInOutCubicEmphasized,
left: currentRect.left,
top: currentRect.top,
width: currentRect.width,
height: currentRect.height,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
image: DecorationImage(
image: AssetImage(
_signImageAssetForType(
context,
widget.data.signType,
),
),
fit: BoxFit.cover,
),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.24),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
),
),
),
],
);
},
),
),
);
}
@@ -217,18 +326,16 @@ class _ResultHeader extends StatelessWidget {
}
class _SignCard extends StatelessWidget {
const _SignCard({required this.signType});
const _SignCard({super.key, required this.signType});
final String signType;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final image = switch (signType) {
'上上签' => 'assets/images/qigua/shangshang.jpg',
'中上签' => 'assets/images/qigua/zhongshang.jpg',
_ => 'assets/images/qigua/zhongxia.jpg',
};
final l10n = AppLocalizations.of(context)!;
final image = _signImageAssetForType(context, signType);
final localizedSignType = _localizedSignTypeLabel(l10n, signType);
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
@@ -248,7 +355,7 @@ class _SignCard extends StatelessWidget {
),
const SizedBox(height: AppSpacing.sm),
Text(
signType,
localizedSignType,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
@@ -261,6 +368,35 @@ class _SignCard extends StatelessWidget {
}
}
String _localizedSignTypeLabel(AppLocalizations l10n, String signType) {
final normalized = signType.trim();
if (normalized.contains('上上')) {
return l10n.signTypeShangShang;
}
if (normalized.contains('中上')) {
return l10n.signTypeZhongShang;
}
if (normalized.contains('下下')) {
return l10n.signTypeXiaXia;
}
return l10n.signTypeZhongXia;
}
String _signImageAssetForType(BuildContext context, String signType) {
final l10n = AppLocalizations.of(context)!;
final normalized = _localizedSignTypeLabel(l10n, signType);
if (normalized == l10n.signTypeShangShang) {
return 'assets/images/qigua/shangshang.jpg';
}
if (normalized == l10n.signTypeZhongShang) {
return 'assets/images/qigua/zhongshang.jpg';
}
if (normalized == l10n.signTypeXiaXia) {
return 'assets/images/qigua/xiaxia.jpg';
}
return 'assets/images/qigua/zhongxia.jpg';
}
class _KeywordCard extends StatelessWidget {
const _KeywordCard({required this.keywords});
@@ -299,6 +435,7 @@ class _AnalysisCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
@@ -323,9 +460,13 @@ class _AnalysisCard extends StatelessWidget {
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: content));
Toast.show(context, '$title已复制', type: ToastType.success);
Toast.show(
context,
l10n.toastContentCopiedWithTitle(title),
type: ToastType.success,
);
},
child: const Text('复制'),
child: Text(l10n.resultCopy),
),
],
),
@@ -351,6 +492,7 @@ class _InfoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
@@ -360,32 +502,41 @@ class _InfoCard extends StatelessWidget {
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'起卦信息',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.resultDivinationInfo,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: AppSpacing.md),
_kv(
context,
'起卦时间',
DateFormat.yMd(
Localizations.localeOf(context).toString(),
).add_Hm().format(data.params.divinationTime),
),
_kv(
context,
'起卦方式',
data.params.method == DivinationMethod.auto ? '自动起卦' : '手动起卦',
),
_kv(context, '问题类型', _typeLabel(data.params.questionType)),
_kv(context, '占卜问题', data.params.question),
],
const SizedBox(height: AppSpacing.md),
_kv(
context,
l10n.resultDivinationTime,
DateFormat.yMd(
Localizations.localeOf(context).toString(),
).add_Hm().format(data.params.divinationTime),
),
_kv(
context,
l10n.resultDivinationMethod,
data.params.method == DivinationMethod.auto
? l10n.resultAutoMethod
: l10n.resultManualMethod,
),
_kv(
context,
l10n.resultQuestionType,
_typeLabel(context, data.params.questionType),
),
_kv(context, l10n.resultQuestion, data.params.question),
],
),
),
),
);
@@ -419,17 +570,18 @@ class _InfoCard extends StatelessWidget {
);
}
String _typeLabel(QuestionType type) {
String _typeLabel(BuildContext context, QuestionType type) {
final l10n = AppLocalizations.of(context)!;
return switch (type) {
QuestionType.career => '事业',
QuestionType.love => '情感',
QuestionType.wealth => '财富',
QuestionType.fortune => '运势',
QuestionType.dream => '解梦',
QuestionType.health => '健康',
QuestionType.study => '学业',
QuestionType.search => '寻物',
QuestionType.other => '其他',
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,
};
}
}
@@ -442,6 +594,7 @@ class _HexagramDetailCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Card(
@@ -457,7 +610,7 @@ class _HexagramDetailCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'干支信息',
l10n.ganZhiInfo,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
@@ -467,26 +620,58 @@ class _HexagramDetailCard extends StatelessWidget {
Row(
children: [
Expanded(
child: _miniKV(context, '月建', data.ganzhi.yueJian),
child: _miniKV(
context,
DivinationTerms.yueJian,
data.ganzhi.yueJian,
),
),
Expanded(
child: _miniKV(
context,
DivinationTerms.riChen,
data.ganzhi.riChen,
),
),
Expanded(child: _miniKV(context, '日辰', data.ganzhi.riChen)),
],
),
const SizedBox(height: AppSpacing.sm),
Row(
children: [
Expanded(child: _miniKV(context, '月破', data.ganzhi.yuePo)),
Expanded(
child: _miniKV(context, '日冲', data.ganzhi.riChong),
child: _miniKV(
context,
DivinationTerms.yuePo,
data.ganzhi.yuePo,
),
),
Expanded(
child: _miniKV(
context,
DivinationTerms.riChong,
data.ganzhi.riChong,
),
),
],
),
const SizedBox(height: AppSpacing.md),
Text('五行旺衰', style: Theme.of(context).textTheme.bodyMedium),
Text(
l10n.wuXingWangShuai,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.sm),
_WuXingTable(data: data),
const SizedBox(height: AppSpacing.md),
Text('干支空亡', style: Theme.of(context).textTheme.bodyMedium),
Text(
l10n.ganZhiKongWang,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.sm),
_KongWangTable(data: data),
],
@@ -626,12 +811,65 @@ class _KongWangTable extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final rows = [
('', '${data.ganzhi.yearGanZhi}', data.ganzhi.yearKongWang),
('', '${data.ganzhi.monthGanZhi}', data.ganzhi.monthKongWang),
('', '${data.ganzhi.dayGanZhi}', data.ganzhi.dayKongWang),
('', '${data.ganzhi.timeGanZhi}', data.ganzhi.timeKongWang),
final l10n = AppLocalizations.of(context)!;
final header = <String>[
l10n.resultPillarColumn,
l10n.resultYearPillar,
l10n.resultMonthPillar,
l10n.resultDayPillar,
l10n.resultTimePillar,
];
final rows = <List<String>>[
<String>[
l10n.resultGanZhiLabel,
data.ganzhi.yearGanZhi,
data.ganzhi.monthGanZhi,
data.ganzhi.dayGanZhi,
data.ganzhi.timeGanZhi,
],
<String>[
l10n.resultKongWangLabel,
data.ganzhi.yearKongWang,
data.ganzhi.monthKongWang,
data.ganzhi.dayKongWang,
data.ganzhi.timeKongWang,
],
];
Widget buildCell(
String text, {
bool isHeader = false,
bool isLast = false,
bool isFirst = false,
}) {
return Expanded(
flex: isFirst ? 2 : 3,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: isHeader ? colors.surfaceContainerHigh : colors.surface,
border: Border(
right: isLast
? BorderSide.none
: BorderSide(color: colors.outline),
),
),
child: Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: isHeader || isFirst
? FontWeight.w700
: FontWeight.w500,
),
),
),
);
}
return Container(
decoration: BoxDecoration(
border: Border.all(color: colors.outline),
@@ -639,20 +877,30 @@ class _KongWangTable extends StatelessWidget {
),
child: Column(
children: [
for (final row in rows)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
Row(
children: [
for (int i = 0; i < header.length; i++)
buildCell(
header[i],
isHeader: true,
isFirst: i == 0,
isLast: i == header.length - 1,
),
],
),
for (int r = 0; r < rows.length; r++)
Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: colors.outline)),
),
child: Row(
children: [
SizedBox(width: 28, child: Text(row.$1)),
Expanded(child: Text(row.$2, textAlign: TextAlign.center)),
SizedBox(
width: 64,
child: Text(row.$3, textAlign: TextAlign.right),
),
for (int c = 0; c < rows[r].length; c++)
buildCell(
rows[r][c],
isFirst: c == 0,
isLast: c == rows[r].length - 1,
),
],
),
),
@@ -5,11 +5,13 @@ import '../../../../core/auth/session_store.dart';
import '../../../../data/network/api_client.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/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/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
import 'auto_divination_screen.dart';
import 'manual_divination_screen.dart';
@@ -19,11 +21,13 @@ class DivinationScreen extends StatefulWidget {
super.key,
required this.sessionStore,
required this.userId,
required this.onCompleted,
this.runServiceOverride,
});
final SessionStore sessionStore;
final String userId;
final Future<void> Function(DivinationResultData result) onCompleted;
final DivinationRunService? runServiceOverride;
@override
@@ -157,6 +161,7 @@ class _DivinationScreenState extends State<DivinationScreen> {
builder: (_) => ManualDivinationScreen(
params: nextParams,
runService: _runService,
onCompleted: widget.onCompleted,
),
),
);
@@ -166,8 +171,11 @@ class _DivinationScreenState extends State<DivinationScreen> {
final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
AutoDivinationScreen(params: nextParams, runService: _runService),
builder: (_) => AutoDivinationScreen(
params: nextParams,
runService: _runService,
onCompleted: widget.onCompleted,
),
),
);
}
@@ -372,16 +380,17 @@ class _StartButton extends StatelessWidget {
Future<void> _showMethodTip(BuildContext context, AppLocalizations l10n) {
return showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.divinationMethodTipTitle),
content: Text(
'${l10n.divinationMethodTipAuto}\n\n${l10n.divinationMethodTipManual}\n\n${l10n.divinationMethodTipRecommend}',
),
builder: (dialogContext) {
return AppModalDialog(
title: l10n.divinationMethodTipTitle,
message:
'${l10n.divinationMethodTipAuto}\n\n${l10n.divinationMethodTipManual}\n\n${l10n.divinationMethodTipRecommend}',
icon: Icons.lightbulb_outline_rounded,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.divinationIAcknowledge),
AppModalDialogAction(
label: l10n.divinationIAcknowledge,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
);
@@ -4,12 +4,17 @@ import 'package:intl/intl.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.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/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_backend_models.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_processing_screen.dart';
@@ -18,10 +23,12 @@ class ManualDivinationScreen extends StatefulWidget {
super.key,
required this.params,
required this.runService,
required this.onCompleted,
});
final DivinationParams params;
final DivinationRunService runService;
final Future<void> Function(DivinationResultData result) onCompleted;
@override
State<ManualDivinationScreen> createState() => _ManualDivinationScreenState();
@@ -155,14 +162,16 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
final l10n = AppLocalizations.of(context)!;
await showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.manualYaoTipTitle),
content: Text(l10n.manualYaoTipContent),
builder: (dialogContext) {
return AppModalDialog(
title: l10n.manualYaoTipTitle,
message: l10n.manualYaoTipContent,
icon: Icons.info_outline_rounded,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.divinationIAcknowledge),
AppModalDialogAction(
label: l10n.divinationIAcknowledge,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
);
@@ -171,6 +180,55 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
PointsBalanceData points;
try {
points = await widget.runService.getPointsBalance();
} catch (_) {
if (!mounted) {
return;
}
Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error);
return;
}
if (!points.canRun || points.availableBalance < points.runCost) {
if (!mounted) {
return;
}
Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning);
return;
}
if (!mounted) {
return;
}
final shouldStart = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AppModalDialog(
title: l10n.divinationCostDialogTitle,
message: l10n.divinationCostDialogBody(
points.runCost,
points.availableBalance,
),
icon: Icons.auto_awesome_rounded,
actions: [
AppModalDialogAction(
label: l10n.cancel,
onPressed: () => Navigator.of(dialogContext).pop(false),
),
AppModalDialogAction(
label: l10n.divinationCostDialogConfirm,
primary: true,
onPressed: () => Navigator.of(dialogContext).pop(true),
),
],
);
},
);
if (shouldStart != true) {
return;
}
setState(() {
_submitting = true;
});
@@ -184,6 +242,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _selectedYaos.cast<YaoType>(),
runService: widget.runService,
onCompleted: widget.onCompleted,
),
),
);
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import '../../../../core/auth/session_store.dart';
import '../../../divination/presentation/screens/divination_screen.dart';
import '../../../divination/presentation/screens/divination_result_screen.dart';
import '../../../divination/data/models/divination_params.dart';
import '../../../divination/data/models/divination_result.dart';
import '../../../settings/data/models/profile_settings.dart';
import '../../../settings/presentation/screens/settings_screen.dart';
import '../../../../l10n/app_localizations.dart';
@@ -18,8 +21,12 @@ class HomeScreen extends StatefulWidget {
required this.sessionStore,
required this.currentLocale,
required this.profileSettings,
required this.historyRecords,
required this.coinBalance,
required this.onLocaleChanged,
required this.onProfileSettingsChanged,
required this.onUploadAvatar,
required this.onDivinationCompleted,
required this.onLogout,
});
@@ -27,8 +34,14 @@ class HomeScreen extends StatefulWidget {
final SessionStore sessionStore;
final Locale currentLocale;
final ProfileSettingsV1 profileSettings;
final List<DivinationResultData> historyRecords;
final int coinBalance;
final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function(DivinationResultData result)
onDivinationCompleted;
final Future<void> Function() onLogout;
@override
@@ -69,26 +82,7 @@ class _HomeScreenState extends State<HomeScreen> {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
final historyItems = [
_HistoryItemData(
question: l10n.historyQuestion1,
category: _HistoryCategory.career,
guaName: l10n.guaName1,
sign: _HistorySign.good,
),
_HistoryItemData(
question: l10n.historyQuestion2,
category: _HistoryCategory.love,
guaName: l10n.guaName2,
sign: _HistorySign.normal,
),
_HistoryItemData(
question: l10n.historyQuestion3,
category: _HistoryCategory.money,
guaName: l10n.guaName3,
sign: _HistorySign.best,
),
];
final historyItems = widget.historyRecords;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
@@ -212,7 +206,23 @@ class _HomeScreenState extends State<HomeScreen> {
style: Theme.of(context).textTheme.titleMedium,
),
TextButton(
onPressed: () => _showSnack(context, l10n.featurePending),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _HistoryRecordsScreen(
records: historyItems,
onOpenResult: (item) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
DivinationResultScreen(data: item),
),
);
},
),
),
);
},
child: Text(l10n.more),
),
],
@@ -245,7 +255,17 @@ class _HomeScreenState extends State<HomeScreen> {
right: AppSpacing.md,
bottom: AppSpacing.md,
),
child: _HistoryCard(item: item),
child: _HistoryCard(
item: item,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
DivinationResultScreen(data: item),
),
);
},
),
);
}).toList(),
),
@@ -270,6 +290,8 @@ class _HomeScreenState extends State<HomeScreen> {
settings: widget.profileSettings,
coinBalance: widget.coinBalance,
onInterfaceLanguageChanged: widget.onLocaleChanged,
onSettingsChanged: widget.onProfileSettingsChanged,
onUploadAvatar: widget.onUploadAvatar,
onLogout: widget.onLogout,
),
),
@@ -283,6 +305,7 @@ class _HomeScreenState extends State<HomeScreen> {
builder: (_) => DivinationScreen(
sessionStore: widget.sessionStore,
userId: widget.account,
onCompleted: widget.onDivinationCompleted,
),
),
);
@@ -294,9 +317,10 @@ class _HomeScreenState extends State<HomeScreen> {
}
class _HistoryCard extends StatelessWidget {
const _HistoryCard({required this.item});
const _HistoryCard({required this.item, required this.onTap});
final _HistoryItemData item;
final DivinationResultData item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
@@ -304,80 +328,90 @@ class _HistoryCard extends StatelessWidget {
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
final categoryLabel = switch (item.category) {
_HistoryCategory.career => l10n.categoryCareer,
_HistoryCategory.love => l10n.categoryLove,
_HistoryCategory.money => l10n.categoryMoney,
final categoryLabel = switch (item.params.questionType) {
QuestionType.career || QuestionType.study => l10n.categoryCareer,
QuestionType.love => l10n.categoryLove,
_ => l10n.categoryMoney,
};
final categoryStyle = switch (item.category) {
_HistoryCategory.career => (
final categoryStyle = switch (item.params.questionType) {
QuestionType.career || QuestionType.study => (
palette.categoryCareerBg,
palette.categoryCareerText,
),
_HistoryCategory.love => (
palette.categoryLoveBg,
palette.categoryLoveText,
),
_HistoryCategory.money => (
palette.categoryMoneyBg,
palette.categoryMoneyText,
),
QuestionType.love => (palette.categoryLoveBg, palette.categoryLoveText),
_ => (palette.categoryMoneyBg, palette.categoryMoneyText),
};
final signLabel = switch (item.sign) {
_HistorySign.best => l10n.signBest,
_HistorySign.good => l10n.signGood,
_HistorySign.normal => l10n.signNormal,
};
final normalizedSignType = item.signType.trim();
final isBestSign = normalizedSignType.contains('上上');
final isGoodSign = !isBestSign && normalizedSignType.contains('中上');
final isWorstSign = normalizedSignType.contains('下下');
final signStyle = switch (item.sign) {
_HistorySign.best => (palette.historyGoldBg, palette.historyGoldText),
_HistorySign.good => (colors.surfaceContainerHighest, colors.primary),
_HistorySign.normal => (palette.historyGrayBg, palette.historyGrayText),
};
final signLabel = isBestSign
? l10n.signTypeShangShang
: isGoodSign
? l10n.signTypeZhongShang
: isWorstSign
? l10n.signTypeXiaXia
: l10n.signTypeZhongXia;
return Card(
margin: EdgeInsets.zero,
color: colors.surface,
elevation: 2,
shape: RoundedRectangleBorder(
final signStyle = isBestSign
? (palette.historyGoldBg, palette.historyGoldText)
: isGoodSign
? (colors.surfaceContainerHighest, colors.primary)
: isWorstSign
? (colors.errorContainer, colors.onErrorContainer)
: (palette.historyGrayBg, palette.historyGrayText);
return Material(
color: colors.surface.withValues(alpha: 0),
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.question,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
onTap: onTap,
child: Card(
margin: EdgeInsets.zero,
color: colors.surface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Tag(
label: categoryLabel,
background: categoryStyle.$1,
foreground: categoryStyle.$2,
Text(
item.params.question,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
_Tag(
label: item.guaName,
background: palette.historyBlueBg,
foreground: palette.historyBlueText,
),
_Tag(
label: signLabel,
background: signStyle.$1,
foreground: signStyle.$2,
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
_Tag(
label: categoryLabel,
background: categoryStyle.$1,
foreground: categoryStyle.$2,
),
_Tag(
label: item.guaName,
background: palette.historyBlueBg,
foreground: palette.historyBlueText,
),
_Tag(
label: signLabel,
background: signStyle.$1,
foreground: signStyle.$2,
),
],
),
],
),
],
),
),
),
);
@@ -416,6 +450,57 @@ class _Tag extends StatelessWidget {
}
}
class _HistoryRecordsScreen extends StatelessWidget {
const _HistoryRecordsScreen({
required this.records,
required this.onOpenResult,
});
final List<DivinationResultData> records;
final ValueChanged<DivinationResultData> onOpenResult;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: Text(l10n.historyTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: records.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.noRecords,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(l10n.noRecordsSubtitle),
],
),
)
: ListView.separated(
padding: const EdgeInsets.all(AppSpacing.md),
itemBuilder: (context, index) {
final item = records[index];
return _HistoryCard(
item: item,
onTap: () => onOpenResult(item),
);
},
separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.md),
itemCount: records.length,
),
);
}
}
class _WelcomeDialog extends StatefulWidget {
const _WelcomeDialog({required this.onDone});
@@ -576,21 +661,3 @@ class _WelcomeDialogState extends State<_WelcomeDialog> {
);
}
}
enum _HistoryCategory { career, love, money }
enum _HistorySign { best, good, normal }
class _HistoryItemData {
const _HistoryItemData({
required this.question,
required this.category,
required this.guaName,
required this.sign,
});
final String question;
final _HistoryCategory category;
final String guaName;
final _HistorySign sign;
}
@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../models/profile_settings.dart';
class ProfileApi {
const ProfileApi({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient;
Future<ProfileSettingsV1> getProfile() async {
final json = await _apiClient.getJson('/api/v1/users/me/profile');
return _toSettings(json);
}
Future<ProfileSettingsV1> updateProfile(ProfileSettingsV1 next) async {
final payload = <String, dynamic>{
'display_name': next.displayName,
'bio': next.bio,
if (next.avatarPath != null && next.avatarPath!.isNotEmpty)
'avatar_path': next.avatarPath,
};
final json = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/users/me/profile',
data: payload,
);
final data = json.data;
if (data is! Map<String, dynamic>) {
throw ApiProblem(
status: 502,
title: 'Invalid profile payload',
detail: 'Expected profile response object',
);
}
return _toSettings(data);
}
Future<ProfileSettingsV1> uploadAvatar(String filePath) async {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath),
});
final response = await _apiClient.rawDio.post<Map<String, dynamic>>(
'/api/v1/users/me/avatar',
data: formData,
);
final data = response.data;
if (data is! Map<String, dynamic>) {
throw ApiProblem(
status: 502,
title: 'Invalid profile payload',
detail: 'Expected profile response object',
);
}
return _toSettings(data);
}
ProfileSettingsV1 _toSettings(Map<String, dynamic> json) {
final settingsRaw = json['settings'];
final preferencesRaw = settingsRaw is Map<String, dynamic>
? settingsRaw['preferences']
: null;
final preferences = preferencesRaw is Map<String, dynamic>
? PreferenceSettings(
interfaceLanguage:
(preferencesRaw['interface_language'] as String?) ?? 'zh-CN',
aiLanguage: (preferencesRaw['ai_language'] as String?) ?? 'zh-CN',
timezone:
(preferencesRaw['timezone'] as String?) ?? 'Asia/Shanghai',
country: (preferencesRaw['country'] as String?) ?? 'CN',
)
: const PreferenceSettings();
return ProfileSettingsV1(
displayName: (json['display_name'] as String?) ?? '',
bio: (json['bio'] as String?) ?? '',
avatarPath: json['avatar_path'] as String?,
avatarUrl: json['avatar_url'] as String?,
preferences: preferences,
privacy: settingsRaw is Map<String, dynamic>
? (settingsRaw['privacy'] as Map<String, dynamic>? ??
const <String, dynamic>{})
: const <String, dynamic>{},
notification: settingsRaw is Map<String, dynamic>
? (settingsRaw['notification'] as Map<String, dynamic>? ??
const <String, dynamic>{})
: const <String, dynamic>{},
);
}
}
@@ -40,24 +40,40 @@ class PreferenceSettings {
class ProfileSettingsV1 {
const ProfileSettingsV1({
this.version = 1,
this.displayName = '',
this.bio = '',
this.avatarPath,
this.avatarUrl,
this.preferences = const PreferenceSettings(),
this.privacy = const <String, Object?>{},
this.notification = const <String, Object?>{},
});
final int version;
final String displayName;
final String bio;
final String? avatarPath;
final String? avatarUrl;
final PreferenceSettings preferences;
final Map<String, Object?> privacy;
final Map<String, Object?> notification;
ProfileSettingsV1 copyWith({
int? version,
String? displayName,
String? bio,
String? avatarPath,
String? avatarUrl,
PreferenceSettings? preferences,
Map<String, Object?>? privacy,
Map<String, Object?>? notification,
}) {
return ProfileSettingsV1(
version: version ?? this.version,
displayName: displayName ?? this.displayName,
bio: bio ?? this.bio,
avatarPath: avatarPath ?? this.avatarPath,
avatarUrl: avatarUrl ?? this.avatarUrl,
preferences: preferences ?? this.preferences,
privacy: privacy ?? this.privacy,
notification: notification ?? this.notification,
@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../../../core/logging/logger.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/profile_settings.dart';
class ProfileEditScreen extends StatefulWidget {
const ProfileEditScreen({
super.key,
required this.account,
required this.settings,
required this.onUploadAvatar,
});
final String account;
final ProfileSettingsV1 settings;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
@override
State<ProfileEditScreen> createState() => _ProfileEditScreenState();
}
class _ProfileEditScreenState extends State<ProfileEditScreen> {
final Logger _logger = getLogger('features.settings.profile_edit_screen');
final ImagePicker _imagePicker = ImagePicker();
late final TextEditingController _nameController;
late final TextEditingController _bioController;
bool _uploadingAvatar = false;
String? _avatarPath;
String? _avatarPreviewUrl;
@override
void initState() {
super.initState();
_nameController = TextEditingController(
text: widget.settings.displayName.isEmpty
? widget.account
: widget.settings.displayName,
);
_bioController = TextEditingController(text: widget.settings.bio);
_avatarPath = widget.settings.avatarPath;
_avatarPreviewUrl = widget.settings.avatarUrl;
}
@override
void dispose() {
_nameController.dispose();
_bioController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: Text(l10n.settingsEditProfileTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.lg),
children: [
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: colors.outlineVariant),
),
child: Column(
children: [
Text(
l10n.settingsAvatar,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.lg),
Stack(
alignment: Alignment.bottomRight,
children: [
Container(
width: 112,
height: 112,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.surfaceContainerHighest,
border: Border.all(
color: colors.primary.withValues(alpha: 0.3),
width: 2,
),
),
clipBehavior: Clip.antiAlias,
child:
(_avatarPreviewUrl != null &&
_avatarPreviewUrl!.isNotEmpty)
? Image.network(
_avatarPreviewUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.person,
size: 44,
color: colors.primary,
);
},
)
: Icon(Icons.person, size: 44, color: colors.primary),
),
FilledButton(
onPressed: _uploadingAvatar ? null : _pickAndUploadAvatar,
style: FilledButton.styleFrom(
minimumSize: const Size(44, 44),
shape: const CircleBorder(),
padding: EdgeInsets.zero,
),
child: _uploadingAvatar
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.photo_camera_outlined, size: 20),
),
],
),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _uploadingAvatar ? null : _pickAndUploadAvatar,
icon: const Icon(Icons.photo_library_outlined),
label: Text(
_uploadingAvatar
? l10n.settingsAvatarUploading
: l10n.settingsAvatarChooseFromAlbum,
),
),
),
],
),
),
const SizedBox(height: AppSpacing.xl),
Text(
l10n.settingsDisplayName,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: _nameController,
maxLength: 20,
decoration: InputDecoration(
hintText: l10n.settingsDisplayNameHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
),
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.settingsBio,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: _bioController,
minLines: 3,
maxLines: 5,
maxLength: 80,
decoration: InputDecoration(
hintText: l10n.settingsBioHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
),
),
const SizedBox(height: AppSpacing.xl),
SizedBox(
width: double.infinity,
child: FilledButton(onPressed: _save, child: Text(l10n.confirm)),
),
],
),
);
}
void _save() {
final l10n = AppLocalizations.of(context)!;
final name = _nameController.text.trim();
if (name.isEmpty) {
Toast.show(
context,
l10n.settingsDisplayNameRequired,
type: ToastType.warning,
);
return;
}
Navigator.of(context).pop(
widget.settings.copyWith(
displayName: name,
bio: _bioController.text.trim(),
avatarPath: _avatarPath,
avatarUrl: _avatarPreviewUrl,
),
);
}
Future<void> _pickAndUploadAvatar() async {
XFile? picked;
try {
picked = await _imagePicker.pickImage(
source: ImageSource.gallery,
maxWidth: 1024,
imageQuality: 85,
requestFullMetadata: false,
);
} catch (error, stackTrace) {
_logger.error(
message: 'Avatar picker failed to open photo library',
error: error,
stackTrace: stackTrace,
);
if (!mounted) {
return;
}
Toast.show(
context,
AppLocalizations.of(context)!.settingsAvatarPickPermissionHint,
type: ToastType.error,
);
return;
}
if (picked == null || !mounted) {
return;
}
setState(() {
_uploadingAvatar = true;
});
try {
final updated = await widget.onUploadAvatar(picked.path);
if (!mounted) {
return;
}
setState(() {
_avatarPath = updated.avatarPath;
_avatarPreviewUrl = updated.avatarUrl;
});
} catch (error, stackTrace) {
_logger.error(
message: 'Avatar upload failed from profile editor',
error: error,
stackTrace: stackTrace,
);
if (!mounted) {
return;
}
Toast.show(
context,
AppLocalizations.of(context)!.errorRequestGeneric,
type: ToastType.error,
);
} finally {
if (mounted) {
setState(() {
_uploadingAvatar = false;
});
}
}
}
}
@@ -1,12 +1,17 @@
import 'package:flutter/material.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../core/logging/logger.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.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 'profile_edit_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({
@@ -15,6 +20,8 @@ class SettingsScreen extends StatefulWidget {
required this.settings,
required this.coinBalance,
required this.onInterfaceLanguageChanged,
required this.onSettingsChanged,
required this.onUploadAvatar,
required this.onLogout,
});
@@ -22,6 +29,8 @@ class SettingsScreen extends StatefulWidget {
final ProfileSettingsV1 settings;
final int coinBalance;
final Future<void> Function(String languageTag) onInterfaceLanguageChanged;
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function() onLogout;
@override
@@ -29,6 +38,7 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
final Logger _logger = getLogger('features.settings.settings_screen');
late ProfileSettingsV1 _settings;
bool _isLoggingOut = false;
@@ -38,6 +48,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
_settings = widget.settings;
}
@override
void didUpdateWidget(covariant SettingsScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.settings != widget.settings) {
_settings = widget.settings;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -59,7 +77,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
AppSpacing.xl,
),
children: [
ProfileHeaderCard(account: widget.account),
ProfileHeaderCard(
account: widget.account,
displayName: _settings.displayName.isEmpty
? widget.account
: _settings.displayName,
bio: _settings.bio,
avatarUrl: _settings.avatarUrl,
onEditTap: _openProfileEdit,
),
const SizedBox(height: AppSpacing.lg),
WalletHeroCard(
balance: widget.coinBalance,
@@ -67,7 +93,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: _openCoinCenter,
),
const SizedBox(height: AppSpacing.xl),
SectionLabel(text: l10n.settingsSectionQuickAccess),
SettingsGroupCard(
children: [
SettingsMenuTile(
@@ -131,6 +156,44 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _openProfileEdit() async {
final result = await Navigator.of(context).push<ProfileSettingsV1>(
MaterialPageRoute<ProfileSettingsV1>(
builder: (_) => ProfileEditScreen(
account: widget.account,
settings: _settings,
onUploadAvatar: widget.onUploadAvatar,
),
),
);
if (result == null || !mounted) {
return;
}
try {
await widget.onSettingsChanged(result);
if (!mounted) {
return;
}
setState(() {
_settings = result;
});
} catch (error, stackTrace) {
_logger.error(
message: 'Failed to save profile settings',
error: error,
stackTrace: stackTrace,
);
if (!mounted) {
return;
}
Toast.show(
context,
AppLocalizations.of(context)!.errorRequestGeneric,
type: ToastType.error,
);
}
}
Future<void> _openLegalCenter() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(builder: (_) => const LegalCenterScreen()),
@@ -142,21 +205,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(l10n.settingsLogoutDialogTitle),
content: Text(l10n.settingsLogoutDialogBody),
return AppModalDialog(
title: l10n.settingsLogoutDialogTitle,
message: l10n.settingsLogoutDialogBody,
icon: Icons.logout_rounded,
actions: [
TextButton(
AppModalDialogAction(
label: l10n.settingsCancel,
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.settingsCancel),
),
FilledButton(
AppModalDialogAction(
label: l10n.logout,
primary: true,
destructive: true,
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
foregroundColor: Theme.of(dialogContext).colorScheme.onError,
),
child: Text(l10n.logout),
),
],
);
@@ -171,6 +233,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
try {
await widget.onLogout();
if (!mounted) {
return;
}
Navigator.of(context).popUntil((route) => route.isFirst);
} finally {
if (mounted) {
setState(() {
@@ -120,9 +120,20 @@ class SettingsMenuTile extends StatelessWidget {
}
class ProfileHeaderCard extends StatelessWidget {
const ProfileHeaderCard({super.key, required this.account});
const ProfileHeaderCard({
super.key,
required this.account,
required this.displayName,
required this.bio,
required this.avatarUrl,
required this.onEditTap,
});
final String account;
final String displayName;
final String bio;
final String? avatarUrl;
final VoidCallback onEditTap;
@override
Widget build(BuildContext context) {
@@ -137,22 +148,83 @@ class ProfileHeaderCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 28,
backgroundColor: colors.surfaceContainerHighest,
child: Icon(Icons.person_rounded, color: colors.primary),
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.full),
),
alignment: Alignment.center,
child: _AvatarContent(avatarUrl: avatarUrl),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(account, style: Theme.of(context).textTheme.titleMedium),
Text(
displayName,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.xs),
Text(
account,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (bio.isNotEmpty) ...[
const SizedBox(height: AppSpacing.sm),
Text(
bio,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
),
),
Icon(Icons.edit_outlined, color: colors.outline, size: 20),
const SizedBox(width: AppSpacing.sm),
Material(
color: colors.surface,
elevation: 2,
shadowColor: colors.shadow.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(AppRadius.full),
child: InkWell(
onTap: onEditTap,
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: colors.primary.withValues(alpha: 0.24),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_rounded, color: colors.primary, size: 18),
const SizedBox(width: AppSpacing.xs),
Text(
AppLocalizations.of(context)!.settingsEditProfileAction,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
],
),
),
@@ -160,6 +232,32 @@ class ProfileHeaderCard extends StatelessWidget {
}
}
class _AvatarContent extends StatelessWidget {
const _AvatarContent({required this.avatarUrl});
final String? avatarUrl;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final url = avatarUrl?.trim() ?? '';
if (url.isNotEmpty) {
return ClipOval(
child: Image.network(
url,
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(Icons.person_rounded, color: colors.primary, size: 30);
},
),
);
}
return Icon(Icons.person_rounded, color: colors.primary, size: 30);
}
}
class WalletHeroCard extends StatelessWidget {
const WalletHeroCard({
super.key,
+58 -4
View File
@@ -74,9 +74,10 @@
"categoryCareer": "Career/Study",
"categoryLove": "Love/Marriage",
"categoryMoney": "Wealth/Investment",
"signBest": "Excellent",
"signGood": "Good",
"signNormal": "Moderate",
"signBest": "Supremely Auspicious",
"signGood": "Auspicious",
"signNormal": "Cautionary",
"signBad": "Inauspicious",
"language": "Language",
"settingsTitle": "Settings",
"settingsSectionGeneral": "General",
@@ -142,6 +143,19 @@
"settingsCancel": "Cancel",
"settingsLogoutConfirmHint": "Tap again to confirm logout",
"settingsLogoutConfirmAction": "Tap again to logout",
"settingsEditProfileAction": "Edit",
"settingsEditProfileTitle": "Edit Profile",
"settingsAvatar": "Avatar",
"settingsDisplayName": "Display Name",
"settingsDisplayNameHint": "Enter display name",
"settingsDisplayNameRequired": "Display name is required",
"settingsBio": "Bio",
"settingsBioHint": "Write a short introduction",
"settingsAvatarPickerHint": "Supports PNG / JPG / WEBP. A clear square photo works best.",
"settingsAvatarChooseFromAlbum": "Choose from Photos",
"settingsAvatarUploading": "Uploading...",
"settingsAvatarUploadSuccess": "Avatar uploaded",
"settingsAvatarPickPermissionHint": "Cannot open photo library. Please allow Photos access in system settings.",
"settingsLanguageSection": "Interface Language",
"settingsCoinBalanceLabel": "Current Credits",
"settingsCoinBalanceValue": "{balance} credits",
@@ -227,6 +241,19 @@
"questionTypeOther": "Other",
"toastPleaseInputQuestion": "Please enter your question",
"toastCoinInsufficient": "Insufficient coins",
"divinationCostDialogTitle": "Confirm divination",
"divinationCostDialogBody": "This run costs {cost} credits. Available balance: {balance} credits. Continue?",
"@divinationCostDialogBody": {
"placeholders": {
"cost": {
"type": "int"
},
"balance": {
"type": "int"
}
}
},
"divinationCostDialogConfirm": "Start",
"toastContentCopied": "Content copied",
"toastContentCopiedWithTitle": "{title} copied",
"@toastContentCopiedWithTitle": {
@@ -251,14 +278,41 @@
"resultQuestion": "Question",
"resultAutoMethod": "Auto",
"resultManualMethod": "Manual",
"signTypeShangShang": "Supremely Auspicious",
"signTypeZhongShang": "Auspicious",
"signTypeZhongXia": "Cautionary",
"signTypeXiaXia": "Inauspicious",
"resultCopy": "Copy",
"resultWarning": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice.",
"transitionPreparing": "Deriving...",
"transitionDeriving": "Analyzing...",
"transitionDone": "Complete\nTap to view",
"processingCardQianTitle": "Qian • The Creative",
"processingCardQianQuote": "The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.",
"processingCardDuiTitle": "Dui • The Joyous",
"processingCardDuiQuote": "Joy grounded in integrity brings openness, harmony, and right expression.",
"processingCardLiTitle": "Li • The Clinging Fire",
"processingCardLiQuote": "With clear brilliance, the great one illumines all directions.",
"processingCardZhenTitle": "Zhen • The Arousing Thunder",
"processingCardZhenQuote": "Shock awakens the heart; composure turns fear into growth.",
"processingCardXunTitle": "Xun • The Gentle Wind",
"processingCardXunQuote": "Gentle penetration furthers progress and helps one meet the right people.",
"processingCardKanTitle": "Kan • The Abysmal Water",
"processingCardKanQuote": "In danger, sincerity and disciplined action carry one through.",
"processingCardGenTitle": "Gen • Keeping Still Mountain",
"processingCardGenQuote": "Stillness at the proper time keeps one centered and steady in place.",
"processingCardKunTitle": "Kun • The Receptive Earth",
"processingCardKunQuote": "The Earth's condition is devoted receptivity; the noble one carries all with broad virtue.",
"ganZhiInfo": "GanZhi Info",
"wuXingWangShuai": "WuXing Strength",
"ganZhiKongWang": "KongWang",
"ganZhiKongWang": "KongWang Info",
"resultPillarColumn": "Pillar",
"resultYearPillar": "Year",
"resultMonthPillar": "Month",
"resultDayPillar": "Day",
"resultTimePillar": "Hour",
"resultGanZhiLabel": "GanZhi",
"resultKongWangLabel": "KongWang",
"manualScreenTitle": "Manual Casting",
"manualSelectTime": "Select time",
"manualSpecifyYaoCombo": "Select coin combination",
+265 -1
View File
@@ -476,6 +476,12 @@ abstract class AppLocalizations {
/// **'中下签'**
String get signNormal;
/// No description provided for @signBad.
///
/// In zh, this message translates to:
/// **'下下签'**
String get signBad;
/// No description provided for @language.
///
/// In zh, this message translates to:
@@ -740,6 +746,84 @@ abstract class AppLocalizations {
/// **'再次点击确认退出'**
String get settingsLogoutConfirmAction;
/// No description provided for @settingsEditProfileAction.
///
/// In zh, this message translates to:
/// **'编辑'**
String get settingsEditProfileAction;
/// No description provided for @settingsEditProfileTitle.
///
/// In zh, this message translates to:
/// **'编辑个人信息'**
String get settingsEditProfileTitle;
/// No description provided for @settingsAvatar.
///
/// In zh, this message translates to:
/// **'头像'**
String get settingsAvatar;
/// No description provided for @settingsDisplayName.
///
/// In zh, this message translates to:
/// **'昵称'**
String get settingsDisplayName;
/// No description provided for @settingsDisplayNameHint.
///
/// In zh, this message translates to:
/// **'请输入昵称'**
String get settingsDisplayNameHint;
/// No description provided for @settingsDisplayNameRequired.
///
/// In zh, this message translates to:
/// **'请输入昵称后再保存'**
String get settingsDisplayNameRequired;
/// No description provided for @settingsBio.
///
/// In zh, this message translates to:
/// **'个人简介'**
String get settingsBio;
/// No description provided for @settingsBioHint.
///
/// In zh, this message translates to:
/// **'一句话介绍你自己'**
String get settingsBioHint;
/// No description provided for @settingsAvatarPickerHint.
///
/// In zh, this message translates to:
/// **'支持 PNG / JPG / WEBP,建议上传清晰正方形头像'**
String get settingsAvatarPickerHint;
/// No description provided for @settingsAvatarChooseFromAlbum.
///
/// In zh, this message translates to:
/// **'从相册选择头像'**
String get settingsAvatarChooseFromAlbum;
/// No description provided for @settingsAvatarUploading.
///
/// In zh, this message translates to:
/// **'上传中...'**
String get settingsAvatarUploading;
/// No description provided for @settingsAvatarUploadSuccess.
///
/// In zh, this message translates to:
/// **'头像上传成功'**
String get settingsAvatarUploadSuccess;
/// No description provided for @settingsAvatarPickPermissionHint.
///
/// In zh, this message translates to:
/// **'无法打开相册,请在系统设置中允许照片访问权限'**
String get settingsAvatarPickPermissionHint;
/// No description provided for @settingsLanguageSection.
///
/// In zh, this message translates to:
@@ -1124,6 +1208,24 @@ abstract class AppLocalizations {
/// **'铜钱不足,无法解卦'**
String get toastCoinInsufficient;
/// No description provided for @divinationCostDialogTitle.
///
/// In zh, this message translates to:
/// **'确认开始解卦'**
String get divinationCostDialogTitle;
/// No description provided for @divinationCostDialogBody.
///
/// In zh, this message translates to:
/// **'本次解卦将消耗 {cost} 点数,当前可用 {balance} 点数。是否继续?'**
String divinationCostDialogBody(int cost, int balance);
/// No description provided for @divinationCostDialogConfirm.
///
/// In zh, this message translates to:
/// **'确认解卦'**
String get divinationCostDialogConfirm;
/// No description provided for @toastContentCopied.
///
/// In zh, this message translates to:
@@ -1226,6 +1328,30 @@ abstract class AppLocalizations {
/// **'手动起卦'**
String get resultManualMethod;
/// No description provided for @signTypeShangShang.
///
/// In zh, this message translates to:
/// **'上上签'**
String get signTypeShangShang;
/// No description provided for @signTypeZhongShang.
///
/// In zh, this message translates to:
/// **'中上签'**
String get signTypeZhongShang;
/// No description provided for @signTypeZhongXia.
///
/// In zh, this message translates to:
/// **'中下签'**
String get signTypeZhongXia;
/// No description provided for @signTypeXiaXia.
///
/// In zh, this message translates to:
/// **'下下签'**
String get signTypeXiaXia;
/// No description provided for @resultCopy.
///
/// In zh, this message translates to:
@@ -1256,6 +1382,102 @@ abstract class AppLocalizations {
/// **'解卦完成\n点击查看'**
String get transitionDone;
/// No description provided for @processingCardQianTitle.
///
/// In zh, this message translates to:
/// **'Qian • The Creative'**
String get processingCardQianTitle;
/// No description provided for @processingCardQianQuote.
///
/// In zh, this message translates to:
/// **'The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.'**
String get processingCardQianQuote;
/// No description provided for @processingCardDuiTitle.
///
/// In zh, this message translates to:
/// **'Dui • The Joyous'**
String get processingCardDuiTitle;
/// No description provided for @processingCardDuiQuote.
///
/// In zh, this message translates to:
/// **'Joy grounded in integrity brings openness, harmony, and right expression.'**
String get processingCardDuiQuote;
/// No description provided for @processingCardLiTitle.
///
/// In zh, this message translates to:
/// **'Li • The Clinging Fire'**
String get processingCardLiTitle;
/// No description provided for @processingCardLiQuote.
///
/// In zh, this message translates to:
/// **'With clear brilliance, the great one illumines all directions.'**
String get processingCardLiQuote;
/// No description provided for @processingCardZhenTitle.
///
/// In zh, this message translates to:
/// **'Zhen • The Arousing Thunder'**
String get processingCardZhenTitle;
/// No description provided for @processingCardZhenQuote.
///
/// In zh, this message translates to:
/// **'Shock awakens the heart; composure turns fear into growth.'**
String get processingCardZhenQuote;
/// No description provided for @processingCardXunTitle.
///
/// In zh, this message translates to:
/// **'Xun • The Gentle Wind'**
String get processingCardXunTitle;
/// No description provided for @processingCardXunQuote.
///
/// In zh, this message translates to:
/// **'Gentle penetration furthers progress and helps one meet the right people.'**
String get processingCardXunQuote;
/// No description provided for @processingCardKanTitle.
///
/// In zh, this message translates to:
/// **'Kan • The Abysmal Water'**
String get processingCardKanTitle;
/// No description provided for @processingCardKanQuote.
///
/// In zh, this message translates to:
/// **'In danger, sincerity and disciplined action carry one through.'**
String get processingCardKanQuote;
/// No description provided for @processingCardGenTitle.
///
/// In zh, this message translates to:
/// **'Gen • Keeping Still Mountain'**
String get processingCardGenTitle;
/// No description provided for @processingCardGenQuote.
///
/// In zh, this message translates to:
/// **'Stillness at the proper time keeps one centered and steady in place.'**
String get processingCardGenQuote;
/// No description provided for @processingCardKunTitle.
///
/// In zh, this message translates to:
/// **'Kun • The Receptive Earth'**
String get processingCardKunTitle;
/// No description provided for @processingCardKunQuote.
///
/// In zh, this message translates to:
/// **'The Earth\'s condition is devoted receptivity; the noble one carries all with broad virtue.'**
String get processingCardKunQuote;
/// No description provided for @ganZhiInfo.
///
/// In zh, this message translates to:
@@ -1271,9 +1493,51 @@ abstract class AppLocalizations {
/// No description provided for @ganZhiKongWang.
///
/// In zh, this message translates to:
/// **'干支空亡'**
/// **'空亡信息'**
String get ganZhiKongWang;
/// No description provided for @resultPillarColumn.
///
/// In zh, this message translates to:
/// **'四柱'**
String get resultPillarColumn;
/// No description provided for @resultYearPillar.
///
/// In zh, this message translates to:
/// **'年柱'**
String get resultYearPillar;
/// No description provided for @resultMonthPillar.
///
/// In zh, this message translates to:
/// **'月柱'**
String get resultMonthPillar;
/// No description provided for @resultDayPillar.
///
/// In zh, this message translates to:
/// **'日柱'**
String get resultDayPillar;
/// No description provided for @resultTimePillar.
///
/// In zh, this message translates to:
/// **'时柱'**
String get resultTimePillar;
/// No description provided for @resultGanZhiLabel.
///
/// In zh, this message translates to:
/// **'干支'**
String get resultGanZhiLabel;
/// No description provided for @resultKongWangLabel.
///
/// In zh, this message translates to:
/// **'空亡'**
String get resultKongWangLabel;
/// No description provided for @manualScreenTitle.
///
/// In zh, this message translates to:
+148 -4
View File
@@ -199,13 +199,16 @@ class AppLocalizationsEn extends AppLocalizations {
String get categoryMoney => 'Wealth/Investment';
@override
String get signBest => 'Excellent';
String get signBest => 'Supremely Auspicious';
@override
String get signGood => 'Good';
String get signGood => 'Auspicious';
@override
String get signNormal => 'Moderate';
String get signNormal => 'Cautionary';
@override
String get signBad => 'Inauspicious';
@override
String get language => 'Language';
@@ -355,6 +358,47 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsLogoutConfirmAction => 'Tap again to logout';
@override
String get settingsEditProfileAction => 'Edit';
@override
String get settingsEditProfileTitle => 'Edit Profile';
@override
String get settingsAvatar => 'Avatar';
@override
String get settingsDisplayName => 'Display Name';
@override
String get settingsDisplayNameHint => 'Enter display name';
@override
String get settingsDisplayNameRequired => 'Display name is required';
@override
String get settingsBio => 'Bio';
@override
String get settingsBioHint => 'Write a short introduction';
@override
String get settingsAvatarPickerHint =>
'Supports PNG / JPG / WEBP. A clear square photo works best.';
@override
String get settingsAvatarChooseFromAlbum => 'Choose from Photos';
@override
String get settingsAvatarUploading => 'Uploading...';
@override
String get settingsAvatarUploadSuccess => 'Avatar uploaded';
@override
String get settingsAvatarPickPermissionHint =>
'Cannot open photo library. Please allow Photos access in system settings.';
@override
String get settingsLanguageSection => 'Interface Language';
@@ -567,6 +611,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get toastCoinInsufficient => 'Insufficient coins';
@override
String get divinationCostDialogTitle => 'Confirm divination';
@override
String divinationCostDialogBody(int cost, int balance) {
return 'This run costs $cost credits. Available balance: $balance credits. Continue?';
}
@override
String get divinationCostDialogConfirm => 'Start';
@override
String get toastContentCopied => 'Content copied';
@@ -620,6 +675,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get resultManualMethod => 'Manual';
@override
String get signTypeShangShang => 'Supremely Auspicious';
@override
String get signTypeZhongShang => 'Auspicious';
@override
String get signTypeZhongXia => 'Cautionary';
@override
String get signTypeXiaXia => 'Inauspicious';
@override
String get resultCopy => 'Copy';
@@ -636,6 +703,62 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get transitionDone => 'Complete\nTap to view';
@override
String get processingCardQianTitle => 'Qian • The Creative';
@override
String get processingCardQianQuote =>
'The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.';
@override
String get processingCardDuiTitle => 'Dui • The Joyous';
@override
String get processingCardDuiQuote =>
'Joy grounded in integrity brings openness, harmony, and right expression.';
@override
String get processingCardLiTitle => 'Li • The Clinging Fire';
@override
String get processingCardLiQuote =>
'With clear brilliance, the great one illumines all directions.';
@override
String get processingCardZhenTitle => 'Zhen • The Arousing Thunder';
@override
String get processingCardZhenQuote =>
'Shock awakens the heart; composure turns fear into growth.';
@override
String get processingCardXunTitle => 'Xun • The Gentle Wind';
@override
String get processingCardXunQuote =>
'Gentle penetration furthers progress and helps one meet the right people.';
@override
String get processingCardKanTitle => 'Kan • The Abysmal Water';
@override
String get processingCardKanQuote =>
'In danger, sincerity and disciplined action carry one through.';
@override
String get processingCardGenTitle => 'Gen • Keeping Still Mountain';
@override
String get processingCardGenQuote =>
'Stillness at the proper time keeps one centered and steady in place.';
@override
String get processingCardKunTitle => 'Kun • The Receptive Earth';
@override
String get processingCardKunQuote =>
'The Earth\'s condition is devoted receptivity; the noble one carries all with broad virtue.';
@override
String get ganZhiInfo => 'GanZhi Info';
@@ -643,7 +766,28 @@ class AppLocalizationsEn extends AppLocalizations {
String get wuXingWangShuai => 'WuXing Strength';
@override
String get ganZhiKongWang => 'KongWang';
String get ganZhiKongWang => 'KongWang Info';
@override
String get resultPillarColumn => 'Pillar';
@override
String get resultYearPillar => 'Year';
@override
String get resultMonthPillar => 'Month';
@override
String get resultDayPillar => 'Day';
@override
String get resultTimePillar => 'Hour';
@override
String get resultGanZhiLabel => 'GanZhi';
@override
String get resultKongWangLabel => 'KongWang';
@override
String get manualScreenTitle => 'Manual Casting';
+143 -1
View File
@@ -205,6 +205,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get signNormal => '中下签';
@override
String get signBad => '下下签';
@override
String get language => '语言';
@@ -349,6 +352,45 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settingsLogoutConfirmAction => '再次点击确认退出';
@override
String get settingsEditProfileAction => '编辑';
@override
String get settingsEditProfileTitle => '编辑个人信息';
@override
String get settingsAvatar => '头像';
@override
String get settingsDisplayName => '昵称';
@override
String get settingsDisplayNameHint => '请输入昵称';
@override
String get settingsDisplayNameRequired => '请输入昵称后再保存';
@override
String get settingsBio => '个人简介';
@override
String get settingsBioHint => '一句话介绍你自己';
@override
String get settingsAvatarPickerHint => '支持 PNG / JPG / WEBP,建议上传清晰正方形头像';
@override
String get settingsAvatarChooseFromAlbum => '从相册选择头像';
@override
String get settingsAvatarUploading => '上传中...';
@override
String get settingsAvatarUploadSuccess => '头像上传成功';
@override
String get settingsAvatarPickPermissionHint => '无法打开相册,请在系统设置中允许照片访问权限';
@override
String get settingsLanguageSection => '界面语言';
@@ -551,6 +593,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get toastCoinInsufficient => '铜钱不足,无法解卦';
@override
String get divinationCostDialogTitle => '确认开始解卦';
@override
String divinationCostDialogBody(int cost, int balance) {
return '本次解卦将消耗 $cost 点数,当前可用 $balance 点数。是否继续?';
}
@override
String get divinationCostDialogConfirm => '确认解卦';
@override
String get toastContentCopied => '分享内容已复制';
@@ -604,6 +657,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get resultManualMethod => '手动起卦';
@override
String get signTypeShangShang => '上上签';
@override
String get signTypeZhongShang => '中上签';
@override
String get signTypeZhongXia => '中下签';
@override
String get signTypeXiaXia => '下下签';
@override
String get resultCopy => '复制';
@@ -620,6 +685,62 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get transitionDone => '解卦完成\n点击查看';
@override
String get processingCardQianTitle => 'Qian • The Creative';
@override
String get processingCardQianQuote =>
'The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.';
@override
String get processingCardDuiTitle => 'Dui • The Joyous';
@override
String get processingCardDuiQuote =>
'Joy grounded in integrity brings openness, harmony, and right expression.';
@override
String get processingCardLiTitle => 'Li • The Clinging Fire';
@override
String get processingCardLiQuote =>
'With clear brilliance, the great one illumines all directions.';
@override
String get processingCardZhenTitle => 'Zhen • The Arousing Thunder';
@override
String get processingCardZhenQuote =>
'Shock awakens the heart; composure turns fear into growth.';
@override
String get processingCardXunTitle => 'Xun • The Gentle Wind';
@override
String get processingCardXunQuote =>
'Gentle penetration furthers progress and helps one meet the right people.';
@override
String get processingCardKanTitle => 'Kan • The Abysmal Water';
@override
String get processingCardKanQuote =>
'In danger, sincerity and disciplined action carry one through.';
@override
String get processingCardGenTitle => 'Gen • Keeping Still Mountain';
@override
String get processingCardGenQuote =>
'Stillness at the proper time keeps one centered and steady in place.';
@override
String get processingCardKunTitle => 'Kun • The Receptive Earth';
@override
String get processingCardKunQuote =>
'The Earth\'s condition is devoted receptivity; the noble one carries all with broad virtue.';
@override
String get ganZhiInfo => '干支信息';
@@ -627,7 +748,28 @@ class AppLocalizationsZh extends AppLocalizations {
String get wuXingWangShuai => '五行旺衰';
@override
String get ganZhiKongWang => '干支空亡';
String get ganZhiKongWang => '空亡信息';
@override
String get resultPillarColumn => '四柱';
@override
String get resultYearPillar => '年柱';
@override
String get resultMonthPillar => '月柱';
@override
String get resultDayPillar => '日柱';
@override
String get resultTimePillar => '时柱';
@override
String get resultGanZhiLabel => '干支';
@override
String get resultKongWangLabel => '空亡';
@override
String get manualScreenTitle => '手动起卦';
+55 -1
View File
@@ -77,6 +77,7 @@
"signBest": "上上签",
"signGood": "中上签",
"signNormal": "中下签",
"signBad": "下下签",
"language": "语言",
"settingsTitle": "设置",
"settingsSectionGeneral": "通用设置",
@@ -142,6 +143,19 @@
"settingsCancel": "取消",
"settingsLogoutConfirmHint": "再次点击确认退出登录",
"settingsLogoutConfirmAction": "再次点击确认退出",
"settingsEditProfileAction": "编辑",
"settingsEditProfileTitle": "编辑个人信息",
"settingsAvatar": "头像",
"settingsDisplayName": "昵称",
"settingsDisplayNameHint": "请输入昵称",
"settingsDisplayNameRequired": "请输入昵称后再保存",
"settingsBio": "个人简介",
"settingsBioHint": "一句话介绍你自己",
"settingsAvatarPickerHint": "支持 PNG / JPG / WEBP,建议上传清晰正方形头像",
"settingsAvatarChooseFromAlbum": "从相册选择头像",
"settingsAvatarUploading": "上传中...",
"settingsAvatarUploadSuccess": "头像上传成功",
"settingsAvatarPickPermissionHint": "无法打开相册,请在系统设置中允许照片访问权限",
"settingsLanguageSection": "界面语言",
"settingsCoinBalanceLabel": "当前点数",
"settingsCoinBalanceValue": "{balance} 点数",
@@ -227,6 +241,19 @@
"questionTypeOther": "其他",
"toastPleaseInputQuestion": "请输入您想占卜的问题",
"toastCoinInsufficient": "铜钱不足,无法解卦",
"divinationCostDialogTitle": "确认开始解卦",
"divinationCostDialogBody": "本次解卦将消耗 {cost} 点数,当前可用 {balance} 点数。是否继续?",
"@divinationCostDialogBody": {
"placeholders": {
"cost": {
"type": "int"
},
"balance": {
"type": "int"
}
}
},
"divinationCostDialogConfirm": "确认解卦",
"toastContentCopied": "分享内容已复制",
"toastContentCopiedWithTitle": "{title}已复制",
"@toastContentCopiedWithTitle": {
@@ -251,14 +278,41 @@
"resultQuestion": "占卜问题",
"resultAutoMethod": "自动起卦",
"resultManualMethod": "手动起卦",
"signTypeShangShang": "上上签",
"signTypeZhongShang": "中上签",
"signTypeZhongXia": "中下签",
"signTypeXiaXia": "下下签",
"resultCopy": "复制",
"resultWarning": "卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。",
"transitionPreparing": "天机推演中",
"transitionDeriving": "正在解卦",
"transitionDone": "解卦完成\n点击查看",
"processingCardQianTitle": "Qian • The Creative",
"processingCardQianQuote": "The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.",
"processingCardDuiTitle": "Dui • The Joyous",
"processingCardDuiQuote": "Joy grounded in integrity brings openness, harmony, and right expression.",
"processingCardLiTitle": "Li • The Clinging Fire",
"processingCardLiQuote": "With clear brilliance, the great one illumines all directions.",
"processingCardZhenTitle": "Zhen • The Arousing Thunder",
"processingCardZhenQuote": "Shock awakens the heart; composure turns fear into growth.",
"processingCardXunTitle": "Xun • The Gentle Wind",
"processingCardXunQuote": "Gentle penetration furthers progress and helps one meet the right people.",
"processingCardKanTitle": "Kan • The Abysmal Water",
"processingCardKanQuote": "In danger, sincerity and disciplined action carry one through.",
"processingCardGenTitle": "Gen • Keeping Still Mountain",
"processingCardGenQuote": "Stillness at the proper time keeps one centered and steady in place.",
"processingCardKunTitle": "Kun • The Receptive Earth",
"processingCardKunQuote": "The Earth's condition is devoted receptivity; the noble one carries all with broad virtue.",
"ganZhiInfo": "干支信息",
"wuXingWangShuai": "五行旺衰",
"ganZhiKongWang": "干支空亡",
"ganZhiKongWang": "空亡信息",
"resultPillarColumn": "四柱",
"resultYearPillar": "年柱",
"resultMonthPillar": "月柱",
"resultDayPillar": "日柱",
"resultTimePillar": "时柱",
"resultGanZhiLabel": "干支",
"resultKongWangLabel": "空亡",
"manualScreenTitle": "手动起卦",
"manualSelectTime": "选择起卦时间",
"manualSpecifyYaoCombo": "指定铜钱字花组合",
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
class AppModalDialogAction {
const AppModalDialogAction({
required this.label,
required this.onPressed,
this.primary = false,
this.destructive = false,
});
final String label;
final VoidCallback onPressed;
final bool primary;
final bool destructive;
}
class AppModalDialog extends StatelessWidget {
const AppModalDialog({
super.key,
required this.title,
required this.message,
required this.actions,
this.icon,
});
final String title;
final String message;
final IconData? icon;
final List<AppModalDialogAction> actions;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Dialog(
insetPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.xl,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.lg,
AppSpacing.lg,
AppSpacing.md,
),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: colors.outlineVariant),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (icon != null)
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.primaryContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
alignment: Alignment.center,
child: Icon(icon, color: colors.primary, size: 20),
),
if (icon != null) const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: AppSpacing.md),
Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
height: 1.45,
),
),
const SizedBox(height: AppSpacing.lg),
Row(
children: actions
.map((action) {
final child = action.primary
? FilledButton(
onPressed: action.onPressed,
style: FilledButton.styleFrom(
backgroundColor: action.destructive
? colors.error
: colors.primary,
foregroundColor: action.destructive
? colors.onError
: colors.onPrimary,
minimumSize: const Size.fromHeight(44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.full,
),
),
),
child: Text(action.label),
)
: OutlinedButton(
onPressed: action.onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: colors.onSurface,
side: BorderSide(color: colors.outline),
minimumSize: const Size.fromHeight(44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.full,
),
),
),
child: Text(action.label),
);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
),
child: child,
),
);
})
.toList(growable: false),
),
],
),
),
);
}
}
@@ -28,6 +28,7 @@ abstract final class DivinationTerms {
static const signBest = '上上签';
static const signGood = '中上签';
static const signNormal = '中下签';
static const signWorst = '下下签';
static const ganZhi = '干支';
static const ganZhiInfo = '干支信息';