Browse Source

feat: 完成全平台适配与功能迭代,新增报告页面与网络权限配置

本次提交完成多项核心改进:
1.  补全iOS/macOS平台的网络权限与麦克风权限配置,修复跨平台网络请求与录音权限问题
2.  新增检测报告页面与历史记录筛选功能,优化筛选芯片UI
3.  重构WebSocket连接逻辑,增加错误捕获与重连机制
4.  修复iOS 18模拟器权限识别异常,重构录音权限申请流程
5.  调整悬浮按钮与底部控制栏UI,优化录音页面交互体验
6.  修复路由配置,将原报告页路由指向新增的ReportsPage
7.  更新测试用例与Pod配置,完善工程依赖管理
wenhongquan 1 month ago
parent
commit
dc947bc293

+ 1 - 1
flutter_asr_client/ios/Podfile

@@ -1,5 +1,5 @@
 # Uncomment this line to define a global platform for your project
-# platform :ios, '13.0'
+platform :ios, '13.0'
 
 # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
 ENV['COCOAPODS_DISABLE_STATS'] = 'true'

+ 41 - 0
flutter_asr_client/ios/Podfile.lock

@@ -0,0 +1,41 @@
+PODS:
+  - Flutter (1.0.0)
+  - integration_test (0.0.1):
+    - Flutter
+  - permission_handler_apple (9.4.8):
+    - Flutter
+  - record_ios (1.2.1):
+    - Flutter
+  - shared_preferences_foundation (0.0.1):
+    - Flutter
+    - FlutterMacOS
+
+DEPENDENCIES:
+  - Flutter (from `Flutter`)
+  - integration_test (from `.symlinks/plugins/integration_test/ios`)
+  - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
+  - record_ios (from `.symlinks/plugins/record_ios/ios`)
+  - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+
+EXTERNAL SOURCES:
+  Flutter:
+    :path: Flutter
+  integration_test:
+    :path: ".symlinks/plugins/integration_test/ios"
+  permission_handler_apple:
+    :path: ".symlinks/plugins/permission_handler_apple/ios"
+  record_ios:
+    :path: ".symlinks/plugins/record_ios/ios"
+  shared_preferences_foundation:
+    :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+
+SPEC CHECKSUMS:
+  Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+  integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
+  permission_handler_apple: ee2fe0fd04551b304eb002714ff067c371c822ed
+  record_ios: 3b171007ae5de51221a3a76d2b379a7105ca98b5
+  shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
+
+PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce
+
+COCOAPODS: 1.16.2

+ 130 - 0
flutter_asr_client/ios/Runner.xcodeproj/project.pbxproj

@@ -14,6 +14,8 @@
 		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
 		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
 		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+		C5697B361E1E444DCE813E0A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26AB1D3BA80CF9317B02D050 /* Pods_RunnerTests.framework */; };
+		D6B9BB336E151464605B07BD /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 68BCA30362F86E3315B803F0 /* Pods_Runner.framework */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -42,12 +44,18 @@
 /* Begin PBXFileReference section */
 		1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
 		1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
+		26AB1D3BA80CF9317B02D050 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
 		331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
+		4653CB41F308F206EF2A3713 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
+		68BCA30362F86E3315B803F0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
 		74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
+		7972017ABB09E455D91AA966 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
 		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
+		7CEE634403FE00F259131478 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
+		9422F1B232719A657EF4859A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
 		9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
 		9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
 		97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -55,13 +63,24 @@
 		97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+		CE3DB8A9A3C5C1C127369858 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
+		EB24E5DCE520B6C74B4F84B8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
+		3D53E616CD87DE3AAD9031B7 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C5697B361E1E444DCE813E0A /* Pods_RunnerTests.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		97C146EB1CF9000F007C117D /* Frameworks */ = {
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				D6B9BB336E151464605B07BD /* Pods_Runner.framework in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -94,6 +113,8 @@
 				97C146F01CF9000F007C117D /* Runner */,
 				97C146EF1CF9000F007C117D /* Products */,
 				331C8082294A63A400263BE5 /* RunnerTests */,
+				C5F29E7C6650494EC5533DE4 /* Pods */,
+				A34BDAB69887CA62E66BBE0C /* Frameworks */,
 			);
 			sourceTree = "<group>";
 		};
@@ -121,6 +142,29 @@
 			path = Runner;
 			sourceTree = "<group>";
 		};
