feat: 优化 Agent 运行时与聊天设置体验
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user