feat: 优化 Agent 运行时与聊天设置体验

This commit is contained in:
qzl
2026-03-16 18:32:09 +08:00
parent 3f79cf0df7
commit 5a34616287
41 changed files with 2603 additions and 1263 deletions
@@ -308,7 +308,7 @@ class HistoryMessage {
required this.role,
required this.content,
required this.timestamp,
this.url,
this.attachments = const <HistoryAttachment>[],
this.uiSchema,
});
@@ -317,7 +317,7 @@ class HistoryMessage {
final String role;
final String content;
final DateTime timestamp;
final String? url;
final List<HistoryAttachment> attachments;
final Map<String, dynamic>? uiSchema;
factory HistoryMessage.fromJson(Map<String, dynamic> json) => HistoryMessage(
@@ -327,11 +327,25 @@ class HistoryMessage {
content: _asString(json['content']),
timestamp:
DateTime.tryParse(_asString(json['timestamp'])) ?? DateTime.now(),
url: json['url'] as String?,
attachments: _parseHistoryAttachments(json['attachments']),
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
);
}
class HistoryAttachment {
const HistoryAttachment({required this.url, required this.mimeType});
final String url;
final String mimeType;
factory HistoryAttachment.fromJson(Map<String, dynamic> json) {
return HistoryAttachment(
url: _asString(json['url']),
mimeType: _asString(json['mimeType']),
);
}
}
String _asString(Object? value, {String fallback = ''}) {
if (value is String) {
return value;
@@ -368,3 +382,17 @@ Map<String, dynamic>? _asMap(Object? value) {
}
return null;
}
List<HistoryAttachment> _parseHistoryAttachments(Object? value) {
if (value is! List) {
return const <HistoryAttachment>[];
}
return value
.whereType<Map<String, dynamic>>()
.map(HistoryAttachment.fromJson)
.where(
(attachment) =>
attachment.url.isNotEmpty && attachment.mimeType.isNotEmpty,
)
.toList();
}
@@ -0,0 +1,20 @@
enum AgentStage { intent, execution }
AgentStage? stageFromStepName(String value) {
switch (value) {
case 'router':
return AgentStage.intent;
case 'worker':
return AgentStage.execution;
default:
return null;
}
}
String stageLabel(AgentStage? stage) {
return switch (stage) {
AgentStage.intent => '意图识别中',
AgentStage.execution => '任务执行中',
null => '任务处理中',
};
}
@@ -7,8 +7,7 @@ import 'package:social_app/core/api/i_api_client.dart';
import '../../data/models/ag_ui_event.dart';
import '../../data/models/chat_list_item.dart';
import '../../data/services/ag_ui_service.dart';
enum AgentStage { intent, execution, report }
import 'agent_stage.dart';
class ChatState {
final List<ChatListItem> items;
@@ -93,6 +92,19 @@ class ChatBloc extends Cubit<ChatState> {
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
<String, Future<Uint8List?>>{};
/// Common state reset for run completion (success/error/cancel)
ChatState _resetRunState({String? error, String? currentMessageId}) {
return state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
currentMessageId: currentMessageId,
error: error,
currentStage: null,
);
}
void _handleEvent(AgUiEvent event) {
switch (event.type) {
case AgUiEventType.runStarted:
@@ -106,29 +118,10 @@ class ChatBloc extends Cubit<ChatState> {
),
);
case AgUiEventType.runFinished:
emit(
state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
currentMessageId: null,
currentStage: null,
),
);
emit(_resetRunState());
case AgUiEventType.runError:
final errorEvent = event as RunErrorEvent;
emit(
state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
currentMessageId: null,
error: errorEvent.message,
currentStage: null,
),
);
emit(_resetRunState(error: errorEvent.message));
case AgUiEventType.stepStarted:
_handleStepStarted(event as StepStartedEvent);
case AgUiEventType.stepFinished:
@@ -151,57 +144,27 @@ class ChatBloc extends Cubit<ChatState> {
}
void _handleStepStarted(StepStartedEvent event) {
emit(state.copyWith(currentStage: _stageFromName(event.stepName)));
emit(state.copyWith(currentStage: stageFromStepName(event.stepName)));
}
void _handleStepFinished(StepFinishedEvent event) {
if (state.currentStage == _stageFromName(event.stepName)) {
if (state.currentStage == stageFromStepName(event.stepName)) {
emit(state.copyWith(currentStage: null));
}
}
void _handleTextMessageEnd(TextMessageEndEvent event) {
final timestamp = DateTime.now();
final items = List<ChatListItem>.from(state.items);
final messageIndex = items.indexWhere(
(item) => item.id == event.messageId && item is TextMessageItem,
final items = _updateOrAddMessage(
state.items,
event.messageId,
event.answer,
timestamp,
);
if (messageIndex >= 0) {
final existing = items[messageIndex] as TextMessageItem;
items[messageIndex] = existing.copyWith(
content: event.answer,
isStreaming: false,
);
} else {
items.add(
TextMessageItem(
id: event.messageId,
content: event.answer,
timestamp: timestamp,
sender: MessageSender.ai,
isStreaming: false,
),
);
}
final uiSchema = event.uiSchema;
if (uiSchema != null) {
final uiItemId = '${event.messageId}-ui';
final existingUiIndex = items.indexWhere((item) => item.id == uiItemId);
final uiItem = ToolResultItem(
id: uiItemId,
callId: event.messageId,
uiSchema: uiSchema,
timestamp: timestamp,
sender: MessageSender.ai,
);
if (existingUiIndex >= 0) {
items[existingUiIndex] = uiItem;
} else {
items.add(uiItem);
}
_upsertUiSchema(items, event.messageId, uiSchema, timestamp);
}
emit(
@@ -214,6 +177,56 @@ class ChatBloc extends Cubit<ChatState> {
);
}
List<ChatListItem> _updateOrAddMessage(
List<ChatListItem> items,
String messageId,
String content,
DateTime timestamp,
) {
final result = List<ChatListItem>.from(items);
final index = result.indexWhere(
(item) => item.id == messageId && item is TextMessageItem,
);
if (index >= 0) {
final existing = result[index] as TextMessageItem;
result[index] = existing.copyWith(content: content, isStreaming: false);
} else {
result.add(
TextMessageItem(
id: messageId,
content: content,
timestamp: timestamp,
sender: MessageSender.ai,
isStreaming: false,
),
);
}
return result;
}
void _upsertUiSchema(
List<ChatListItem> items,
String messageId,
Map<String, dynamic> uiSchema,
DateTime timestamp,
) {
final uiItemId = '$messageId-ui';
final existingIndex = items.indexWhere((item) => item.id == uiItemId);
final uiItem = ToolResultItem(
id: uiItemId,
callId: messageId,
uiSchema: uiSchema,
timestamp: timestamp,
sender: MessageSender.ai,
);
if (existingIndex >= 0) {
items[existingIndex] = uiItem;
} else {
items.add(uiItem);
}
}
void _handleToolCallStart(ToolCallStartEvent event) {
final items = List<ChatListItem>.from(state.items)
..add(
@@ -299,10 +312,14 @@ class ChatBloc extends Cubit<ChatState> {
final converted = <ChatListItem>[];
for (final msg in messages) {
final sender = msg.role == 'user' ? MessageSender.user : MessageSender.ai;
final attachments = <Map<String, dynamic>>[];
if (msg.url != null && msg.url!.isNotEmpty) {
attachments.add({'url': msg.url!, 'mimeType': 'image/*'});
}
final attachments = msg.attachments
.map(
(attachment) => <String, dynamic>{
'url': attachment.url,
'mimeType': attachment.mimeType,
},
)
.toList();
if (msg.content.isNotEmpty || sender == MessageSender.user) {
converted.add(
@@ -500,16 +517,3 @@ class ChatBloc extends Cubit<ChatState> {
emit(state.copyWith(error: null));
}
}
AgentStage? _stageFromName(String value) {
switch (value) {
case 'intent':
return AgentStage.intent;
case 'execution':
return AgentStage.execution;
case 'report':
return AgentStage.report;
default:
return null;
}
}
@@ -94,24 +94,28 @@ class UiSchemaRenderer {
final status = _asString(node['status']);
final style = switch (role) {
'title' => const TextStyle(
fontSize: 17,
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
height: 1.25,
height: 1.2,
),
'subtitle' => const TextStyle(
fontSize: 15,
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
'caption' => const TextStyle(fontSize: 12, color: AppColors.slate500),
'caption' => const TextStyle(
fontSize: 12,
color: AppColors.slate500,
height: 1.4,
),
'code' => const TextStyle(
fontSize: 12,
color: AppColors.slate700,
fontFamily: 'monospace',
),
_ => const TextStyle(
fontSize: 14,
fontSize: 15,
color: AppColors.slate700,
height: 1.45,
),
@@ -139,16 +143,17 @@ class UiSchemaRenderer {
final bg = _statusBackground(status);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: _statusBorder(status)),
),
child: Text(
_asString(node['label']),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: fg),
),
);
}
@@ -173,17 +178,20 @@ class UiSchemaRenderer {
style: ElevatedButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
backgroundColor: style == 'primary'
? AppColors.blue600
: AppColors.homeComposerAccent,
? AppColors.authPrimaryButton
: AppColors.surfaceInfoLight,
foregroundColor: style == 'primary'
? AppColors.white
: AppColors.slate700,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderRadius: BorderRadius.circular(AppRadius.full),
side: style == 'primary'
? BorderSide.none
: const BorderSide(color: AppColors.borderTertiary),
),
),
child: Text(
@@ -211,32 +219,43 @@ class UiSchemaRenderer {
fallback: _asString(item['key']),
);
final value = item['value']?.toString() ?? '-';
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.slate500,
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.slate500,
),
),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
flex: 5,
child: Text(
value,
style: const TextStyle(
fontSize: 13,
color: AppColors.slate800,
fontWeight: FontWeight.w500,
const SizedBox(width: AppSpacing.sm),
Expanded(
flex: 5,
child: Text(
value,
style: const TextStyle(
fontSize: 13,
color: AppColors.slate800,
fontWeight: FontWeight.w600,
),
),
),
),
],
],
),
);
}).toList(),
AppSpacing.xs,
@@ -259,7 +278,8 @@ class UiSchemaRenderer {
return child;
}
final bg = switch (appearance) {
'section' => AppColors.homeComposerInner,
'section' => AppColors.surfaceSecondary,
'card' => AppColors.white,
_ => _statusBackground(status),
};
final borderColor = switch (status) {
@@ -270,16 +290,16 @@ class UiSchemaRenderer {
};
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppRadius.lg),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: borderColor),
boxShadow: [
BoxShadow(
color: AppColors.blue100.withValues(alpha: 0.35),
blurRadius: 18,
offset: const Offset(0, 8),
color: AppColors.slate200.withValues(alpha: 0.6),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
@@ -325,7 +345,17 @@ class UiSchemaRenderer {
'warning' => AppColors.feedbackWarningSurface,
'error' => AppColors.feedbackErrorSurface,
'pending' => AppColors.feedbackInfoSurface,
_ => AppColors.homeConversationSurface,
_ => AppColors.surfaceSecondary,
};
}
static Color _statusBorder(String status) {
return switch (status) {
'success' => AppColors.feedbackSuccessBorder,
'warning' => AppColors.feedbackWarningBorder,
'error' => AppColors.feedbackErrorBorder,
'pending' => AppColors.feedbackInfoBorder,
_ => AppColors.borderTertiary,
};
}
@@ -10,6 +10,7 @@ import '../../../../core/api/api_exception.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../chat/data/models/chat_list_item.dart';
import '../../../chat/presentation/bloc/agent_stage.dart';
import '../../../chat/presentation/bloc/chat_bloc.dart';
import '../../../messages/data/inbox_api.dart';
import '../../data/voice_recorder.dart';
@@ -49,6 +50,14 @@ const homeEmptyStateAmbientKey = ValueKey('home_empty_state_ambient');
const _chatBgColor = AppColors.slate50;
const _userBubbleColor = AppColors.blue50;
/// 录制状态颜色
const _recordingCancelTopColor = AppColors.warningBackground;
const _recordingCancelBottomColor = AppColors.red400;
const _recordingCancelLabelColor = AppColors.red600;
const _recordingActiveTopColor = AppColors.blue50;
const _recordingActiveBottomColor = AppColors.blue400;
const _recordingActiveLabelColor = AppColors.white;
class HomeScreen extends StatefulWidget {
final VoiceRecorder? voiceRecorder;
final Future<String> Function(String filePath)? onTranscribeAudio;
@@ -258,12 +267,7 @@ class _HomeScreenState extends State<HomeScreen>
}
Widget _buildWaitingIndicator({required AgentStage? currentStage}) {
final label = switch (currentStage) {
AgentStage.intent => '意图识别中',
AgentStage.execution => '任务执行中',
AgentStage.report => '结果总结中',
null => '正在思考...',
};
final label = stageLabel(currentStage);
return Padding(
padding: const EdgeInsets.fromLTRB(
_defaultPadding,
@@ -490,12 +494,12 @@ class _HomeScreenState extends State<HomeScreen>
Widget _buildToolCallItem(ToolCallItem item) {
final (statusText, statusColor, statusIcon) = switch (item.status) {
ToolCallStatus.pending => (
'准备中...',
'工具准备中',
AppColors.slate500,
LucideIcons.clock,
),
ToolCallStatus.executing => (
'执行中...',
'任务执行中',
AppColors.blue600,
LucideIcons.loader,
),
@@ -512,27 +516,51 @@ class _HomeScreenState extends State<HomeScreen>
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.blue50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.slate300),
color: AppColors.surfaceInfoLight,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderTertiary),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(statusIcon, size: 16, color: statusColor),
const SizedBox(width: AppSpacing.sm),
Text(
item.toolName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate700,
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderTertiary),
),
child: Icon(statusIcon, size: 14, color: statusColor),
),
const SizedBox(width: AppSpacing.sm),
Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.toolName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate800,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
],
),
);
@@ -819,16 +847,17 @@ class _HomeScreenState extends State<HomeScreen>
}
Widget _buildRecordingGestureOverlay() {
final topColor = _isCancelGestureActive
? AppColors.warningBackground
: AppColors.blue50;
final bottomColor = _isCancelGestureActive
? AppColors.red400
: AppColors.blue400;
final labelColor = _isCancelGestureActive
? AppColors.red600
: AppColors.white;
final label = _isCancelGestureActive ? '松手取消' : '松手发送,上移取消';
final isCancel = _isCancelGestureActive;
final topColor = isCancel
? _recordingCancelTopColor
: _recordingActiveTopColor;
final bottomColor = isCancel
? _recordingCancelBottomColor
: _recordingActiveBottomColor;
final labelColor = isCancel
? _recordingCancelLabelColor
: _recordingActiveLabelColor;
final label = isCancel ? '松手取消' : '松手发送,上移取消';
return IgnorePointer(
child: Align(
@@ -2,71 +2,104 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../auth/presentation/bloc/auth_bloc.dart';
import '../../../auth/presentation/bloc/auth_event.dart';
import '../../../auth/presentation/bloc/auth_state.dart';
import '../widgets/account_section_card.dart';
import '../widgets/account_surface_scaffold.dart';
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surfaceSecondary,
body: SafeArea(
child: Column(
children: [
_buildHeader(context),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildMenuCard(context),
const SizedBox(height: 24),
_buildLogoutButton(context),
],
),
),
),
],
),
return AccountSurfaceScaffold(
title: '我的账户',
subtitle: '管理资料信息与账户安全',
onBack: () => context.pop(),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildProfileHero(context),
const SizedBox(height: AppSpacing.lg),
_buildMenuCard(context),
const SizedBox(height: AppSpacing.lg),
_buildSecurityCard(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
widgets.BackButton(),
const SizedBox(width: 12),
const Text(
'我的账户',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
Widget _buildProfileHero(BuildContext context) {
final authState = context.watch<AuthBloc>().state;
final email = authState is AuthAuthenticated ? authState.user.email : '';
final identity = email.isEmpty ? '当前登录账户' : email;
final badge = email.isEmpty ? 'A' : email.characters.first.toUpperCase();
return AccountSectionCard(
title: '账户概览',
description: '查看当前账户状态与基础身份信息',
backgroundColor: AppColors.surfaceInfoLight,
borderColor: AppColors.borderQuaternary,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderQuaternary),
),
child: Center(
child: Text(
badge,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.blue600,
),
),
),
],
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
identity,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpacing.xs),
const Text(
'账户状态正常,可安全管理资料与密码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
],
),
),
],
),
);
}
Widget _buildMenuCard(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSecondary),
),
return AccountSectionCard(
title: '账户信息',
description: '编辑公开资料和登录信息',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildMenuItem(
icon: Icons.edit,
@@ -84,6 +117,52 @@ class AccountScreen extends StatelessWidget {
);
}
Widget _buildSecurityCard(BuildContext context) {
return AccountSectionCard(
title: '安全与会话',
description: '若为公共设备,请及时退出登录',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.shield_outlined,
size: 16,
color: AppColors.slate500,
),
SizedBox(width: AppSpacing.xs),
Expanded(
child: Text(
'退出后需要重新登录才能继续使用。',
style: TextStyle(
fontSize: 12,
color: AppColors.slate500,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(height: AppSpacing.md),
_buildLogoutButton(context),
],
),
);
}
Widget _buildMenuItem({
required IconData icon,
required String title,
@@ -93,15 +172,17 @@ class AccountScreen extends StatelessWidget {
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
height: AppSpacing.xxl,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 20, color: AppColors.slate500),
const SizedBox(width: 12),
const SizedBox(width: AppSpacing.md),
Text(
title,
style: const TextStyle(
@@ -126,8 +207,8 @@ class AccountScreen extends StatelessWidget {
Widget _buildDivider() {
return Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 16),
color: const Color(0xFFEEF2F7),
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
color: AppColors.borderTertiary,
);
}
@@ -138,9 +219,9 @@ class AccountScreen extends StatelessWidget {
width: double.infinity,
height: 52,
decoration: BoxDecoration(
color: const Color(0xFFFEE2E2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFECACA)),
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.feedbackErrorBorder),
),
child: const Center(
child: Text(
@@ -148,7 +229,7 @@ class AccountScreen extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFFDC2626),
color: AppColors.feedbackErrorText,
),
),
),
@@ -179,7 +260,10 @@ class AccountScreen extends StatelessWidget {
context.go('/');
}
},
child: const Text('退出', style: TextStyle(color: Color(0xFFDC2626))),
child: const Text(
'退出',
style: TextStyle(color: AppColors.feedbackErrorText),
),
),
],
),
@@ -5,14 +5,16 @@ import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/banner/app_banner.dart';
import '../../../../shared/widgets/fixed_length_code_input.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../auth/presentation/bloc/auth_bloc.dart';
import '../../../auth/presentation/bloc/auth_state.dart';
import '../../../../features/auth/presentation/cubits/reset_password_cubit.dart';
import '../../../../features/auth/data/auth_repository.dart';
import '../widgets/account_section_card.dart';
import '../widgets/account_surface_scaffold.dart';
class ChangePasswordScreen extends StatelessWidget {
const ChangePasswordScreen({super.key});
@@ -39,19 +41,13 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
String _userEmail = '';
@override
void initState() {
super.initState();
_loadUserEmail();
}
void _loadUserEmail() {
String _resolveUserEmail() {
final authState = context.read<AuthBloc>().state;
if (authState is AuthAuthenticated) {
_userEmail = authState.user.email;
return authState.user.email;
}
return '';
}
@override
@@ -63,8 +59,14 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
}
Future<void> _handleSubmit() async {
final email = _resolveUserEmail();
if (email.isEmpty) {
Toast.show(context, '未读取到登录邮箱,请重新登录后重试', type: ToastType.warning);
return;
}
final cubit = context.read<ResetPasswordCubit>();
cubit.emailChanged(_userEmail);
cubit.emailChanged(email);
cubit.codeChanged(_codeController.text);
cubit.newPasswordChanged(_passwordController.text);
cubit.confirmPasswordChanged(_confirmPasswordController.text);
@@ -94,281 +96,307 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
Toast.show(context, state.errorMessage!, type: ToastType.error);
}
},
child: Scaffold(
backgroundColor: AppColors.surfaceSecondary,
body: SafeArea(
child: Column(
children: [
SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
const widgets.BackButton(),
const SizedBox(width: 12),
const Text(
'修改密码',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
],
),
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEmailDisplay(),
const SizedBox(height: 24),
_buildForm(),
],
),
),
),
],
),
child: AccountSurfaceScaffold(
title: '修改密码',
subtitle: '通过邮箱验证码安全更新你的登录密码',
onBack: () => context.pop(),
body: _buildForm(),
footer: BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
builder: (context, state) {
return _buildSubmitButton(state);
},
),
),
);
}
Widget _buildEmailDisplay() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
children: [
const Icon(Icons.email_outlined, size: 20, color: AppColors.slate500),
const SizedBox(width: 12),
Text(
_userEmail,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.slate900,
),
),
],
),
);
}
Widget _buildForm() {
return BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCodeInput(state),
const SizedBox(height: 16),
_buildPasswordInput(state.newPassword.displayError != null),
const SizedBox(height: 16),
_buildConfirmPasswordInput(
_buildEmailSection(state, _resolveUserEmail()),
const SizedBox(height: AppSpacing.lg),
_buildPasswordSection(
state,
state.newPassword.displayError != null,
state.confirmPassword.displayError != null,
),
const SizedBox(height: 32),
_buildSubmitButton(state),
],
);
},
);
}
Widget _buildCodeInput(ResetPasswordState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'验证码',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate700,
Widget _buildEmailSection(ResetPasswordState state, String userEmail) {
return AccountSectionCard(
title: '第 1 步:验证邮箱',
description: '先向登录邮箱发送验证码,再进行密码设置',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.surfaceInfoLight,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderTertiary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.email_outlined,
size: 20,
color: AppColors.blue600,
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
userEmail.isEmpty ? '未读取到登录邮箱' : userEmail,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '修改密码验证码输入框',
keyboardType: TextInputType.number,
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context.read<ResetPasswordCubit>().codeChanged(value);
},
),
const SizedBox(height: AppSpacing.lg),
AppButton(
text: state.resendCountdown > 0
? '${state.resendCountdown} 秒后可重发'
: (state.codeSent ? '重新发送验证码' : '发送验证码'),
onPressed:
state.resendCountdown > 0 ||
state.status == FormzSubmissionStatus.inProgress
? null
: () {
if (userEmail.isEmpty) {
Toast.show(
context,
'未读取到登录邮箱,请重新登录后重试',
type: ToastType.warning,
);
return;
}
if (state.codeSent) {
context.read<ResetPasswordCubit>().resendCode();
} else {
context.read<ResetPasswordCubit>().emailChanged(
userEmail,
);
context.read<ResetPasswordCubit>().sendCode();
}
},
isOutlined: state.codeSent,
),
],
),
);
}
Widget _buildPasswordSection(
ResetPasswordState state,
bool passwordHasError,
bool confirmHasError,
) {
return AccountSectionCard(
title: '第 2 步:输入验证码并设置新密码',
description: '验证码有效后,确认新密码即可完成修改',
backgroundColor: state.codeSent
? AppColors.white
: AppColors.surfaceTertiary,
borderColor: state.codeSent
? AppColors.borderSecondary
: AppColors.borderTertiary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!state.codeSent)
const AppBanner(
title: '请先发送验证码',
message: '完成邮箱验证后,可继续设置新密码。',
type: ToastType.info,
),
const SizedBox(width: 12),
SizedBox(
height: 48,
child: TextButton(
onPressed:
state.resendCountdown > 0 ||
state.status == FormzSubmissionStatus.inProgress
? null
: () {
if (state.codeSent) {
context.read<ResetPasswordCubit>().resendCode();
} else {
context.read<ResetPasswordCubit>().emailChanged(
_userEmail,
);
context.read<ResetPasswordCubit>().sendCode();
}
},
style: TextButton.styleFrom(
backgroundColor: state.codeSent
? AppColors.background
: AppColors.primary,
foregroundColor: state.codeSent
? AppColors.primary
: AppColors.primaryForeground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
child: Text(
state.resendCountdown > 0
? '${state.resendCountdown}'
: (state.codeSent ? '重新发送' : '发送验证码'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
if (state.codeSent)
AppBanner(
title: '验证码已发送',
message: state.resendCountdown > 0
? '如未收到,可在 ${state.resendCountdown} 秒后重新发送。'
: '若未收到邮件,可重新发送验证码。',
type: ToastType.info,
),
],
),
],
const SizedBox(height: AppSpacing.lg),
const Text(
'验证码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
),
),
const SizedBox(height: AppSpacing.sm),
FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '修改密码验证码输入框',
keyboardType: TextInputType.number,
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context.read<ResetPasswordCubit>().codeChanged(value);
},
),
const SizedBox(height: AppSpacing.lg),
_buildPasswordInput(passwordHasError),
const SizedBox(height: AppSpacing.lg),
_buildConfirmPasswordInput(confirmHasError),
],
),
);
}
Widget _buildPasswordInput(bool hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'新密码',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate700,
),
),
const SizedBox(height: 8),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
onChanged: (value) {
context.read<ResetPasswordCubit>().newPasswordChanged(value);
},
decoration: InputDecoration(
hintText: '请输入新密码(至少 6 位)',
errorText: hasError ? ' ' : null,
filled: true,
fillColor: AppColors.white,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
size: 20,
color: AppColors.slate400,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
),
],
return _buildPasswordField(
label: '新密码',
controller: _passwordController,
hintText: '请输入新密码(至少 6 位)',
hasError: hasError,
isObscured: _obscurePassword,
onToggleVisibility: () =>
setState(() => _obscurePassword = !_obscurePassword),
onChanged: (value) =>
context.read<ResetPasswordCubit>().newPasswordChanged(value),
);
}
Widget _buildConfirmPasswordInput(bool hasError) {
return _buildPasswordField(
label: '确认密码',
controller: _confirmPasswordController,
hintText: '请再次输入新密码',
hasError: hasError,
isObscured: _obscureConfirmPassword,
onToggleVisibility: () =>
setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
onChanged: (value) =>
context.read<ResetPasswordCubit>().confirmPasswordChanged(value),
);
}
Widget _buildPasswordField({
required String label,
required TextEditingController controller,
required String hintText,
required bool hasError,
required bool isObscured,
required VoidCallback onToggleVisibility,
required ValueChanged<String> onChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'确认密码',
style: TextStyle(
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate700,
),
),
const SizedBox(height: 8),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
onChanged: (value) {
context.read<ResetPasswordCubit>().confirmPasswordChanged(value);
},
controller: controller,
obscureText: isObscured,
onChanged: onChanged,
decoration: InputDecoration(
hintText: '请再次输入新密码',
hintText: hintText,
errorText: hasError ? ' ' : null,
filled: true,
fillColor: AppColors.white,
fillColor: AppColors.surfaceSecondary,
hintStyle: const TextStyle(color: AppColors.slate400),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility_off
: Icons.visibility,
isObscured ? Icons.visibility_off : Icons.visibility,
size: 20,
color: AppColors.slate400,
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
onPressed: onToggleVisibility,
),
border: _inputBorder,
enabledBorder: _enabledBorder,
focusedBorder: _focusedBorder,
),
),
],
);
}
static final _inputBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide.none,
);
static final _enabledBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.borderTertiary),
);
static final _focusedBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.blue500),
);
Widget _buildSubmitButton(ResetPasswordState state) {
final isLoading = state.status == FormzSubmissionStatus.inProgress;
final isDisabled = isLoading || !state.codeSent;
final isDisabled = isLoading || !state.canSubmit;
return SizedBox(
width: double.infinity,
height: 52,
child: AppButton(
text: '确认修改',
onPressed: isDisabled ? null : _handleSubmit,
isLoading: isLoading,
),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!state.codeSent)
const Text(
'完成验证码验证后可提交密码修改',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
if (!state.codeSent) const SizedBox(height: AppSpacing.sm),
SizedBox(
width: double.infinity,
height: 52,
child: AppButton(
text: '确认修改',
onPressed: isDisabled ? null : _handleSubmit,
isLoading: isLoading,
),
),
],
);
}
}
@@ -4,11 +4,12 @@ import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../users/data/models/user_response.dart';
import '../../../users/data/users_api.dart';
import '../widgets/account_section_card.dart';
import '../widgets/account_surface_scaffold.dart';
class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({super.key});
@@ -118,193 +119,187 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surfaceSecondary,
body: SafeArea(
child: Column(
children: [
_buildHeader(),
Expanded(
child: _isLoading
? const Center(child: AppLoadingIndicator(size: 22))
: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildAvatarSection(),
const SizedBox(height: 24),
_buildFormSection(),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 52,
child: AppButton(
text: '保存修改',
onPressed: _hasChanges && !_isSaving
? _saveProfile
: null,
isLoading: _isSaving,
),
),
],
),
),
return AccountSurfaceScaffold(
title: '编辑资料',
subtitle: '完善公开信息,让好友更容易认识你',
onBack: () => context.pop(),
body: _isLoading
? const Center(
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildProfileSummarySection(),
const SizedBox(height: AppSpacing.lg),
_buildBasicInfoSection(),
const SizedBox(height: AppSpacing.lg),
_buildBioSection(),
],
),
],
footer: SizedBox(
width: double.infinity,
height: 52,
child: AppButton(
text: '保存修改',
onPressed: _hasChanges && !_isSaving ? _saveProfile : null,
isLoading: _isSaving,
),
),
);
}
Widget _buildHeader() {
return SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
widgets.BackButton(onPressed: () => context.pop()),
const SizedBox(width: 12),
const Text(
'编辑资料',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
],
),
),
);
}
Widget _buildProfileSummarySection() {
final username = _user?.username ?? '未设置用户名';
final email = _user?.email;
Widget _buildAvatarSection() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSecondary),
),
return AccountSectionCard(
title: '资料概览',
description: '展示你的公开身份信息',
backgroundColor: AppColors.white,
borderColor: AppColors.borderSecondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFEAF1FF), Color(0xFFF8FBFF)],
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue100, AppColors.surfaceInfoLight],
),
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderQuaternary),
),
child: const Icon(
Icons.person,
size: 28,
color: AppColors.blue600,
),
),
borderRadius: BorderRadius.circular(36),
border: Border.all(color: const Color(0xFFD9E5FA)),
),
child: const Icon(Icons.person, size: 36, color: AppColors.blue500),
),
const SizedBox(height: 12),
Text(
'点击更换头像',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
const SizedBox(width: AppSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
username,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpacing.xs),
Text(
email ?? '邮箱未绑定',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
],
),
);
}
Widget _buildFormSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSecondary),
),
Widget _buildBasicInfoSection() {
return AccountSectionCard(
title: '基础信息',
description: '用户名会在个人资料和社交场景中展示',
backgroundColor: AppColors.white,
borderColor: AppColors.borderSecondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'用户名',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
),
),
const SizedBox(height: 8),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: _usernameController,
onChanged: (_) => _onFieldChanged(),
decoration: InputDecoration(
hintText: '请输入用户名',
filled: true,
fillColor: AppColors.surfaceSecondary,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: AppColors.blue500,
width: 1,
),
),
),
),
const SizedBox(height: 20),
const Text(
'个人简介',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate700,
),
),
const SizedBox(height: 8),
TextField(
controller: _bioController,
onChanged: (_) => _onFieldChanged(),
maxLines: 4,
maxLength: 200,
decoration: InputDecoration(
hintText: '介绍一下自己吧',
filled: true,
fillColor: AppColors.surfaceSecondary,
contentPadding: const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: AppColors.blue500,
width: 1,
),
),
),
style: const TextStyle(fontSize: 15, color: AppColors.slate900),
decoration: _buildInputDecoration('请输入用户名'),
),
],
),
);
}
Widget _buildBioSection() {
return AccountSectionCard(
title: '个人简介',
description: '一句话介绍自己,帮助他人快速了解你',
backgroundColor: AppColors.white,
borderColor: AppColors.borderSecondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'简介内容',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
),
),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: _bioController,
onChanged: (_) => _onFieldChanged(),
maxLines: 4,
maxLength: 200,
style: const TextStyle(fontSize: 15, color: AppColors.slate900),
decoration: _buildInputDecoration(
'介绍一下自己吧',
).copyWith(contentPadding: const EdgeInsets.all(AppSpacing.lg)),
),
],
),
);
}
InputDecoration _buildInputDecoration(String hintText) {
return InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(fontSize: 14, color: AppColors.slate400),
filled: true,
fillColor: AppColors.surfaceSecondary,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.borderTertiary),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.blue500),
),
);
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
class AccountSectionCard extends StatelessWidget {
const AccountSectionCard({
super.key,
this.title,
this.description,
required this.child,
this.backgroundColor = AppColors.white,
this.borderColor = AppColors.borderSecondary,
});
final String? title;
final String? description;
final Widget child;
final Color backgroundColor;
final Color borderColor;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(
title!,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
],
if (description != null) ...[
const SizedBox(height: AppSpacing.xs),
Text(
description!,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
],
if (title != null || description != null)
const SizedBox(height: AppSpacing.lg),
child,
],
),
);
}
}
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
class AccountSurfaceScaffold extends StatelessWidget {
const AccountSurfaceScaffold({
super.key,
required this.title,
required this.subtitle,
required this.body,
this.footer,
this.onBack,
});
final String title;
final String subtitle;
final Widget body;
final Widget? footer;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surfaceSecondary,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
widgets.PageHeader(leading: widgets.BackButton(onPressed: onBack)),
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.none,
AppSpacing.xl,
AppSpacing.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
subtitle,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.sm,
AppSpacing.xl,
AppSpacing.xl,
),
child: body,
),
),
if (footer != null)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.none,
AppSpacing.xl,
AppSpacing.xl,
),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.surfaceInfoLight,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
),
child: footer,
),
),
],
),
),
);
}
}