+		A34BDAB69887CA62E66BBE0C /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+				68BCA30362F86E3315B803F0 /* Pods_Runner.framework */,
+				26AB1D3BA80CF9317B02D050 /* Pods_RunnerTests.framework */,
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+		C5F29E7C6650494EC5533DE4 /* Pods */ = {
+			isa = PBXGroup;
+			children = (
+				9422F1B232719A657EF4859A /* Pods-Runner.debug.xcconfig */,
+				7972017ABB09E455D91AA966 /* Pods-Runner.release.xcconfig */,
+				7CEE634403FE00F259131478 /* Pods-Runner.profile.xcconfig */,
+				4653CB41F308F206EF2A3713 /* Pods-RunnerTests.debug.xcconfig */,
+				EB24E5DCE520B6C74B4F84B8 /* Pods-RunnerTests.release.xcconfig */,
+				CE3DB8A9A3C5C1C127369858 /* Pods-RunnerTests.profile.xcconfig */,
+			);
+			name = Pods;
+			path = Pods;
+			sourceTree = "<group>";
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -128,8 +172,10 @@
 			isa = PBXNativeTarget;
 			buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
 			buildPhases = (
+				315F9C27D684E77FEEFFA0A1 /* [CP] Check Pods Manifest.lock */,
 				331C807D294A63A400263BE5 /* Sources */,
 				331C807F294A63A400263BE5 /* Resources */,
+				3D53E616CD87DE3AAD9031B7 /* Frameworks */,
 			);
 			buildRules = (
 			);
@@ -145,12 +191,15 @@
 			isa = PBXNativeTarget;
 			buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
 			buildPhases = (
+				E09289D27D48FA0CFF422379 /* [CP] Check Pods Manifest.lock */,
 				9740EEB61CF901F6004384FC /* Run Script */,
 				97C146EA1CF9000F007C117D /* Sources */,
 				97C146EB1CF9000F007C117D /* Frameworks */,
 				97C146EC1CF9000F007C117D /* Resources */,
 				9705A1C41CF9048500538489 /* Embed Frameworks */,
 				3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+				478300DE3CACA11EE0F7BF8B /* [CP] Embed Pods Frameworks */,
+				D3F462411D720FF09A9BB305 /* [CP] Copy Pods Resources */,
 			);
 			buildRules = (
 			);
@@ -222,6 +271,28 @@
 /* End PBXResourcesBuildPhase section */
 
 /* Begin PBXShellScriptBuildPhase section */
+		315F9C27D684E77FEEFFA0A1 /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
 		3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
 			isa = PBXShellScriptBuildPhase;
 			alwaysOutOfDate = 1;
@@ -238,6 +309,23 @@
 			shellPath = /bin/sh;
 			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
 		};
+		478300DE3CACA11EE0F7BF8B /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
 		9740EEB61CF901F6004384FC /* Run Script */ = {
 			isa = PBXShellScriptBuildPhase;
 			alwaysOutOfDate = 1;
@@ -253,6 +341,45 @@
 			shellPath = /bin/sh;
 			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
 		};
+		D3F462411D720FF09A9BB305 /* [CP] Copy Pods Resources */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
+			);
+			name = "[CP] Copy Pods Resources";
+			outputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		E09289D27D48FA0CFF422379 /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
 /* End PBXShellScriptBuildPhase section */
 
 /* Begin PBXSourcesBuildPhase section */
