feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验

This commit is contained in:
zl-q
2026-03-17 00:13:41 +08:00
parent d3783522e6
commit c26cdbbc27
27 changed files with 1532 additions and 412 deletions
+16 -1
View File
@@ -22,7 +22,7 @@ abstract class ApiException implements Exception {
(data['detail'] ?? data['message'] ?? data['error'])?.toString() ??
'请求失败';
} else {
detail = '请求失败';
detail = _networkErrorMessage(error);
}
final localized = _localizeError(detail, statusCode);
@@ -57,6 +57,21 @@ abstract class ApiException implements Exception {
}
return detail;
}
static String _networkErrorMessage(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return '网络超时,请确认手机与服务端在同一网络后重试';
}
if (error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.unknown) {
return '无法连接服务器。请在 iPhone 设置中为本应用开启“无线数据(WLAN与蜂窝网络)”,并确认本地网络权限已开启。';
}
return '请求失败';
}
}
class ServerException extends ApiException {
@@ -94,18 +94,18 @@ class UiSchemaRenderer {
final status = _asString(node['status']);
final style = switch (role) {
'title' => const TextStyle(
fontSize: 22,
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
height: 1.2,
),
'subtitle' => const TextStyle(
fontSize: 16,
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
'caption' => const TextStyle(
fontSize: 12,
fontSize: 11,
color: AppColors.slate500,
height: 1.4,
),
@@ -115,9 +115,9 @@ class UiSchemaRenderer {
fontFamily: 'monospace',
),
_ => const TextStyle(
fontSize: 15,
fontSize: 13,
color: AppColors.slate700,
height: 1.45,
height: 1.35,
),
};
return Text(
@@ -131,7 +131,7 @@ class UiSchemaRenderer {
static Widget _renderIcon(Map<String, dynamic> node) {
final value = _asString(node['value']);
if (_asString(node['source']) == 'emoji' && value.isNotEmpty) {
return Text(value, style: const TextStyle(fontSize: 20));
return Text(value, style: const TextStyle(fontSize: 18));
}
return Icon(Icons.bubble_chart_rounded, color: _statusTextColor('', null));
}
@@ -179,7 +179,7 @@ class UiSchemaRenderer {
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
vertical: AppSpacing.sm,
),
backgroundColor: style == 'primary'
? AppColors.authPrimaryButton
@@ -196,7 +196,7 @@ class UiSchemaRenderer {
),
child: Text(
_asString(node['label'], fallback: '操作'),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
},
@@ -223,7 +223,7 @@ class UiSchemaRenderer {
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
@@ -237,7 +237,7 @@ class UiSchemaRenderer {
child: Text(
label,
style: const TextStyle(
fontSize: 12,
fontSize: 11,
color: AppColors.slate500,
),
),
@@ -248,7 +248,7 @@ class UiSchemaRenderer {
child: Text(
value,
style: const TextStyle(
fontSize: 13,
fontSize: 12,
color: AppColors.slate800,
fontWeight: FontWeight.w600,
),
@@ -290,16 +290,16 @@ class UiSchemaRenderer {
};
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.xl),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppRadius.xl),
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: borderColor),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.6),
blurRadius: 20,
offset: const Offset(0, 10),
color: AppColors.slate200.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
@@ -41,7 +41,9 @@ const _transcribingStrokeWidth = 2.0;
const _attachmentPreviewSize = 88.0;
const _attachmentPreviewRadius = 10.0;
const _attachmentPreviewGap = 8.0;
const _bottomStackReservedHeight = 140.0;
const _bottomStackReservedHeight = 116.0;
const _toolResultWidthFactor = 0.9;
const _pullRefreshMinVisibleMs = 450;
const homeConversationStageKey = ValueKey('home_conversation_stage');
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
@@ -92,7 +94,7 @@ class _HomeScreenState extends State<HomeScreen>
late final Future<void> Function(String transcript) _autoSendTranscript;
late final AnimationController _listeningAnimationController;
bool _isRecording = false;
bool _isHoldToSpeakMode = false;
bool _isHoldToSpeakMode = true;
bool _isTranscribing = false;
bool _isCancelGestureActive = false;
bool _isSendingMessage = false;
@@ -356,12 +358,29 @@ class _HomeScreenState extends State<HomeScreen>
if (_isPullRefreshing) {
return;
}
final chatBloc = context.read<ChatBloc>();
if (chatBloc.state.isLoadingHistory) {
return;
}
final hasEarlierHistory = chatBloc.state.hasEarlierHistory;
if (mounted) {
setState(() => _isPullRefreshing = true);
}
final startedAt = DateTime.now();
try {
await context.read<ChatBloc>().loadMoreHistory();
if (hasEarlierHistory) {
await chatBloc.loadMoreHistory();
} else {
Toast.show(context, '没有更早的历史记录了', type: ToastType.info);
}
} finally {
final elapsed = DateTime.now().difference(startedAt);
final minDuration = const Duration(
milliseconds: _pullRefreshMinVisibleMs,
);
if (elapsed < minDuration) {
await Future.delayed(minDuration - elapsed);
}
if (mounted) {
setState(() => _isPullRefreshing = false);
}
@@ -585,7 +604,32 @@ class _HomeScreenState extends State<HomeScreen>
}
Widget _buildToolResultItem(ToolResultItem item) {
return UiSchemaRenderer.renderSchema(item.uiSchema);
final rootNode = item.uiSchema['root'];
final appearance = rootNode is Map<String, dynamic>
? rootNode['appearance'] as String?
: null;
final needsOuterCard = appearance == null || appearance == 'plain';
final schemaContent = UiSchemaRenderer.renderSchema(item.uiSchema);
final wrappedContent = needsOuterCard
? Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.homeConversationBorder),
),
child: schemaContent,
)
: schemaContent;
return Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: _toolResultWidthFactor,
child: wrappedContent,
),
);
}
Widget _buildBottomInputStack(BuildContext context, ChatState state) {
@@ -733,8 +777,7 @@ class _HomeScreenState extends State<HomeScreen>
}
void _onHoldToSpeakStart() {
HapticFeedback.heavyImpact();
HapticFeedback.vibrate();
HapticFeedback.selectionClick();
setState(() {
_isCancelGestureActive = false;
});
@@ -747,7 +790,7 @@ class _HomeScreenState extends State<HomeScreen>
_cancelRecording(showToast: false);
return;
}
HapticFeedback.mediumImpact();
HapticFeedback.selectionClick();
_stopRecording(autoSendAfterTranscribe: true);
}
@@ -59,24 +59,26 @@ class _HomeBottomGlow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
key: homeBottomGlowKey,
width: double.infinity,
height: AppSpacing.xxl * 6,
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
decoration: BoxDecoration(
color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
boxShadow: [
BoxShadow(
color: AppColors.homeBackgroundGlow.withValues(alpha: 0.12),
blurRadius: AppSpacing.xxl * 2,
spreadRadius: AppSpacing.md,
),
],
return IgnorePointer(
child: Align(
alignment: Alignment.bottomCenter,
child: Transform.translate(
offset: const Offset(0, AppSpacing.lg),
child: Container(
key: homeBottomGlowKey,
width: AppSpacing.xxl * 12,
height: AppSpacing.xxl * 3,
decoration: BoxDecoration(
color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
boxShadow: [
BoxShadow(
color: AppColors.homeBackgroundGlow.withValues(alpha: 0.1),
blurRadius: AppSpacing.xxl,
spreadRadius: AppSpacing.sm,
),
],
),
),
),
),
@@ -26,9 +26,9 @@ class HomeFloatingHeader extends StatelessWidget {
key: homeFloatingHeaderKey,
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.xs,
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.xs,
),
decoration: const BoxDecoration(
color: AppColors.homeToolbarSurface,
@@ -93,6 +93,11 @@ class _HeaderIconButton extends StatelessWidget {
Widget build(BuildContext context) {
return IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(AppSpacing.xs),
constraints: const BoxConstraints(
minWidth: AppSpacing.xxl + AppSpacing.lg,
minHeight: AppSpacing.xxl + AppSpacing.lg,
),
onPressed: onPressed,
icon: Icon(icon, size: AppSpacing.xxl, color: AppColors.slate900),
);
@@ -109,6 +114,11 @@ class _MessagesButton extends StatelessWidget {
Widget build(BuildContext context) {
return IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(AppSpacing.xs),
constraints: const BoxConstraints(
minWidth: AppSpacing.xxl + AppSpacing.lg,
minHeight: AppSpacing.xxl + AppSpacing.lg,
),
onPressed: onPressed,
icon: Stack(
clipBehavior: Clip.none,