feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验
This commit is contained in:
@@ -114,7 +114,6 @@
|
||||
DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */,
|
||||
E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -471,13 +470,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = NHCUQ772U3;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -653,13 +653,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = NHCUQ772U3;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -675,13 +676,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = NHCUQ772U3;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -24,6 +26,38 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机来拍摄照片</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>需要访问局域网以连接开发服务器</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要访问相册来选择照片</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>192.168.66.57</key>
|
||||
<dict>
|
||||
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSTemporaryExceptionMinimumTLSVersion</key>
|
||||
<string>TLSv1.0</string>
|
||||
</dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSTemporaryExceptionMinimumTLSVersion</key>
|
||||
<string>TLSv1.0</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -41,13 +75,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机来拍摄照片</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要访问相册来选择照片</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,6 +59,64 @@ void main() {
|
||||
expect(find.text('评审会'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders batch result list items in one card', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'status': 'warning',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'title', 'content': '日历操作完成'},
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'gap': 8,
|
||||
'children': [
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '#1 create'},
|
||||
{'type': 'text', 'role': 'caption', 'content': '成功'},
|
||||
{'type': 'text', 'role': 'caption', 'content': '日程「晨会」已创建'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '#2 delete'},
|
||||
{'type': 'text', 'role': 'caption', 'content': '失败'},
|
||||
{
|
||||
'type': 'text',
|
||||
'role': 'caption',
|
||||
'content': 'Schedule item not found',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('日历操作完成'), findsOneWidget);
|
||||
expect(find.text('#1 create'), findsOneWidget);
|
||||
expect(find.text('#2 delete'), findsOneWidget);
|
||||
expect(find.text('成功'), findsOneWidget);
|
||||
expect(find.text('失败'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders fallback for invalid schema', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
|
||||
Reference in New Issue
Block a user