@@ -379,6 +506,7 @@
 		};
 		331C8088294A63A400263BE5 /* Debug */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = 4653CB41F308F206EF2A3713 /* Pods-RunnerTests.debug.xcconfig */;
 			buildSettings = {
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CODE_SIGN_STYLE = Automatic;
@@ -396,6 +524,7 @@
 		};
 		331C8089294A63A400263BE5 /* Release */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = EB24E5DCE520B6C74B4F84B8 /* Pods-RunnerTests.release.xcconfig */;
 			buildSettings = {
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CODE_SIGN_STYLE = Automatic;
@@ -411,6 +540,7 @@
 		};
 		331C808A294A63A400263BE5 /* Profile */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = CE3DB8A9A3C5C1C127369858 /* Pods-RunnerTests.profile.xcconfig */;
 			buildSettings = {
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CODE_SIGN_STYLE = Automatic;

+ 3 - 0
flutter_asr_client/ios/Runner.xcworkspace/contents.xcworkspacedata

@@ -4,4 +4,7 @@
    <FileRef
       location = "group:Runner.xcodeproj">
    </FileRef>
+   <FileRef
+      location = "group:Pods/Pods.xcodeproj">
+   </FileRef>
 </Workspace>

+ 2 - 0
flutter_asr_client/ios/Runner/Info.plist

@@ -4,6 +4,8 @@
 <dict>
 	<key>CFBundleDevelopmentRegion</key>
 	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>NSMicrophoneUsageDescription</key>
+	<string>需要使用麦克风进行实时语音识别录音</string>
 	<key>CFBundleDisplayName</key>
 	<string>Asr Client</string>
 	<key>CFBundleExecutable</key>

+ 1 - 1
flutter_asr_client/lib/pages/history/history_page.dart

@@ -35,7 +35,7 @@ class HistoryPage extends ConsumerWidget {
         children: [
           Container(
             color: AppColors.card,
-            padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
+            padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
             child: const FilterChips(),
           ),
           Expanded(

+ 123 - 29
flutter_asr_client/lib/pages/history/widgets/filter_chips.dart

@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/models/report.dart';
 import 'package:asr_client/providers/report_providers.dart';
 import 'package:asr_client/theme/app_colors.dart';
 
@@ -9,42 +10,135 @@ class FilterChips extends ConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final current = ref.watch(reportFilterProvider);
+    final reports =
+        ref.watch(reportGroupsProvider).expand((group) => group.reports);
 
-    return SingleChildScrollView(
-      scrollDirection: Axis.horizontal,
+    const configs = <_FilterConfig>[
+      _FilterConfig(filter: ReportFilter.all, label: '全部', dotColor: null),
+      _FilterConfig(filter: ReportFilter.ok, label: '合格', dotColor: AppColors.ok),
+      _FilterConfig(filter: ReportFilter.warn, label: '预警', dotColor: AppColors.warn),
+      _FilterConfig(filter: ReportFilter.draft, label: '草稿', dotColor: AppColors.ink3),
+    ];
+
+    return Container(
+      decoration: BoxDecoration(
+        color: AppColors.card,
+        borderRadius: BorderRadius.circular(12),
+        border: Border.all(color: AppColors.line2),
+      ),
+      padding: const EdgeInsets.all(4),
       child: Row(
-        children: ReportFilter.values.map((filter) {
-          final isSelected = filter == current;
-          return Padding(
-            padding: const EdgeInsets.only(right: 8),
-            child: ChoiceChip(
-              label: Text(_label(filter)),
-              selected: isSelected,
-              onSelected: (_) =>
-                  ref.read(reportFilterProvider.notifier).state = filter,
-              selectedColor: AppColors.app,
-              backgroundColor: AppColors.card,
-              side: BorderSide(
-                color: isSelected ? AppColors.app : AppColors.line2,
-              ),
-              labelStyle: TextStyle(
-                color: isSelected ? Colors.white : AppColors.ink2,
-                fontWeight: FontWeight.w700,
-                fontSize: 12,
+        children: [
+          for (final config in configs)
+            Expanded(
+              child: _FilterSegment(
+                label: config.label,
+                count: config.filter == ReportFilter.all
+                    ? reports.length
+                    : reports
+                        .where((r) => _matches(config.filter, r.status))
+                        .length,
+                dotColor: config.dotColor,
+                selected: config.filter == current,
+                onTap: () =>
+                    ref.read(reportFilterProvider.notifier).state = config.filter,
               ),
             ),
-          );
-        }).toList(),
+        ],
       ),
     );
   }
 
-  String _label(ReportFilter filter) {
-    return switch (filter) {
-      ReportFilter.all => '全部',
-      ReportFilter.ok => '合格',
-      ReportFilter.warn => '预警',
-      ReportFilter.draft => '草稿',
-    };
+  bool _matches(ReportFilter filter, ReportStatus status) {
+    switch (filter) {
+      case ReportFilter.ok:
+        return status == ReportStatus.ok;
+      case ReportFilter.warn:
+        return status == ReportStatus.warn;
+      case ReportFilter.draft:
+        return status == ReportStatus.draft;
+      case ReportFilter.all:
+        return true;
+    }
+  }
+}
+
+class _FilterConfig {
+  const _FilterConfig({
+    required this.filter,
+    required this.label,
+    required this.dotColor,
+  });
+
+  final ReportFilter filter;
+  final String label;
+  final Color? dotColor;
+}
+
+class _FilterSegment extends StatelessWidget {
+  const _FilterSegment({
+    required this.label,
+    required this.count,
+    required this.selected,
+    this.dotColor,
+    required this.onTap,
+  });
+
+  final String label;
+  final int count;
+  final bool selected;
+  final Color? dotColor;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final fill = selected ? (dotColor ?? AppColors.app) : Colors.transparent;
+    final foreground = selected ? Colors.white : AppColors.ink2;
+
+    return Material(
+      color: fill,
+      borderRadius: BorderRadius.circular(9),
+      child: InkWell(
+        borderRadius: BorderRadius.circular(9),
+        onTap: onTap,
+        child: Container(
+          height: 38,
+          alignment: Alignment.center,
+          child: Row(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: [
+              if (dotColor != null) ...[
+                Container(
+                  width: 7,
+                  height: 7,
+                  decoration: BoxDecoration(
+                    shape: BoxShape.circle,
+                    color: selected ? Colors.white : dotColor,
+                  ),
+                ),
+                const SizedBox(width: 6),
+              ],
+              Text(
+                label,
+                style: TextStyle(
+                  fontSize: 13,
+                  fontWeight: FontWeight.w600,
+                  color: foreground,
+                ),
+              ),
+              const SizedBox(width: 5),
+              Text(
+                '$count',
+                style: TextStyle(
+                  fontSize: 11,
+                  fontWeight: FontWeight.w700,
+                  color: selected ? Colors.white70 : AppColors.ink3,
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
   }
 }

+ 25 - 2
flutter_asr_client/lib/pages/recording/notifiers/recording_notifier.dart

@@ -117,7 +117,17 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
       templateName: '模板 · 普通混凝土坍落度 GB/T 50080',
     );
 
-    await _connectWebSocket();
+    try {
+      await _connectWebSocket();
+    } on Exception catch (e) {
+      state = AsyncData(
+        initialState.copyWith(
+          status: RecordingStatus.idle,
+          errorMessage: '无法连接服务器: $e',
+        ),
+      );
+      return state.value ?? initialState;
+    }
     if (_webSocketService.currentState != ConnectionState.connected) {
       state = AsyncData(
         initialState.copyWith(
@@ -128,6 +138,12 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
       return state.value ?? initialState;
     }
     await _startRecording(initialState);
+    // Also set isConnected from the actual WebSocket state, because the
+    // server's 'connected' message may have arrived before the listener was
+    // attached (broadcast stream doesn't buffer).
+    if (_webSocketService.currentState == ConnectionState.connected) {
+      _updateState((s) => s.copyWith(isConnected: true));
+    }
     return state.value ?? initialState;
   }
 
@@ -140,10 +156,17 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
     try {
       final hasPermission = await _audioCaptureService.requestPermission();
       if (!hasPermission) {
+        final permanentlyDenied =
+            await _audioCaptureService.isPermissionPermanentlyDenied;
+        if (permanentlyDenied) {
+          await _audioCaptureService.openSettings();
+        }
         state = AsyncData(
           initialState.copyWith(
             status: RecordingStatus.idle,
-            errorMessage: '需要麦克风权限才能进行录音',
+            errorMessage: permanentlyDenied
+                ? '麦克风权限被拒绝,请在系统设置中开启后重试'
+                : '需要麦克风权限才能进行录音',
           ),
         );
         return;

+ 7 - 8
flutter_asr_client/lib/pages/recording/recording_page.dart

@@ -6,7 +6,6 @@ import 'package:asr_client/pages/recording/notifiers/recording_notifier.dart';
 import 'package:asr_client/pages/recording/widgets/bottom_controls.dart';
 import 'package:asr_client/pages/recording/widgets/conversation_list.dart';
 import 'package:asr_client/pages/recording/widgets/recording_header.dart';
-import 'package:asr_client/pages/recording/widgets/recording_status_bar.dart';
 import 'package:asr_client/theme/app_colors.dart';
 
 class RecordingPage extends ConsumerWidget {
@@ -40,7 +39,11 @@ class RecordingPage extends ConsumerWidget {
         ),
         title: const Text(
           '实时语音识别',
-          style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
+          style: TextStyle(
+            fontSize: 17,
+            fontWeight: FontWeight.w700,
+            color: Colors.white,
+          ),
         ),
         centerTitle: true,
         actions: [
@@ -220,12 +223,6 @@ class _RecordingBody extends ConsumerWidget {
                   taskName: state.taskName ?? '',
                   templateName: state.templateName ?? '',
                 ),
-                const SizedBox(height: 10),
-                RecordingStatusBar(
-                  isRecording: state.isRecording,
-                  elapsed: state.elapsed,
-                  audioLevel: state.audioLevel,
-                ),
                 const SizedBox(height: 8),
                 ConversationList(items: state.items),
               ],
@@ -234,6 +231,8 @@ class _RecordingBody extends ConsumerWidget {
         ),
         BottomControls(
           status: state.status,
+          elapsed: state.elapsed,
+          audioLevel: state.audioLevel,
           onPause: notifier.pauseRecording,
           onResume: notifier.resumeRecording,
           onEnd: () => _RecordingPageHelper.showEndConfirm(context, ref),

+ 91 - 41
flutter_asr_client/lib/pages/recording/widgets/bottom_controls.dart

@@ -6,78 +6,128 @@ class BottomControls extends StatelessWidget {
   const BottomControls({
     super.key,
     required this.status,
+    required this.elapsed,
+    required this.audioLevel,
     required this.onPause,
     required this.onResume,
     required this.onEnd,
   });
 
   final RecordingStatus status;
+  final Duration elapsed;
+  final double audioLevel;
   final VoidCallback onPause;
   final VoidCallback onResume;
   final VoidCallback onEnd;
 
+  String get _timerText {
+    final minutes = elapsed.inMinutes.remainder(60).toString().padLeft(2, '0');
+    final seconds = elapsed.inSeconds.remainder(60).toString().padLeft(2, '0');
+    return '$minutes:$seconds';
+  }
+
   @override
   Widget build(BuildContext context) {
     final isPaused = status == RecordingStatus.paused;
 
     return Container(
-      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
       decoration: const BoxDecoration(
         color: AppColors.card,
         border: Border(top: BorderSide(color: AppColors.line)),
       ),
       child: SafeArea(
-        child: Row(
+        top: false,
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
           children: [
-            Expanded(
-              flex: 44,
-              child: _ScaleButton(
-                onTap: isPaused ? onResume : onPause,
-                child: ElevatedButton(
-                  onPressed: isPaused ? onResume : onPause,
-                  style: ElevatedButton.styleFrom(
-                    backgroundColor: AppColors.card,
-                    foregroundColor: AppColors.ink2,
-                    elevation: 0,
-                    padding: const EdgeInsets.symmetric(vertical: 13),
-                    shape: RoundedRectangleBorder(
-                      borderRadius: BorderRadius.circular(12),
-                      side: const BorderSide(
-                        color: AppColors.line2,
-                        width: 1.5,
-                      ),
+            // Timer + waveform row
+            Padding(
+              padding: const EdgeInsets.only(bottom: 10),
+              child: Row(
+                children: [
+                  Container(
+                    width: 8,
+                    height: 8,
+                    decoration: BoxDecoration(
+                      shape: BoxShape.circle,
+                      color: isPaused ? AppColors.warn : AppColors.danger,
                     ),
-                    textStyle: const TextStyle(
-                      fontSize: 15,
+                  ),
+                  const SizedBox(width: 8),
+                  Text(
+                    _timerText,
+                    style: const TextStyle(
+                      fontSize: 20,
                       fontWeight: FontWeight.w700,
+                      fontFamily: 'monospace',
+                      letterSpacing: 1,
+                      color: AppColors.ink,
                     ),
                   ),
-                  child: Text(isPaused ? '▶ 继续' : '⏸ 暂停'),
-                ),
+                  const Spacer(),
+                  Icon(
+                    isPaused ? Icons.mic_off : Icons.mic,
+                    size: 16,
+                    color: AppColors.ink2,
+                  ),
+                ],
               ),
             ),
-            const SizedBox(width: 10),
-            Expanded(
-              flex: 56,
-              child: _ScaleButton(
-                onTap: onEnd,
-                child: ElevatedButton(
-                  onPressed: onEnd,
-                  style: ElevatedButton.styleFrom(
-                    backgroundColor: AppColors.danger,
-                    foregroundColor: Colors.white,
-                    padding: const EdgeInsets.symmetric(vertical: 13),
-                    shape: RoundedRectangleBorder(
-                      borderRadius: BorderRadius.circular(12),
+            // Button row
+            Row(
+              children: [
+                Expanded(
+                  flex: 44,
+                  child: _ScaleButton(
+                    onTap: isPaused ? onResume : onPause,
+                    child: ElevatedButton(
+                      onPressed: isPaused ? onResume : onPause,
+                      style: ElevatedButton.styleFrom(
+                        backgroundColor: AppColors.card,
+                        foregroundColor: AppColors.ink2,
+                        elevation: 0,
+                        padding: const EdgeInsets.symmetric(vertical: 13),
+                        shape: RoundedRectangleBorder(
+                          borderRadius: BorderRadius.circular(12),
+                          side: const BorderSide(
+                            color: AppColors.line2,
+                            width: 1.5,
+                          ),
+                        ),
+                        textStyle: const TextStyle(
+                          fontSize: 15,
+                          fontWeight: FontWeight.w700,
+                        ),
+                      ),
+                      child: Text(isPaused ? '▶ 继续' : '⏸ 暂停'),
                     ),
-                    textStyle: const TextStyle(
-                      fontSize: 15,
-                      fontWeight: FontWeight.w700,
+                  ),
+                ),
+                const SizedBox(width: 10),
+                Expanded(
+                  flex: 56,
+                  child: _ScaleButton(
+                    onTap: onEnd,
+                    child: ElevatedButton(
+                      onPressed: onEnd,
+                      style: ElevatedButton.styleFrom(
+                        backgroundColor: AppColors.danger,
+                        foregroundColor: Colors.white,
+                        padding: const EdgeInsets.symmetric(vertical: 13),
+                        shape: RoundedRectangleBorder(
+                          borderRadius: BorderRadius.circular(12),
+                        ),
+                        textStyle: const TextStyle(
+                          fontSize: 15,
+                          fontWeight: FontWeight.w700,
+                        ),
+                      ),
+                      child: const Text('■ 结束并生成报告'),
                     ),
                   ),
-                  child: const Text('■ 结束并生成报告'),
                 ),
-              ),
+              ],
             ),
           ],
         ),

+ 158 - 0
flutter_asr_client/lib/pages/reports/reports_page.dart

@@ -0,0 +1,158 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/models/report.dart';
+import 'package:asr_client/pages/history/widgets/date_group_header.dart';
+import 'package:asr_client/pages/history/widgets/filter_chips.dart';
+import 'package:asr_client/pages/history/widgets/report_preview_card.dart';
+import 'package:asr_client/providers/report_providers.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class ReportsPage extends ConsumerWidget {
+  const ReportsPage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final groups = ref.watch(filteredReportsProvider);
+
+    return Scaffold(
+      backgroundColor: AppColors.bg,
+      appBar: AppBar(
+        backgroundColor: AppColors.card,
+        foregroundColor: AppColors.ink,
+        elevation: 0,
+        title: const Text(
+          '检测报告',
+          style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
+        ),
+        centerTitle: true,
+      ),
+      body: Column(
+        children: [
+          Container(
+            color: AppColors.card,
+            padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
+            child: const FilterChips(),
+          ),
+          Expanded(
+            child: groups.isEmpty
+                ? const _EmptyView()
+                : ListView.builder(
+                    padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
+                    itemCount: groups.length * 2,
+                    itemBuilder: (context, index) {
+                      final groupIndex = index ~/ 2;
+                      final group = groups[groupIndex];
+                      if (index.isEven) {
+                        return DateGroupHeader(label: group.label);
+                      }
+                      return Column(
+                        children: group.reports
+                            .map((report) => _ReportCard(report: report))
+                            .toList(),
+                      );
+                    },
+                  ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _ReportCard extends StatelessWidget {
+  const _ReportCard({required this.report});
+
+  final Report report;
+
+  @override
+  Widget build(BuildContext context) {
+    final (statusText, statusColor, statusBg) = switch (report.status) {
+      ReportStatus.ok => ('合格', AppColors.ok, AppColors.okBg),
+      ReportStatus.warn => ('预警', AppColors.danger, AppColors.dangerBg),
+      ReportStatus.draft => ('草稿', AppColors.ink3, AppColors.line),
+    };
+
+    return Card(
+      color: AppColors.card,
+      elevation: 0,
+      margin: const EdgeInsets.only(bottom: 10),
+      shape: RoundedRectangleBorder(
+        borderRadius: BorderRadius.circular(14),
+        side: const BorderSide(color: AppColors.line),
+      ),
+      child: Padding(
+        padding: const EdgeInsets.all(14),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              children: [
+                Container(
+                  padding: const EdgeInsets.symmetric(
+                    horizontal: 8,
+                    vertical: 3,
+                  ),
+                  decoration: BoxDecoration(
+                    color: statusBg,
+                    borderRadius: BorderRadius.circular(6),
+                  ),
+                  child: Text(
+                    statusText,
+                    style: TextStyle(
+                      fontSize: 10.5,
+                      fontWeight: FontWeight.w700,
+                      color: statusColor,
+                    ),
+                  ),
+                ),
+                const Spacer(),
+                Text(
+                  _formatDate(report.date),
+                  style: const TextStyle(fontSize: 11.5, color: AppColors.ink3),
+                ),
+              ],
+            ),
+            const SizedBox(height: 9),
+            Text(
+              report.name,
+              style: const TextStyle(
+                fontSize: 15,
+                fontWeight: FontWeight.w700,
+                color: AppColors.ink,
+              ),
+            ),
+            const SizedBox(height: 3),
+            Text(
+              report.project,
+              style: const TextStyle(fontSize: 12, color: AppColors.ink2),
+            ),
+            ReportPreviewCard(report: report),
+          ],
+        ),
+      ),
+    );
+  }
+
+  String _formatDate(DateTime date) {
+    return '${date.year}-${date.month.toString().padLeft(2, '0')}-'
+        '${date.day.toString().padLeft(2, '0')}';
+  }
+}
+
+class _EmptyView extends StatelessWidget {
+  const _EmptyView();
+
+  @override
+  Widget build(BuildContext context) {
+    return const Center(
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          Icon(Icons.description_outlined, size: 48, color: AppColors.ink3),
+          SizedBox(height: 12),
+          Text('该筛选条件下暂无报告', style: TextStyle(color: AppColors.ink3)),
+        ],
+      ),
+    );
+  }
+}

+ 21 - 19
flutter_asr_client/lib/pages/shell/app_shell.dart

@@ -138,27 +138,29 @@ class _FabButton extends StatelessWidget {
       child: GestureDetector(
         onTap: onTap,
         child: Center(
-          child: Container(
-            width: 52,
-            height: 52,
-            margin: const EdgeInsets.only(top: 22),
-            decoration: BoxDecoration(
-              shape: BoxShape.circle,
-              gradient: const LinearGradient(
-                colors: [AppColors.app, AppColors.appDark],
-                begin: Alignment.topLeft,
-                end: Alignment.bottomRight,
-              ),
-              border: Border.all(color: AppColors.card, width: 3),
-              boxShadow: [
-                BoxShadow(
-                  color: AppColors.app.withValues(alpha: 0.6),
-                  blurRadius: 18,
-                  offset: const Offset(0, 8),
+          child: Transform.translate(
+            offset: const Offset(0, -16),
+            child: Container(
+              width: 60,
+              height: 60,
+              decoration: BoxDecoration(
+                shape: BoxShape.circle,
+                gradient: const LinearGradient(
+                  colors: [AppColors.app, AppColors.appDark],
+                  begin: Alignment.topLeft,
+                  end: Alignment.bottomRight,
                 ),
-              ],
+                border: Border.all(color: AppColors.card, width: 3),
+                boxShadow: [
+                  BoxShadow(
+                    color: AppColors.app.withValues(alpha: 0.6),
+                    blurRadius: 18,
+                    offset: const Offset(0, 8),
+                  ),
+                ],
+              ),
+              child: const Icon(Icons.add, color: Colors.white, size: 32),
             ),
-            child: const Icon(Icons.add, color: Colors.white, size: 24),
           ),
         ),
       ),

+ 2 - 1
flutter_asr_client/lib/router/app_router.dart

@@ -6,6 +6,7 @@ import 'package:asr_client/pages/tasks/tasks_page.dart';
 import 'package:asr_client/pages/history/history_page.dart';
 import 'package:asr_client/pages/profile/profile_page.dart';
 import 'package:asr_client/pages/recording/recording_page.dart';
+import 'package:asr_client/pages/reports/reports_page.dart';
 
 final GlobalKey<NavigatorState> _rootNavKey = GlobalKey<NavigatorState>();
 
@@ -39,7 +40,7 @@ final goRouterProvider = Provider<GoRouter>((ref) {
             routes: [
               GoRoute(
                 path: '/reports',
-                builder: (context, state) => const HistoryPage(),
+                builder: (context, state) => const ReportsPage(),
               ),
             ],
           ),

+ 19 - 1
flutter_asr_client/lib/services/audio_capture_service.dart

@@ -10,6 +10,8 @@ import 'package:asr_client/common/constants.dart';
 abstract interface class AudioCaptureService {
   Stream<double> get audioLevelStream;
   Future<bool> requestPermission();
+  Future<bool> get isPermissionPermanentlyDenied;
+  Future<void> openSettings();
   Future<void> startRecording();
   Future<void> stopRecording();
   Future<String> readLatestChunkAsBase64();
@@ -36,11 +38,27 @@ final class AudioCaptureServiceImpl implements AudioCaptureService {
 
   @override
   Future<bool> requestPermission() async {
+    // Use record's native hasPermission() which reads AVAudioSession directly.
+    // permission_handler is broken on iOS 18 Simulator (always returns
+    // permanentlyDenied even when TCC.db shows granted).
+    final hasPermission = await _recorder.hasPermission();
+    if (hasPermission) return true;
+
+    // Fallback: trigger permission_handler to show the system dialog.
     final status = await Permission.microphone.request();
-    return status.isGranted;
+    if (status.isGranted) return true;
+
+    // Last resort: re-check record in case the dialog just granted it.
+    return await _recorder.hasPermission();
   }
 
   @override
+  Future<bool> get isPermissionPermanentlyDenied async => false;
+
+  @override
+  Future<void> openSettings() => openAppSettings();
+
+  @override
   Future<void> startRecording() async {
     if (_isRecording) return;
     final hasPermission = await _recorder.hasPermission();

+ 4 - 3
flutter_asr_client/lib/services/websocket_service.dart

@@ -61,10 +61,10 @@ final class WebSocketServiceImpl implements WebSocketService {
     }
     _currentUrl = url;
     _shouldReconnect = true;
-    await _connectInternal(url);
+    await _connectInternal(url, rethrowError: true);
   }
 
-  Future<void> _connectInternal(String url) async {
+  Future<void> _connectInternal(String url, {bool rethrowError = false}) async {
     _setState(ConnectionState.connecting);
     try {
       final socket = await WebSocket.connect(
@@ -98,6 +98,7 @@ final class WebSocketServiceImpl implements WebSocketService {
     } on Exception catch (e) {
       _setState(ConnectionState.error);
       _messageController.add(ErrorMessage(message: 'Connection failed: $e'));
+      if (rethrowError) rethrow;
       _scheduleReconnect();
     }
   }
@@ -125,7 +126,7 @@ final class WebSocketServiceImpl implements WebSocketService {
     _reconnectTimer?.cancel();
     _reconnectTimer = Timer(delay, () {
       if (_shouldReconnect && _currentUrl != null) {
-        unawaited(_connectInternal(_currentUrl!));
+        unawaited(_connectInternal(_currentUrl!, rethrowError: false));
       }
     });
   }

+ 2 - 0
flutter_asr_client/macos/Runner/DebugProfile.entitlements

@@ -6,6 +6,8 @@
 	<true/>
 	<key>com.apple.security.cs.allow-jit</key>
 	<true/>
+	<key>com.apple.security.network.client</key>
+	<true/>
 	<key>com.apple.security.network.server</key>
 	<true/>
 </dict>

+ 2 - 0
flutter_asr_client/macos/Runner/Info.plist

@@ -28,5 +28,7 @@
 	<string>MainMenu</string>
 	<key>NSPrincipalClass</key>
 	<string>NSApplication</string>
+	<key>NSLocalNetworkUsageDescription</key>
+	<string>需要访问本地网络以连接语音识别服务</string>
 </dict>
 </plist>

+ 2 - 0
flutter_asr_client/macos/Runner/Release.entitlements

@@ -4,5 +4,7 @@
 <dict>
 	<key>com.apple.security.app-sandbox</key>
 	<true/>
+	<key>com.apple.security.network.client</key>
+	<true/>
 </dict>
 </plist>

+ 6 - 0
flutter_asr_client/test/fakes/fake_services.dart

@@ -79,6 +79,12 @@ class FakeAudioCaptureService implements AudioCaptureService {
   Future<bool> requestPermission() async => true;
 
   @override
+  Future<bool> get isPermissionPermanentlyDenied async => false;
+
+  @override
+  Future<void> openSettings() async {}
+
+  @override
   Future<void> startRecording() async {
     _recording = true;
   }

+ 1 - 1
flutter_asr_client/test/unit/services/websocket_service_runtime_test.dart

@@ -38,7 +38,7 @@ void main() {
         markTestSkipped('ASR server is not running on localhost:8765');
       }
 
-      await service.connect('ws://192.168.199.195:8765');
+      await service.connect('ws://127.0.0.1:8765');
 
       final connected = await service.messageStream
           .firstWhere((msg) => msg is ConnectedMessage)