feat: 实现用户画像、占卜历史与后端用户管理模块
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+184
-35
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user