Просмотр исходного кода

feat: 初始化跨平台ASR客户端项目,添加基础工程与配置

1.  初始化Flutter跨平台项目,添加Android、iOS、macOS、Windows、Linux平台工程文件
2.  添加应用图标、启动页等资源文件
3.  配置各平台构建参数与依赖
4.  添加基础应用代码与状态管理配置
5.  修改whisper-qt-server监听地址为0.0.0.0以支持网络访问
wenhongquan 3 недель назад
Родитель
Сommit
5a9f4e300a
100 измененных файлов с 4641 добавлено и 0 удалено
  1. 5 0
      .codegraph/.gitignore
  2. 45 0
      flutter_asr_client/.metadata
  3. 16 0
      flutter_asr_client/README.md
  4. 28 0
      flutter_asr_client/analysis_options.yaml
  5. 44 0
      flutter_asr_client/android/app/build.gradle.kts
  6. 7 0
      flutter_asr_client/android/app/src/debug/AndroidManifest.xml
  7. 47 0
      flutter_asr_client/android/app/src/main/AndroidManifest.xml
  8. 5 0
      flutter_asr_client/android/app/src/main/kotlin/com/dinnoni/asr_client/MainActivity.kt
  9. 12 0
      flutter_asr_client/android/app/src/main/res/drawable-v21/launch_background.xml
  10. 12 0
      flutter_asr_client/android/app/src/main/res/drawable/launch_background.xml
  11. BIN
      flutter_asr_client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
  12. BIN
      flutter_asr_client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
  13. BIN
      flutter_asr_client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  14. BIN
      flutter_asr_client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  15. BIN
      flutter_asr_client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  16. 18 0
      flutter_asr_client/android/app/src/main/res/values-night/styles.xml
  17. 18 0
      flutter_asr_client/android/app/src/main/res/values/styles.xml
  18. 7 0
      flutter_asr_client/android/app/src/profile/AndroidManifest.xml
  19. 24 0
      flutter_asr_client/android/build.gradle.kts
  20. 2 0
      flutter_asr_client/android/gradle.properties
  21. 5 0
      flutter_asr_client/android/gradle/wrapper/gradle-wrapper.properties
  22. 26 0
      flutter_asr_client/android/settings.gradle.kts
  23. 2 0
      flutter_asr_client/dart_test.yaml
  24. 180 0
      flutter_asr_client/integration_test/app_flow_test.dart
  25. 26 0
      flutter_asr_client/ios/Flutter/AppFrameworkInfo.plist
  26. 2 0
      flutter_asr_client/ios/Flutter/Debug.xcconfig
  27. 2 0
      flutter_asr_client/ios/Flutter/Release.xcconfig
  28. 43 0
      flutter_asr_client/ios/Podfile
  29. 619 0
      flutter_asr_client/ios/Runner.xcodeproj/project.pbxproj
  30. 7 0
      flutter_asr_client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
  31. 8 0
      flutter_asr_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  32. 8 0
      flutter_asr_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
  33. 101 0
      flutter_asr_client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  34. 7 0
      flutter_asr_client/ios/Runner.xcworkspace/contents.xcworkspacedata
  35. 8 0
      flutter_asr_client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  36. 8 0
      flutter_asr_client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
  37. 13 0
      flutter_asr_client/ios/Runner/AppDelegate.swift
  38. 122 0
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
  39. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
  40. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
  41. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
  42. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
  43. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
  44. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
  45. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
  46. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
  47. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
  48. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
  49. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
  50. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
  51. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
  52. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
  53. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
  54. 23 0
      flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
  55. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
  56. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
  57. BIN
      flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
  58. 5 0
      flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
  59. 37 0
      flutter_asr_client/ios/Runner/Base.lproj/LaunchScreen.storyboard
  60. 26 0
      flutter_asr_client/ios/Runner/Base.lproj/Main.storyboard
  61. 49 0
      flutter_asr_client/ios/Runner/Info.plist
  62. 1 0
      flutter_asr_client/ios/Runner/Runner-Bridging-Header.h
  63. 12 0
      flutter_asr_client/ios/RunnerTests/RunnerTests.swift
  64. 24 0
      flutter_asr_client/lib/app.dart
  65. 13 0
      flutter_asr_client/lib/common/constants.dart
  66. 8 0
      flutter_asr_client/lib/main.dart
  67. 80 0
      flutter_asr_client/lib/models/conversation.dart
  68. 64 0
      flutter_asr_client/lib/models/report.dart
  69. 61 0
      flutter_asr_client/lib/models/task.dart
  70. 71 0
      flutter_asr_client/lib/models/user_profile.dart
  71. 91 0
      flutter_asr_client/lib/models/websocket_message.dart
  72. 83 0
      flutter_asr_client/lib/pages/history/history_page.dart
  73. 36 0
      flutter_asr_client/lib/pages/history/widgets/date_group_header.dart
  74. 50 0
      flutter_asr_client/lib/pages/history/widgets/filter_chips.dart
  75. 95 0
      flutter_asr_client/lib/pages/history/widgets/report_item.dart
  76. 70 0
      flutter_asr_client/lib/pages/history/widgets/report_preview_card.dart
  77. 55 0
      flutter_asr_client/lib/pages/profile/profile_page.dart
  78. 79 0
      flutter_asr_client/lib/pages/profile/widgets/profile_hero_card.dart
  79. 89 0
      flutter_asr_client/lib/pages/profile/widgets/server_settings_dialog.dart
  80. 50 0
      flutter_asr_client/lib/pages/profile/widgets/settings_menu_item.dart
  81. 44 0
      flutter_asr_client/lib/pages/profile/widgets/settings_section.dart
  82. 76 0
      flutter_asr_client/lib/pages/profile/widgets/stats_row.dart
  83. 353 0
      flutter_asr_client/lib/pages/recording/notifiers/recording_notifier.dart
  84. 250 0
      flutter_asr_client/lib/pages/recording/recording_page.dart
  85. 143 0
      flutter_asr_client/lib/pages/recording/widgets/ai_response_card.dart
  86. 120 0
      flutter_asr_client/lib/pages/recording/widgets/bottom_controls.dart
  87. 52 0
      flutter_asr_client/lib/pages/recording/widgets/conversation_list.dart
  88. 52 0
      flutter_asr_client/lib/pages/recording/widgets/field_grid.dart
  89. 58 0
      flutter_asr_client/lib/pages/recording/widgets/recording_header.dart
  90. 206 0
      flutter_asr_client/lib/pages/recording/widgets/recording_status_bar.dart
  91. 65 0
      flutter_asr_client/lib/pages/recording/widgets/user_bubble.dart
  92. 31 0
      flutter_asr_client/lib/pages/recording/widgets/verdict_badge.dart
  93. 167 0
      flutter_asr_client/lib/pages/shell/app_shell.dart
  94. 118 0
      flutter_asr_client/lib/pages/tasks/tasks_page.dart
  95. 38 0
      flutter_asr_client/lib/pages/tasks/widgets/section_header.dart
  96. 111 0
      flutter_asr_client/lib/pages/tasks/widgets/task_card.dart
  97. 36 0
      flutter_asr_client/lib/pages/tasks/widgets/task_status_chip.dart
  98. 16 0
      flutter_asr_client/lib/providers/audio_providers.dart
  99. 11 0
      flutter_asr_client/lib/providers/profile_providers.dart
  100. 45 0
      flutter_asr_client/lib/providers/report_providers.dart

+ 5 - 0
.codegraph/.gitignore

@@ -0,0 +1,5 @@
+# CodeGraph data files — local to each machine, not for committing.
+# Ignore everything in .codegraph/ except this file itself, so transient
+# files (the database, daemon.pid, sockets, logs) never show up in git.
+*
+!.gitignore

+ 45 - 0
flutter_asr_client/.metadata

@@ -0,0 +1,45 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787"
+  channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+  platforms:
+    - platform: root
+      create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+      base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+    - platform: android
+      create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+      base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+    - platform: ios
+      create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+      base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+    - platform: linux
+      create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+      base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+    - platform: macos
+      create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+      base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+    - platform: web
+      create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+      base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+    - platform: windows
+      create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+      base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
+
+  # User provided section
+
+  # List of Local paths (relative to this file) that should be
+  # ignored by the migrate tool.
+  #
+  # Files that are not part of the templates will be ignored by default.
+  unmanaged_files:
+    - 'lib/main.dart'
+    - 'ios/Runner.xcodeproj/project.pbxproj'

+ 16 - 0
flutter_asr_client/README.md

@@ -0,0 +1,16 @@
+# asr_client
+
+A new Flutter project.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.

+ 28 - 0
flutter_asr_client/analysis_options.yaml

@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+  # The lint rules applied to this project can be customized in the
+  # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+  # included above or to enable additional rules. A list of all available lints
+  # and their documentation is published at https://dart.dev/lints.
+  #
+  # Instead of disabling a lint rule for the entire project in the
+  # section below, it can also be suppressed for a single line of code
+  # or a specific dart file by using the `// ignore: name_of_lint` and
+  # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+  # producing the lint.
+  rules:
+    # avoid_print: false  # Uncomment to disable the `avoid_print` rule
+    # prefer_single_quotes: true  # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options

+ 44 - 0
flutter_asr_client/android/app/build.gradle.kts

@@ -0,0 +1,44 @@
+plugins {
+    id("com.android.application")
+    id("kotlin-android")
+    // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+    id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+    namespace = "com.dinnoni.asr_client"
+    compileSdk = flutter.compileSdkVersion
+    ndkVersion = flutter.ndkVersion
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.toString()
+    }
+
+    defaultConfig {
+        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+        applicationId = "com.dinnoni.asr_client"
+        // You can update the following values to match your application needs.
+        // For more information, see: https://flutter.dev/to/review-gradle-config.
+        minSdk = flutter.minSdkVersion
+        targetSdk = flutter.targetSdkVersion
+        versionCode = flutter.versionCode
+        versionName = flutter.versionName
+    }
+
+    buildTypes {
+        release {
+            // TODO: Add your own signing config for the release build.
+            // Signing with the debug keys for now, so `flutter run --release` works.
+            signingConfig = signingConfigs.getByName("debug")
+        }
+    }
+}
+
+flutter {
+    source = "../.."
+}

+ 7 - 0
flutter_asr_client/android/app/src/debug/AndroidManifest.xml

@@ -0,0 +1,7 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- The INTERNET permission is required for development. Specifically,
+         the Flutter tool needs it to communicate with the running application
+         to allow setting breakpoints, to provide hot reload, etc.
+    -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>

+ 47 - 0
flutter_asr_client/android/app/src/main/AndroidManifest.xml

@@ -0,0 +1,47 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <uses-permission android:name="android.permission.RECORD_AUDIO"/>
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <application
+        android:label="asr_client"
+        android:name="${applicationName}"
+        android:icon="@mipmap/ic_launcher">
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:launchMode="singleTop"
+            android:taskAffinity=""
+            android:theme="@style/LaunchTheme"
+            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
+            android:hardwareAccelerated="true"
+            android:windowSoftInputMode="adjustResize">
+            <!-- Specifies an Android theme to apply to this Activity as soon as
+                 the Android process has started. This theme is visible to the user
+                 while the Flutter UI initializes. After that, this theme continues
+                 to determine the Window background behind the Flutter UI. -->
+            <meta-data
+              android:name="io.flutter.embedding.android.NormalTheme"
+              android:resource="@style/NormalTheme"
+              />
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
+            </intent-filter>
+        </activity>
+        <!-- Don't delete the meta-data below.
+             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+        <meta-data
+            android:name="flutterEmbedding"
+            android:value="2" />
+    </application>
+    <!-- Required to query activities that can process text, see:
+         https://developer.android.com/training/package-visibility and
+         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
+
+         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
+    <queries>
+        <intent>
+            <action android:name="android.intent.action.PROCESS_TEXT"/>
+            <data android:mimeType="text/plain"/>
+        </intent>
+    </queries>
+</manifest>

+ 5 - 0
flutter_asr_client/android/app/src/main/kotlin/com/dinnoni/asr_client/MainActivity.kt

@@ -0,0 +1,5 @@
+package com.dinnoni.asr_client
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()

+ 12 - 0
flutter_asr_client/android/app/src/main/res/drawable-v21/launch_background.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Modify this file to customize your launch splash screen -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="?android:colorBackground" />
+
+    <!-- You can insert your own image assets here -->
+    <!-- <item>
+        <bitmap
+            android:gravity="center"
+            android:src="@mipmap/launch_image" />
+    </item> -->
+</layer-list>

+ 12 - 0
flutter_asr_client/android/app/src/main/res/drawable/launch_background.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Modify this file to customize your launch splash screen -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="@android:color/white" />
+
+    <!-- You can insert your own image assets here -->
+    <!-- <item>
+        <bitmap
+            android:gravity="center"
+            android:src="@mipmap/launch_image" />
+    </item> -->
+</layer-list>

BIN
flutter_asr_client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png


BIN
flutter_asr_client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png


BIN
flutter_asr_client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png


BIN
flutter_asr_client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png


BIN
flutter_asr_client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png


+ 18 - 0
flutter_asr_client/android/app/src/main/res/values-night/styles.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
+    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <!-- Show a splash screen on the activity. Automatically removed when
+             the Flutter engine draws its first frame -->
+        <item name="android:windowBackground">@drawable/launch_background</item>
+    </style>
+    <!-- Theme applied to the Android Window as soon as the process has started.
+         This theme determines the color of the Android Window while your
+         Flutter UI initializes, as well as behind your Flutter UI while its
+         running.
+
+         This Theme is only used starting with V2 of Flutter's Android embedding. -->
+    <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <item name="android:windowBackground">?android:colorBackground</item>
+    </style>
+</resources>

+ 18 - 0
flutter_asr_client/android/app/src/main/res/values/styles.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
+    <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
+        <!-- Show a splash screen on the activity. Automatically removed when
+             the Flutter engine draws its first frame -->
+        <item name="android:windowBackground">@drawable/launch_background</item>
+    </style>
+    <!-- Theme applied to the Android Window as soon as the process has started.
+         This theme determines the color of the Android Window while your
+         Flutter UI initializes, as well as behind your Flutter UI while its
+         running.
+
+         This Theme is only used starting with V2 of Flutter's Android embedding. -->
+    <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
+        <item name="android:windowBackground">?android:colorBackground</item>
+    </style>
+</resources>

+ 7 - 0
flutter_asr_client/android/app/src/profile/AndroidManifest.xml

@@ -0,0 +1,7 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- The INTERNET permission is required for development. Specifically,
+         the Flutter tool needs it to communicate with the running application
+         to allow setting breakpoints, to provide hot reload, etc.
+    -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>

+ 24 - 0
flutter_asr_client/android/build.gradle.kts

@@ -0,0 +1,24 @@
+allprojects {
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+val newBuildDir: Directory =
+    rootProject.layout.buildDirectory
+        .dir("../../build")
+        .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+    val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+    project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+    project.evaluationDependsOn(":app")
+}
+
+tasks.register<Delete>("clean") {
+    delete(rootProject.layout.buildDirectory)
+}

+ 2 - 0
flutter_asr_client/android/gradle.properties

@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true

+ 5 - 0
flutter_asr_client/android/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

+ 26 - 0
flutter_asr_client/android/settings.gradle.kts

@@ -0,0 +1,26 @@
+pluginManagement {
+    val flutterSdkPath =
+        run {
+            val properties = java.util.Properties()
+            file("local.properties").inputStream().use { properties.load(it) }
+            val flutterSdkPath = properties.getProperty("flutter.sdk")
+            require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+            flutterSdkPath
+        }
+
+    includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+plugins {
+    id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+    id("com.android.application") version "8.11.1" apply false
+    id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")

+ 2 - 0
flutter_asr_client/dart_test.yaml

@@ -0,0 +1,2 @@
+tags:
+  runtime:

+ 180 - 0
flutter_asr_client/integration_test/app_flow_test.dart

@@ -0,0 +1,180 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:integration_test/integration_test.dart';
+
+import 'package:asr_client/models/websocket_message.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/recording/widgets/recording_header.dart';
+import 'package:asr_client/pages/shell/app_shell.dart';
+import 'package:asr_client/pages/tasks/tasks_page.dart';
+import 'package:asr_client/providers/audio_providers.dart';
+import 'package:asr_client/providers/settings_providers.dart';
+import 'package:asr_client/providers/websocket_providers.dart';
+import 'package:asr_client/theme/app_theme.dart';
+
+import '../test/fakes/fake_services.dart';
+
+class _IntegrationTestApp extends StatelessWidget {
+  const _IntegrationTestApp({required this.overrides, required this.router});
+
+  final List<Override> overrides;
+  final GoRouter router;
+
+  @override
+  Widget build(BuildContext context) {
+    return ProviderScope(
+      overrides: overrides,
+      child: MaterialApp.router(
+        title: '检测数据采集',
+        theme: AppTheme.light,
+        routerConfig: router,
+        debugShowCheckedModeBanner: false,
+      ),
+    );
+  }
+}
+
+GoRouter _buildRouter() {
+  final rootNavKey = GlobalKey<NavigatorState>();
+  return GoRouter(
+    navigatorKey: rootNavKey,
+    initialLocation: '/tasks',
+    routes: [
+      StatefulShellRoute.indexedStack(
+        builder: (context, state, navigationShell) {
+          return AppShell(navigationShell: navigationShell);
+        },
+        branches: [
+          StatefulShellBranch(
+            routes: [
+              GoRoute(
+                path: '/tasks',
+                builder: (context, state) => const TasksPage(),
+              ),
+            ],
+          ),
+          StatefulShellBranch(
+            routes: [
+              GoRoute(
+                path: '/records',
+                builder: (context, state) => const HistoryPage(),
+              ),
+            ],
+          ),
+          StatefulShellBranch(
+            routes: [
+              GoRoute(
+                path: '/reports',
+                builder: (context, state) => const HistoryPage(),
+              ),
+            ],
+          ),
+          StatefulShellBranch(
+            routes: [
+              GoRoute(
+                path: '/profile',
+                builder: (context, state) => const ProfilePage(),
+              ),
+            ],
+          ),
+        ],
+      ),
+      GoRoute(
+        path: '/recording',
+        parentNavigatorKey: rootNavKey,
+        builder: (context, state) => const RecordingPage(),
+      ),
+    ],
+  );
+}
+
+Future<void> _pumpUntil(
+  WidgetTester tester,
+  Finder finder, {
+  int maxIterations = 50,
+}) async {
+  for (var i = 0; i < maxIterations; i++) {
+    await tester.pump(const Duration(milliseconds: 100));
+    if (finder.evaluate().isNotEmpty) return;
+  }
+}
+
+void main() {
+  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+  group('App flow', () {
+    late FakeWebSocketService fakeWebSocket;
+    late FakeAudioCaptureService fakeAudio;
+
+    setUp(() {
+      fakeWebSocket = FakeWebSocketService();
+      fakeAudio = FakeAudioCaptureService();
+    });
+
+    tearDown(() {
+      fakeWebSocket.dispose();
+      fakeAudio.dispose();
+    });
+
+    testWidgets('navigates tabs and records a transcript', (tester) async {
+      await tester.pumpWidget(
+        _IntegrationTestApp(
+          overrides: [
+            settingsServiceProvider.overrideWithValue(FakeSettingsService()),
+            webSocketServiceProvider.overrideWithValue(fakeWebSocket),
+            audioCaptureServiceProvider.overrideWithValue(fakeAudio),
+          ],
+          router: _buildRouter(),
+        ),
+      );
+
+      await _pumpUntil(tester, find.text('今日任务'));
+      expect(find.text('今日任务'), findsOneWidget);
+
+      await tester.tap(find.text('记录'));
+      await _pumpUntil(tester, find.text('检测记录'));
+      expect(find.text('检测记录'), findsOneWidget);
+
+      await tester.tap(find.text('报告'));
+      await _pumpUntil(tester, find.text('检测记录'));
+      expect(find.text('检测记录'), findsOneWidget);
+
+      await tester.tap(find.text('我的'));
+      await _pumpUntil(tester, find.text('采集设置'));
+      expect(find.text('采集设置'), findsOneWidget);
+
+      await tester.tap(find.text('任务'));
+      await _pumpUntil(tester, find.text('今日任务'));
+
+      await tester.tap(find.byIcon(Icons.add));
+      await _pumpUntil(tester, find.text('实时语音识别'));
+      await _pumpUntil(tester, find.text('⏸ 暂停'));
+      final headerFinder = find.byType(RecordingHeader);
+      expect(headerFinder, findsOneWidget);
+      final header = tester.widget<RecordingHeader>(headerFinder);
+      expect(header.taskName, '混凝土坍落度试验');
+      expect(
+        find.descendant(of: headerFinder, matching: find.text('混凝土坍落度试验')),
+        findsOneWidget,
+      );
+      expect(find.text('⏸ 暂停'), findsOneWidget);
+
+      fakeWebSocket.emit(const TranscriptMessage(text: '坍落度 180'));
+      await _pumpUntil(tester, find.textContaining('坍落度 180'));
+      expect(find.textContaining('坍落度 180'), findsOneWidget);
+      expect(find.text('AI'), findsWidgets);
+
+      await tester.tap(find.text('⏸ 暂停'));
+      await _pumpUntil(tester, find.text('▶ 继续'));
+      expect(find.text('▶ 继续'), findsOneWidget);
+
+      await tester.tap(find.text('▶ 继续'));
+      await _pumpUntil(tester, find.text('⏸ 暂停'));
+      expect(find.text('⏸ 暂停'), findsOneWidget);
+    });
+  });
+}

+ 26 - 0
flutter_asr_client/ios/Flutter/AppFrameworkInfo.plist

@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-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>CFBundleDevelopmentRegion</key>
+  <string>en</string>
+  <key>CFBundleExecutable</key>
+  <string>App</string>
+  <key>CFBundleIdentifier</key>
+  <string>io.flutter.flutter.app</string>
+  <key>CFBundleInfoDictionaryVersion</key>
+  <string>6.0</string>
+  <key>CFBundleName</key>
+  <string>App</string>
+  <key>CFBundlePackageType</key>
+  <string>FMWK</string>
+  <key>CFBundleShortVersionString</key>
+  <string>1.0</string>
+  <key>CFBundleSignature</key>
+  <string>????</string>
+  <key>CFBundleVersion</key>
+  <string>1.0</string>
+  <key>MinimumOSVersion</key>
+  <string>13.0</string>
+</dict>
+</plist>

+ 2 - 0
flutter_asr_client/ios/Flutter/Debug.xcconfig

@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "Generated.xcconfig"

+ 2 - 0
flutter_asr_client/ios/Flutter/Release.xcconfig

@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "Generated.xcconfig"

+ 43 - 0
flutter_asr_client/ios/Podfile

@@ -0,0 +1,43 @@
+# Uncomment this line to define a global platform for your project
+# platform :ios, '13.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+  'Debug' => :debug,
+  'Profile' => :release,
+  'Release' => :release,
+}
+
+def flutter_root
+  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+  unless File.exist?(generated_xcode_build_settings_path)
+    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+  end
+
+  File.foreach(generated_xcode_build_settings_path) do |line|
+    matches = line.match(/FLUTTER_ROOT\=(.*)/)
+    return matches[1].strip if matches
+  end
+  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+  use_frameworks!
+
+  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+  target 'RunnerTests' do
+    inherit! :search_paths
+  end
+end
+
+post_install do |installer|
+  installer.pods_project.targets.each do |target|
+    flutter_additional_ios_build_settings(target)
+  end
+end

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

@@ -0,0 +1,619 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 54;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+		74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+		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 */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+		331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+			remoteInfo = Runner;
+		};
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+		9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 10;
+			files = (
+			);
+			name = "Embed Frameworks";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXCopyFilesBuildPhase section */
+
+/* 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>"; };
+		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>"; };
+		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>"; };
+		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.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; };
+		97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
+		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>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+		97C146EB1CF9000F007C117D /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+		331C8082294A63A400263BE5 /* RunnerTests */ = {
+			isa = PBXGroup;
+			children = (
+				331C807B294A618700263BE5 /* RunnerTests.swift */,
+			);
+			path = RunnerTests;
+			sourceTree = "<group>";
+		};
+		9740EEB11CF90186004384FC /* Flutter */ = {
+			isa = PBXGroup;
+			children = (
+				3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+				9740EEB21CF90195004384FC /* Debug.xcconfig */,
+				7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+				9740EEB31CF90195004384FC /* Generated.xcconfig */,
+			);
+			name = Flutter;
+			sourceTree = "<group>";
+		};
+		97C146E51CF9000F007C117D = {
+			isa = PBXGroup;
+			children = (
+				9740EEB11CF90186004384FC /* Flutter */,
+				97C146F01CF9000F007C117D /* Runner */,
+				97C146EF1CF9000F007C117D /* Products */,
+				331C8082294A63A400263BE5 /* RunnerTests */,
+			);
+			sourceTree = "<group>";
+		};
+		97C146EF1CF9000F007C117D /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				97C146EE1CF9000F007C117D /* Runner.app */,
+				331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		97C146F01CF9000F007C117D /* Runner */ = {
+			isa = PBXGroup;
+			children = (
+				97C146FA1CF9000F007C117D /* Main.storyboard */,
+				97C146FD1CF9000F007C117D /* Assets.xcassets */,
+				97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+				97C147021CF9000F007C117D /* Info.plist */,
+				1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+				1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+				74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+				74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+			);
+			path = Runner;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		331C8080294A63A400263BE5 /* RunnerTests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+			buildPhases = (
+				331C807D294A63A400263BE5 /* Sources */,
+				331C807F294A63A400263BE5 /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				331C8086294A63A400263BE5 /* PBXTargetDependency */,
+			);
+			name = RunnerTests;
+			productName = RunnerTests;
+			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
+		97C146ED1CF9000F007C117D /* Runner */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+			buildPhases = (
+				9740EEB61CF901F6004384FC /* Run Script */,
+				97C146EA1CF9000F007C117D /* Sources */,
+				97C146EB1CF9000F007C117D /* Frameworks */,
+				97C146EC1CF9000F007C117D /* Resources */,
+				9705A1C41CF9048500538489 /* Embed Frameworks */,
+				3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = Runner;
+			productName = Runner;
+			productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		97C146E61CF9000F007C117D /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				BuildIndependentTargetsInParallel = YES;
+				LastUpgradeCheck = 1510;
+				ORGANIZATIONNAME = "";
+				TargetAttributes = {
+					331C8080294A63A400263BE5 = {
+						CreatedOnToolsVersion = 14.0;
+						TestTargetID = 97C146ED1CF9000F007C117D;
+					};
+					97C146ED1CF9000F007C117D = {
+						CreatedOnToolsVersion = 7.3.1;
+						LastSwiftMigration = 1100;
+					};
+				};
+			};
+			buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+			compatibilityVersion = "Xcode 9.3";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				en,
+				Base,
+			);
+			mainGroup = 97C146E51CF9000F007C117D;
+			productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				97C146ED1CF9000F007C117D /* Runner */,
+				331C8080294A63A400263BE5 /* RunnerTests */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		331C807F294A63A400263BE5 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		97C146EC1CF9000F007C117D /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+				3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+				97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+				97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+		3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+			isa = PBXShellScriptBuildPhase;
+			alwaysOutOfDate = 1;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+			);
+			name = "Thin Binary";
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+		};
+		9740EEB61CF901F6004384FC /* Run Script */ = {
+			isa = PBXShellScriptBuildPhase;
+			alwaysOutOfDate = 1;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+			);
+			name = "Run Script";
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+		};
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		331C807D294A63A400263BE5 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		97C146EA1CF9000F007C117D /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+				1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+		331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 97C146ED1CF9000F007C117D /* Runner */;
+			targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+		97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+			isa = PBXVariantGroup;
+			children = (
+				97C146FB1CF9000F007C117D /* Base */,
+			);
+			name = Main.storyboard;
+			sourceTree = "<group>";
+		};
+		97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+			isa = PBXVariantGroup;
+			children = (
+				97C147001CF9000F007C117D /* Base */,
+			);
+			name = LaunchScreen.storyboard;
+			sourceTree = "<group>";
+		};
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+		249021D3217E4FDB00AE95B9 /* Profile */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GCC_C_LANGUAGE_STANDARD = gnu99;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				SDKROOT = iphoneos;
+				SUPPORTED_PLATFORMS = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Profile;
+		};
+		249021D4217E4FDB00AE95B9 /* Profile */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ENABLE_MODULES = YES;
+				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+				DEVELOPMENT_TEAM = 3SDP3H4UT7;
+				ENABLE_BITCODE = NO;
+				INFOPLIST_FILE = Runner/Info.plist;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.dinnoni.asrClient;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+				SWIFT_VERSION = 5.0;
+				VERSIONING_SYSTEM = "apple-generic";
+			};
+			name = Profile;
+		};
+		331C8088294A63A400263BE5 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				GENERATE_INFOPLIST_FILE = YES;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.dinnoni.asrClient.RunnerTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 5.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+			};
+			name = Debug;
+		};
+		331C8089294A63A400263BE5 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				GENERATE_INFOPLIST_FILE = YES;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.dinnoni.asrClient.RunnerTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_VERSION = 5.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+			};
+			name = Release;
+		};
+		331C808A294A63A400263BE5 /* Profile */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				GENERATE_INFOPLIST_FILE = YES;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.dinnoni.asrClient.RunnerTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_VERSION = 5.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+			};
+			name = Profile;
+		};
+		97C147031CF9000F007C117D /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = dwarf;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_TESTABILITY = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GCC_C_LANGUAGE_STANDARD = gnu99;
+				GCC_DYNAMIC_NO_PIC = NO;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_OPTIMIZATION_LEVEL = 0;
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"DEBUG=1",
+					"$(inherited)",
+				);
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+				MTL_ENABLE_DEBUG_INFO = YES;
+				ONLY_ACTIVE_ARCH = YES;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		97C147041CF9000F007C117D /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GCC_C_LANGUAGE_STANDARD = gnu99;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				SDKROOT = iphoneos;
+				SUPPORTED_PLATFORMS = iphoneos;
+				SWIFT_COMPILATION_MODE = wholemodule;
+				SWIFT_OPTIMIZATION_LEVEL = "-O";
+				TARGETED_DEVICE_FAMILY = "1,2";
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
+		97C147061CF9000F007C117D /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ENABLE_MODULES = YES;
+				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+				DEVELOPMENT_TEAM = 3SDP3H4UT7;
+				ENABLE_BITCODE = NO;
+				INFOPLIST_FILE = Runner/Info.plist;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.dinnoni.asrClient;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 5.0;
+				VERSIONING_SYSTEM = "apple-generic";
+			};
+			name = Debug;
+		};
+		97C147071CF9000F007C117D /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ENABLE_MODULES = YES;
+				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+				DEVELOPMENT_TEAM = 3SDP3H4UT7;
+				ENABLE_BITCODE = NO;
+				INFOPLIST_FILE = Runner/Info.plist;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.dinnoni.asrClient;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+				SWIFT_VERSION = 5.0;
+				VERSIONING_SYSTEM = "apple-generic";
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				331C8088294A63A400263BE5 /* Debug */,
+				331C8089294A63A400263BE5 /* Release */,
+				331C808A294A63A400263BE5 /* Profile */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				97C147031CF9000F007C117D /* Debug */,
+				97C147041CF9000F007C117D /* Release */,
+				249021D3217E4FDB00AE95B9 /* Profile */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				97C147061CF9000F007C117D /* Debug */,
+				97C147071CF9000F007C117D /* Release */,
+				249021D4217E4FDB00AE95B9 /* Profile */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}

+ 7 - 0
flutter_asr_client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+   version = "1.0">
+   <FileRef
+      location = "self:">
+   </FileRef>
+</Workspace>

+ 8 - 0
flutter_asr_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-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>IDEDidComputeMac32BitWarning</key>
+	<true/>
+</dict>
+</plist>

+ 8 - 0
flutter_asr_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-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>PreviewsEnabled</key>
+	<false/>
+</dict>
+</plist>

+ 101 - 0
flutter_asr_client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme

@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1510"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+               BuildableName = "Runner.app"
+               BlueprintName = "Runner"
+               ReferencedContainer = "container:Runner.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+            BuildableName = "Runner.app"
+            BlueprintName = "Runner"
+            ReferencedContainer = "container:Runner.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <Testables>
+         <TestableReference
+            skipped = "NO"
+            parallelizable = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "331C8080294A63A400263BE5"
+               BuildableName = "RunnerTests.xctest"
+               BlueprintName = "RunnerTests"
+               ReferencedContainer = "container:Runner.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      enableGPUValidationMode = "1"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+            BuildableName = "Runner.app"
+            BlueprintName = "Runner"
+            ReferencedContainer = "container:Runner.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Profile"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+            BuildableName = "Runner.app"
+            BlueprintName = "Runner"
+            ReferencedContainer = "container:Runner.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

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

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+   version = "1.0">
+   <FileRef
+      location = "group:Runner.xcodeproj">
+   </FileRef>
+</Workspace>

+ 8 - 0
flutter_asr_client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-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>IDEDidComputeMac32BitWarning</key>
+	<true/>
+</dict>
+</plist>

+ 8 - 0
flutter_asr_client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-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>PreviewsEnabled</key>
+	<false/>
+</dict>
+</plist>

+ 13 - 0
flutter_asr_client/ios/Runner/AppDelegate.swift

@@ -0,0 +1,13 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate {
+  override func application(
+    _ application: UIApplication,
+    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+  ) -> Bool {
+    GeneratedPluginRegistrant.register(with: self)
+    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+  }
+}

+ 122 - 0
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json

@@ -0,0 +1,122 @@
+{
+  "images" : [
+    {
+      "size" : "20x20",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-20x20@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "20x20",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-20x20@3x.png",
+      "scale" : "3x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-29x29@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-29x29@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-29x29@3x.png",
+      "scale" : "3x"
+    },
+    {
+      "size" : "40x40",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-40x40@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "40x40",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-40x40@3x.png",
+      "scale" : "3x"
+    },
+    {
+      "size" : "60x60",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-60x60@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "60x60",
+      "idiom" : "iphone",
+      "filename" : "Icon-App-60x60@3x.png",
+      "scale" : "3x"
+    },
+    {
+      "size" : "20x20",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-20x20@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "20x20",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-20x20@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-29x29@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "29x29",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-29x29@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "40x40",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-40x40@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "40x40",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-40x40@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "76x76",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-76x76@1x.png",
+      "scale" : "1x"
+    },
+    {
+      "size" : "76x76",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-76x76@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "83.5x83.5",
+      "idiom" : "ipad",
+      "filename" : "Icon-App-83.5x83.5@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "size" : "1024x1024",
+      "idiom" : "ios-marketing",
+      "filename" : "Icon-App-1024x1024@1x.png",
+      "scale" : "1x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}

BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png


+ 23 - 0
flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json

@@ -0,0 +1,23 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "filename" : "LaunchImage.png",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "universal",
+      "filename" : "LaunchImage@2x.png",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "filename" : "LaunchImage@3x.png",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}

BIN
flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png


BIN
flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png


+ 5 - 0
flutter_asr_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md

@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

+ 37 - 0
flutter_asr_client/ios/Runner/Base.lproj/LaunchScreen.storyboard

@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
+    </dependencies>
+    <scenes>
+        <!--View Controller-->
+        <scene sceneID="EHf-IW-A2E">
+            <objects>
+                <viewController id="01J-lp-oVM" sceneMemberID="viewController">
+                    <layoutGuides>
+                        <viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
+                        <viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
+                    </layoutGuides>
+                    <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <subviews>
+                            <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
+                            </imageView>
+                        </subviews>
+                        <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
+                        <constraints>
+                            <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
+                            <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
+                        </constraints>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="53" y="375"/>
+        </scene>
+    </scenes>
+    <resources>
+        <image name="LaunchImage" width="168" height="185"/>
+    </resources>
+</document>

+ 26 - 0
flutter_asr_client/ios/Runner/Base.lproj/Main.storyboard

@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
+    </dependencies>
+    <scenes>
+        <!--Flutter View Controller-->
+        <scene sceneID="tne-QT-ifu">
+            <objects>
+                <viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
+                    <layoutGuides>
+                        <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
+                        <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
+                    </layoutGuides>
+                    <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
+                        <rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
+            </objects>
+        </scene>
+    </scenes>
+</document>

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

@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-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>CFBundleDevelopmentRegion</key>
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>CFBundleDisplayName</key>
+	<string>Asr Client</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>asr_client</string>
+	<key>CFBundlePackageType</key>
+	<string>APPL</string>
+	<key>CFBundleShortVersionString</key>
+	<string>$(FLUTTER_BUILD_NAME)</string>
+	<key>CFBundleSignature</key>
+	<string>????</string>
+	<key>CFBundleVersion</key>
+	<string>$(FLUTTER_BUILD_NUMBER)</string>
+	<key>LSRequiresIPhoneOS</key>
+	<true/>
+	<key>UILaunchStoryboardName</key>
+	<string>LaunchScreen</string>
+	<key>UIMainStoryboardFile</key>
+	<string>Main</string>
+	<key>UISupportedInterfaceOrientations</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+	<key>UISupportedInterfaceOrientations~ipad</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationPortraitUpsideDown</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+	<key>CADisableMinimumFrameDurationOnPhone</key>
+	<true/>
+	<key>UIApplicationSupportsIndirectInputEvents</key>
+	<true/>
+</dict>
+</plist>

+ 1 - 0
flutter_asr_client/ios/Runner/Runner-Bridging-Header.h

@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"

+ 12 - 0
flutter_asr_client/ios/RunnerTests/RunnerTests.swift

@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+  func testExample() {
+    // If you add code to the Runner application, consider adding tests here.
+    // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+  }
+
+}

+ 24 - 0
flutter_asr_client/lib/app.dart

@@ -0,0 +1,24 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/theme/app_theme.dart';
+import 'package:asr_client/router/app_router.dart';
+
+class AsrApp extends ConsumerWidget {
+  const AsrApp({super.key, this.overrides = const []});
+
+  final List<Override> overrides;
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final router = ref.watch(goRouterProvider);
+    return ProviderScope(
+      overrides: overrides,
+      child: MaterialApp.router(
+        title: '检测数据采集',
+        theme: AppTheme.light,
+        routerConfig: router,
+        debugShowCheckedModeBanner: false,
+      ),
+    );
+  }
+}

+ 13 - 0
flutter_asr_client/lib/common/constants.dart

@@ -0,0 +1,13 @@
+abstract final class AppConstants {
+  static const defaultHost = 'localhost';
+  static const defaultPort = 8765;
+  static const targetSampleRate = 16000;
+  static const bufferSize = 4096;
+  static const transcribeInterval = Duration(seconds: 2);
+  static const audioSendInterval = Duration(milliseconds: 300);
+  static const maxAudioChunkBytes = 16000;
+  static const pingInterval = Duration(seconds: 30);
+  static const reconnectBaseDelay = Duration(seconds: 1);
+  static const reconnectMaxDelay = Duration(seconds: 30);
+  static const appName = '检测数据采集';
+}

+ 8 - 0
flutter_asr_client/lib/main.dart

@@ -0,0 +1,8 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/app.dart';
+
+void main() {
+  WidgetsFlutterBinding.ensureInitialized();
+  runApp(const ProviderScope(child: AsrApp()));
+}

+ 80 - 0
flutter_asr_client/lib/models/conversation.dart

@@ -0,0 +1,80 @@
+import 'package:flutter/foundation.dart';
+
+enum ConversationItemType { userBubble, aiCard }
+
+enum AiVerdictStatus { ok, warn, neutral }
+
+@immutable
+final class ConversationItem {
+  const ConversationItem({
+    required this.id,
+    required this.type,
+    required this.timestamp,
+    this.text,
+    this.aiLabel,
+    this.aiSubLabel,
+    this.fields = const [],
+    this.verdict,
+    this.verdictStatus = AiVerdictStatus.neutral,
+    this.actions = const [],
+  });
+
+  final String id;
+  final ConversationItemType type;
+  final DateTime timestamp;
+  final String? text;
+  final String? aiLabel;
+  final String? aiSubLabel;
+  final List<AiField> fields;
+  final String? verdict;
+  final AiVerdictStatus verdictStatus;
+  final List<AiAction> actions;
+
+  ConversationItem copyWith({
+    String? id,
+    ConversationItemType? type,
+    DateTime? timestamp,
+    String? text,
+    String? aiLabel,
+    String? aiSubLabel,
+    List<AiField>? fields,
+    String? verdict,
+    AiVerdictStatus? verdictStatus,
+    List<AiAction>? actions,
+  }) {
+    return ConversationItem(
+      id: id ?? this.id,
+      type: type ?? this.type,
+      timestamp: timestamp ?? this.timestamp,
+      text: text ?? this.text,
+      aiLabel: aiLabel ?? this.aiLabel,
+      aiSubLabel: aiSubLabel ?? this.aiSubLabel,
+      fields: fields ?? this.fields,
+      verdict: verdict ?? this.verdict,
+      verdictStatus: verdictStatus ?? this.verdictStatus,
+      actions: actions ?? this.actions,
+    );
+  }
+}
+
+@immutable
+final class AiField {
+  const AiField({
+    required this.key,
+    required this.value,
+    this.highlight = false,
+  });
+
+  final String key;
+  final String value;
+  final bool highlight;
+}
+
+@immutable
+final class AiAction {
+  const AiAction({required this.label, this.isPrimary = false, this.onTap});
+
+  final String label;
+  final bool isPrimary;
+  final VoidCallback? onTap;
+}

+ 64 - 0
flutter_asr_client/lib/models/report.dart

@@ -0,0 +1,64 @@
+import 'package:flutter/foundation.dart';
+
+enum ReportStatus { ok, warn, draft }
+
+@immutable
+final class Report {
+  const Report({
+    required this.id,
+    required this.name,
+    required this.project,
+    required this.status,
+    required this.date,
+    this.reportId,
+    this.fields,
+    this.verdict,
+  });
+
+  final String id;
+  final String name;
+  final String project;
+  final ReportStatus status;
+  final DateTime date;
+  final String? reportId;
+  final List<ReportField>? fields;
+  final String? verdict;
+
+  Report copyWith({
+    String? id,
+    String? name,
+    String? project,
+    ReportStatus? status,
+    DateTime? date,
+    String? reportId,
+    List<ReportField>? fields,
+    String? verdict,
+  }) {
+    return Report(
+      id: id ?? this.id,
+      name: name ?? this.name,
+      project: project ?? this.project,
+      status: status ?? this.status,
+      date: date ?? this.date,
+      reportId: reportId ?? this.reportId,
+      fields: fields ?? this.fields,
+      verdict: verdict ?? this.verdict,
+    );
+  }
+}
+
+@immutable
+final class ReportField {
+  const ReportField({required this.key, required this.value});
+
+  final String key;
+  final String value;
+}
+
+@immutable
+final class ReportDateGroup {
+  const ReportDateGroup({required this.label, required this.reports});
+
+  final String label;
+  final List<Report> reports;
+}

+ 61 - 0
flutter_asr_client/lib/models/task.dart

@@ -0,0 +1,61 @@
+import 'package:flutter/foundation.dart';
+
+enum TaskStatus { recording, pending, scheduled, completed, failed }
+
+@immutable
+final class Task {
+  const Task({
+    required this.id,
+    required this.title,
+    required this.project,
+    required this.status,
+    this.progress,
+    this.recordedCount,
+    this.totalCount,
+    this.footNote,
+    this.timeInfo,
+  });
+
+  final String id;
+  final String title;
+  final String project;
+  final TaskStatus status;
+  final double? progress;
+  final int? recordedCount;
+  final int? totalCount;
+  final String? footNote;
+  final String? timeInfo;
+
+  Task copyWith({
+    String? id,
+    String? title,
+    String? project,
+    TaskStatus? status,
+    double? progress,
+    int? recordedCount,
+    int? totalCount,
+    String? footNote,
+    String? timeInfo,
+  }) {
+    return Task(
+      id: id ?? this.id,
+      title: title ?? this.title,
+      project: project ?? this.project,
+      status: status ?? this.status,
+      progress: progress ?? this.progress,
+      recordedCount: recordedCount ?? this.recordedCount,
+      totalCount: totalCount ?? this.totalCount,
+      footNote: footNote ?? this.footNote,
+      timeInfo: timeInfo ?? this.timeInfo,
+    );
+  }
+}
+
+@immutable
+final class TaskSection {
+  const TaskSection({required this.title, required this.tasks, this.action});
+
+  final String title;
+  final List<Task> tasks;
+  final String? action;
+}

+ 71 - 0
flutter_asr_client/lib/models/user_profile.dart

@@ -0,0 +1,71 @@
+import 'package:flutter/material.dart';
+
+@immutable
+final class UserProfile {
+  const UserProfile({
+    required this.name,
+    required this.role,
+    required this.employeeId,
+    required this.avatarText,
+    required this.monthlyCount,
+    required this.passRate,
+    required this.activeProjects,
+  });
+
+  final String name;
+  final String role;
+  final String employeeId;
+  final String avatarText;
+  final int monthlyCount;
+  final String passRate;
+  final int activeProjects;
+
+  UserProfile copyWith({
+    String? name,
+    String? role,
+    String? employeeId,
+    String? avatarText,
+    int? monthlyCount,
+    String? passRate,
+    int? activeProjects,
+  }) {
+    return UserProfile(
+      name: name ?? this.name,
+      role: role ?? this.role,
+      employeeId: employeeId ?? this.employeeId,
+      avatarText: avatarText ?? this.avatarText,
+      monthlyCount: monthlyCount ?? this.monthlyCount,
+      passRate: passRate ?? this.passRate,
+      activeProjects: activeProjects ?? this.activeProjects,
+    );
+  }
+}
+
+@immutable
+final class SettingsSection {
+  const SettingsSection({required this.title, required this.items});
+
+  final String title;
+  final List<SettingsItem> items;
+}
+
+@immutable
+final class SettingsItem {
+  const SettingsItem({
+    required this.icon,
+    required this.label,
+    this.iconBgColor,
+    this.iconColor,
+    this.value,
+    this.showArrow = true,
+    this.onTap,
+  });
+
+  final String icon;
+  final String label;
+  final Color? iconBgColor;
+  final Color? iconColor;
+  final String? value;
+  final bool showArrow;
+  final VoidCallback? onTap;
+}

+ 91 - 0
flutter_asr_client/lib/models/websocket_message.dart

@@ -0,0 +1,91 @@
+import 'dart:convert';
+
+sealed class WebSocketMessage {
+  const WebSocketMessage();
+
+  Map<String, dynamic> toJson();
+
+  String toJsonString() => jsonEncode(toJson());
+}
+
+final class AudioMessage extends WebSocketMessage {
+  const AudioMessage({required this.data});
+
+  final String data;
+
+  @override
+  Map<String, dynamic> toJson() => {'type': 'audio', 'data': data};
+}
+
+final class TranscribeMessage extends WebSocketMessage {
+  const TranscribeMessage();
+
+  @override
+  Map<String, dynamic> toJson() => {'type': 'transcribe'};
+}
+
+final class ClearMessage extends WebSocketMessage {
+  const ClearMessage();
+
+  @override
+  Map<String, dynamic> toJson() => {'type': 'clear'};
+}
+
+sealed class ServerMessage {
+  const ServerMessage();
+
+  static ServerMessage fromJson(Map<String, dynamic> json) {
+    final type = json['type'] as String?;
+    switch (type) {
+      case 'connected':
+        return ConnectedMessage(
+          model: json['model'] as Map<String, dynamic>? ?? const {},
+        );
+      case 'transcript':
+        return TranscriptMessage(
+          text: json['text'] as String? ?? '',
+          language: json['language'] as String?,
+        );
+      case 'cleared':
+        return const ClearedMessage();
+      case 'error':
+        return ErrorMessage(
+          message: json['message'] as String? ?? 'Unknown error',
+        );
+      default:
+        return ErrorMessage(message: 'Unknown message type: $type');
+    }
+  }
+
+  static ServerMessage fromJsonString(String source) {
+    try {
+      final json = jsonDecode(source) as Map<String, dynamic>;
+      return fromJson(json);
+    } on FormatException catch (e) {
+      return ErrorMessage(message: 'Invalid JSON: $e');
+    }
+  }
+}
+
+final class ConnectedMessage extends ServerMessage {
+  const ConnectedMessage({required this.model});
+
+  final Map<String, dynamic> model;
+}
+
+final class TranscriptMessage extends ServerMessage {
+  const TranscriptMessage({required this.text, this.language});
+
+  final String text;
+  final String? language;
+}
+
+final class ClearedMessage extends ServerMessage {
+  const ClearedMessage();
+}
+
+final class ErrorMessage extends ServerMessage {
+  const ErrorMessage({required this.message});
+
+  final String message;
+}

+ 83 - 0
flutter_asr_client/lib/pages/history/history_page.dart

@@ -0,0 +1,83 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.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_item.dart';
+import 'package:asr_client/providers/report_providers.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class HistoryPage extends ConsumerWidget {
+  const HistoryPage({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,
+        actions: [
+          IconButton(
+            icon: const Icon(Icons.tune, color: AppColors.ink2),
+            onPressed: () {},
+          ),
+        ],
+      ),
+      body: Column(
+        children: [
+          Container(
+            color: AppColors.card,
+            padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
+            child: const FilterChips(),
+          ),
+          Expanded(
+            child: groups.isEmpty
+                ? const _EmptyView()
+                : ListView.builder(
+                    padding: const EdgeInsets.symmetric(horizontal: 14),
+                    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) => ReportItem(report: report))
+                            .toList(),
+                      );
+                    },
+                  ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _EmptyView extends StatelessWidget {
+  const _EmptyView();
+
+  @override
+  Widget build(BuildContext context) {
+    return const Center(
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          Icon(Icons.folder_open_outlined, size: 48, color: AppColors.ink3),
+          SizedBox(height: 12),
+          Text('该筛选条件下暂无记录', style: TextStyle(color: AppColors.ink3)),
+        ],
+      ),
+    );
+  }
+}

+ 36 - 0
flutter_asr_client/lib/pages/history/widgets/date_group_header.dart

@@ -0,0 +1,36 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class DateGroupHeader extends StatelessWidget {
+  const DateGroupHeader({super.key, required this.label});
+
+  final String label;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.fromLTRB(4, 18, 4, 8),
+      child: Row(
+        children: [
+          Container(
+            width: 4,
+            height: 14,
+            decoration: BoxDecoration(
+              color: AppColors.app,
+              borderRadius: BorderRadius.circular(2),
+            ),
+          ),
+          const SizedBox(width: 8),
+          Text(
+            label,
+            style: const TextStyle(
+              fontSize: 13,
+              fontWeight: FontWeight.w800,
+              color: AppColors.ink,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 50 - 0
flutter_asr_client/lib/pages/history/widgets/filter_chips.dart

@@ -0,0 +1,50 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/providers/report_providers.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class FilterChips extends ConsumerWidget {
+  const FilterChips({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final current = ref.watch(reportFilterProvider);
+
+    return SingleChildScrollView(
+      scrollDirection: Axis.horizontal,
+      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,
+              ),
+            ),
+          );
+        }).toList(),
+      ),
+    );
+  }
+
+  String _label(ReportFilter filter) {
+    return switch (filter) {
+      ReportFilter.all => '全部',
+      ReportFilter.ok => '合格',
+      ReportFilter.warn => '预警',
+      ReportFilter.draft => '草稿',
+    };
+  }
+}

+ 95 - 0
flutter_asr_client/lib/pages/history/widgets/report_item.dart

@@ -0,0 +1,95 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/report.dart';
+import 'package:asr_client/pages/history/widgets/report_preview_card.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class ReportItem extends StatefulWidget {
+  const ReportItem({super.key, required this.report});
+
+  final Report report;
+
+  @override
+  State<ReportItem> createState() => _ReportItemState();
+}
+
+class _ReportItemState extends State<ReportItem> {
+  bool _expanded = false;
+
+  @override
+  Widget build(BuildContext context) {
+    final report = widget.report;
+    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: InkWell(
+        onTap: () => setState(() => _expanded = !_expanded),
+        borderRadius: BorderRadius.circular(14),
+        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(),
+                  Icon(
+                    _expanded ? Icons.expand_less : Icons.expand_more,
+                    color: AppColors.ink3,
+                    size: 20,
+                  ),
+                ],
+              ),
+              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),
+              ),
+              if (_expanded &&
+                  (report.fields?.isNotEmpty ??
+                      false || report.verdict != null))
+                ReportPreviewCard(report: report),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 70 - 0
flutter_asr_client/lib/pages/history/widgets/report_preview_card.dart

@@ -0,0 +1,70 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/report.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class ReportPreviewCard extends StatelessWidget {
+  const ReportPreviewCard({super.key, required this.report});
+
+  final Report report;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      margin: const EdgeInsets.only(top: 10),
+      padding: const EdgeInsets.all(12),
+      decoration: BoxDecoration(
+        color: AppColors.appBg,
+        borderRadius: BorderRadius.circular(12),
+        border: Border.all(color: AppColors.line2),
+      ),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          if (report.fields != null)
+            ...report.fields!.map(
+              (field) => Padding(
+                padding: const EdgeInsets.only(bottom: 4),
+                child: Row(
+                  children: [
+                    Text(
+                      '${field.key}: ',
+                      style: const TextStyle(
+                        fontSize: 11.5,
+                        color: AppColors.ink3,
+                      ),
+                    ),
+                    Text(
+                      field.value,
+                      style: const TextStyle(
+                        fontSize: 11.5,
+                        fontWeight: FontWeight.w700,
+                        color: AppColors.ink2,
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            ),
+          if (report.verdict != null) ...[
+            const SizedBox(height: 6),
+            Container(
+              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
+              decoration: BoxDecoration(
+                color: AppColors.okBg,
+                borderRadius: BorderRadius.circular(6),
+              ),
+              child: Text(
+                report.verdict!,
+                style: const TextStyle(
+                  fontSize: 11.5,
+                  fontWeight: FontWeight.w700,
+                  color: AppColors.ok,
+                ),
+              ),
+            ),
+          ],
+        ],
+      ),
+    );
+  }
+}

+ 55 - 0
flutter_asr_client/lib/pages/profile/profile_page.dart

@@ -0,0 +1,55 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/pages/profile/widgets/profile_hero_card.dart';
+import 'package:asr_client/pages/profile/widgets/server_settings_dialog.dart';
+import 'package:asr_client/pages/profile/widgets/settings_section.dart';
+import 'package:asr_client/pages/profile/widgets/stats_row.dart';
+import 'package:asr_client/providers/profile_providers.dart';
+import 'package:asr_client/services/mock_data_service.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class ProfilePage extends ConsumerWidget {
+  const ProfilePage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final profile = ref.watch(userProfileProvider);
+    final settingsSections = MockDataService.settingsSections(
+      () => _showServerDialog(context),
+    );
+
+    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,
+        actions: [
+          IconButton(
+            icon: const Icon(Icons.settings, color: AppColors.ink2),
+            onPressed: () => _showServerDialog(context),
+          ),
+        ],
+      ),
+      body: ListView(
+        children: [
+          ProfileHeroCard(profile: profile),
+          StatsRow(profile: profile),
+          ...settingsSections.map(
+            (section) => SettingsSectionWidget(section: section),
+          ),
+          const SizedBox(height: 24),
+        ],
+      ),
+    );
+  }
+
+  void _showServerDialog(BuildContext context) {
+    showDialog(context: context, builder: (_) => const ServerSettingsDialog());
+  }
+}

+ 79 - 0
flutter_asr_client/lib/pages/profile/widgets/profile_hero_card.dart

@@ -0,0 +1,79 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/user_profile.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class ProfileHeroCard extends StatelessWidget {
+  const ProfileHeroCard({super.key, required this.profile});
+
+  final UserProfile profile;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      margin: const EdgeInsets.all(14),
+      padding: const EdgeInsets.all(16),
+      decoration: BoxDecoration(
+        borderRadius: BorderRadius.circular(16),
+        gradient: const LinearGradient(
+          colors: [AppColors.nav, AppColors.nav2],
+          begin: Alignment.topLeft,
+          end: Alignment.bottomRight,
+        ),
+        boxShadow: [
+          BoxShadow(
+            color: AppColors.nav.withValues(alpha: 0.25),
+            blurRadius: 16,
+            offset: const Offset(0, 6),
+          ),
+        ],
+      ),
+      child: Row(
+        children: [
+          Container(
+            width: 56,
+            height: 56,
+            decoration: BoxDecoration(
+              color: AppColors.app,
+              shape: BoxShape.circle,
+              border: Border.all(color: Colors.white24, width: 2),
+            ),
+            child: Center(
+              child: Text(
+                profile.avatarText,
+                style: const TextStyle(
+                  fontSize: 22,
+                  fontWeight: FontWeight.w800,
+                  color: Colors.white,
+                ),
+              ),
+            ),
+          ),
+          const SizedBox(width: 14),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text(
+                  profile.name,
+                  style: const TextStyle(
+                    fontSize: 18,
+                    fontWeight: FontWeight.w800,
+                    color: Colors.white,
+                  ),
+                ),
+                const SizedBox(height: 2),
+                Text(
+                  '${profile.role} · ${profile.employeeId}',
+                  style: const TextStyle(
+                    fontSize: 12,
+                    color: Color(0xFFB8D3EA),
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 89 - 0
flutter_asr_client/lib/pages/profile/widgets/server_settings_dialog.dart

@@ -0,0 +1,89 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/common/constants.dart';
+import 'package:asr_client/providers/settings_providers.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class ServerSettingsDialog extends ConsumerStatefulWidget {
+  const ServerSettingsDialog({super.key});
+
+  @override
+  ConsumerState<ServerSettingsDialog> createState() =>
+      _ServerSettingsDialogState();
+}
+
+class _ServerSettingsDialogState extends ConsumerState<ServerSettingsDialog> {
+  late final TextEditingController _hostController;
+  late final TextEditingController _portController;
+
+  @override
+  void initState() {
+    super.initState();
+    final host =
+        ref.read(serverHostProvider).valueOrNull ?? AppConstants.defaultHost;
+    final port =
+        ref.read(serverPortProvider).valueOrNull ?? AppConstants.defaultPort;
+    _hostController = TextEditingController(text: host);
+    _portController = TextEditingController(text: port.toString());
+  }
+
+  @override
+  void dispose() {
+    _hostController.dispose();
+    _portController.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AlertDialog(
+      backgroundColor: AppColors.card,
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+      title: const Text('服务器设置'),
+      content: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          TextField(
+            controller: _hostController,
+            decoration: const InputDecoration(
+              labelText: '主机地址',
+              hintText: '例如 localhost',
+              border: OutlineInputBorder(),
+            ),
+          ),
+          const SizedBox(height: 12),
+          TextField(
+            controller: _portController,
+            keyboardType: TextInputType.number,
+            decoration: const InputDecoration(
+              labelText: '端口',
+              hintText: '例如 8765',
+              border: OutlineInputBorder(),
+            ),
+          ),
+        ],
+      ),
+      actions: [
+        TextButton(
+          onPressed: () => Navigator.of(context).pop(),
+          child: const Text('取消'),
+        ),
+        ElevatedButton(onPressed: _save, child: const Text('保存')),
+      ],
+    );
+  }
+
+  Future<void> _save() async {
+    final host = _hostController.text.trim();
+    final port =
+        int.tryParse(_portController.text.trim()) ?? AppConstants.defaultPort;
+
+    if (host.isEmpty) return;
+
+    final service = ref.read(settingsServiceProvider);
+    await service.setHost(host);
+    await service.setPort(port);
+
+    if (mounted) Navigator.of(context).pop();
+  }
+}

+ 50 - 0
flutter_asr_client/lib/pages/profile/widgets/settings_menu_item.dart

@@ -0,0 +1,50 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/user_profile.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class SettingsMenuItem extends StatelessWidget {
+  const SettingsMenuItem({super.key, required this.item});
+
+  final SettingsItem item;
+
+  @override
+  Widget build(BuildContext context) {
+    return ListTile(
+      contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
+      leading: Container(
+        width: 34,
+        height: 34,
+        decoration: BoxDecoration(
+          color: item.iconBgColor ?? AppColors.bg,
+          borderRadius: BorderRadius.circular(9),
+        ),
+        child: Center(
+          child: Text(item.icon, style: const TextStyle(fontSize: 16)),
+        ),
+      ),
+      title: Text(
+        item.label,
+        style: const TextStyle(
+          fontSize: 14,
+          fontWeight: FontWeight.w600,
+          color: AppColors.ink,
+        ),
+      ),
+      trailing: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          if (item.value != null)
+            Text(
+              item.value!,
+              style: const TextStyle(fontSize: 12, color: AppColors.ink3),
+            ),
+          if (item.showArrow) ...[
+            const SizedBox(width: 6),
+            const Icon(Icons.chevron_right, color: AppColors.ink3, size: 20),
+          ],
+        ],
+      ),
+      onTap: item.onTap,
+    );
+  }
+}

+ 44 - 0
flutter_asr_client/lib/pages/profile/widgets/settings_section.dart

@@ -0,0 +1,44 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/user_profile.dart';
+import 'package:asr_client/pages/profile/widgets/settings_menu_item.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class SettingsSectionWidget extends StatelessWidget {
+  const SettingsSectionWidget({super.key, required this.section});
+
+  final SettingsSection section;
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Padding(
+          padding: const EdgeInsets.fromLTRB(14, 20, 14, 6),
+          child: Text(
+            section.title,
+            style: const TextStyle(
+              fontSize: 12,
+              fontWeight: FontWeight.w800,
+              color: AppColors.ink3,
+              letterSpacing: 0.05,
+            ),
+          ),
+        ),
+        Container(
+          margin: const EdgeInsets.symmetric(horizontal: 14),
+          decoration: BoxDecoration(
+            color: AppColors.card,
+            borderRadius: BorderRadius.circular(14),
+            border: Border.all(color: AppColors.line),
+          ),
+          child: Column(
+            children: section.items
+                .map((item) => SettingsMenuItem(item: item))
+                .toList(),
+          ),
+        ),
+      ],
+    );
+  }
+}

+ 76 - 0
flutter_asr_client/lib/pages/profile/widgets/stats_row.dart

@@ -0,0 +1,76 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/user_profile.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class StatsRow extends StatelessWidget {
+  const StatsRow({super.key, required this.profile});
+
+  final UserProfile profile;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
+      child: Row(
+        children: [
+          _StatCard(
+            value: profile.monthlyCount.toString(),
+            label: '本月检测',
+            color: AppColors.app,
+          ),
+          const SizedBox(width: 10),
+          _StatCard(value: profile.passRate, label: '合格率', color: AppColors.ok),
+          const SizedBox(width: 10),
+          _StatCard(
+            value: profile.activeProjects.toString(),
+            label: '进行中项目',
+            color: AppColors.mid,
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _StatCard extends StatelessWidget {
+  const _StatCard({
+    required this.value,
+    required this.label,
+    required this.color,
+  });
+
+  final String value;
+  final String label;
+  final Color color;
+
+  @override
+  Widget build(BuildContext context) {
+    return Expanded(
+      child: Container(
+        padding: const EdgeInsets.symmetric(vertical: 14),
+        decoration: BoxDecoration(
+          color: AppColors.card,
+          borderRadius: BorderRadius.circular(12),
+          border: Border.all(color: AppColors.line),
+        ),
+        child: Column(
+          children: [
+            Text(
+              value,
+              style: TextStyle(
+                fontSize: 20,
+                fontWeight: FontWeight.w800,
+                color: color,
+              ),
+            ),
+            const SizedBox(height: 2),
+            Text(
+              label,
+              style: const TextStyle(fontSize: 11, color: AppColors.ink3),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 353 - 0
flutter_asr_client/lib/pages/recording/notifiers/recording_notifier.dart

@@ -0,0 +1,353 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:intl/intl.dart';
+import 'package:uuid/uuid.dart';
+
+import 'package:asr_client/common/constants.dart';
+import 'package:asr_client/models/conversation.dart';
+import 'package:asr_client/models/websocket_message.dart';
+import 'package:asr_client/providers/audio_providers.dart';
+import 'package:asr_client/providers/settings_providers.dart';
+import 'package:asr_client/providers/websocket_providers.dart';
+import 'package:asr_client/services/audio_capture_service.dart';
+import 'package:asr_client/services/websocket_service.dart';
+
+enum RecordingStatus { idle, recording, paused, ending }
+
+@immutable
+final class RecordingState {
+  const RecordingState({
+    this.status = RecordingStatus.idle,
+    this.elapsed = Duration.zero,
+    this.audioLevel = 0.0,
+    this.items = const [],
+    this.isConnected = false,
+    this.errorMessage,
+    this.sessionId,
+    this.projectName,
+    this.taskName,
+    this.templateName,
+  });
+
+  final RecordingStatus status;
+  final Duration elapsed;
+  final double audioLevel;
+  final List<ConversationItem> items;
+  final bool isConnected;
+  final String? errorMessage;
+  final String? sessionId;
+  final String? projectName;
+  final String? taskName;
+  final String? templateName;
+
+  RecordingState copyWith({
+    RecordingStatus? status,
+    Duration? elapsed,
+    double? audioLevel,
+    List<ConversationItem>? items,
+    bool? isConnected,
+    String? errorMessage,
+    String? sessionId,
+    String? projectName,
+    String? taskName,
+    String? templateName,
+  }) {
+    return RecordingState(
+      status: status ?? this.status,
+      elapsed: elapsed ?? this.elapsed,
+      audioLevel: audioLevel ?? this.audioLevel,
+      items: items ?? this.items,
+      isConnected: isConnected ?? this.isConnected,
+      errorMessage: errorMessage ?? this.errorMessage,
+      sessionId: sessionId ?? this.sessionId,
+      projectName: projectName ?? this.projectName,
+      taskName: taskName ?? this.taskName,
+      templateName: templateName ?? this.templateName,
+    );
+  }
+
+  bool get isRecording => status == RecordingStatus.recording;
+  bool get isPaused => status == RecordingStatus.paused;
+}
+
+final recordingNotifierProvider =
+    AutoDisposeAsyncNotifierProvider<RecordingNotifier, RecordingState>(
+      RecordingNotifier.new,
+    );
+
+final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
+  Timer? _timer;
+  Timer? _transcribeTimer;
+  Timer? _audioSendTimer;
+  final _uuid = const Uuid();
+  DateTime? _startTime;
+  Duration _accumulated = Duration.zero;
+  var _isSendingAudio = false;
+
+  WebSocketService get _webSocketService => ref.read(webSocketServiceProvider);
+
+  AudioCaptureService get _audioCaptureService =>
+      ref.read(audioCaptureServiceProvider);
+
+  @override
+  FutureOr<RecordingState> build() async {
+    ref.onDispose(() {
+      _timer?.cancel();
+      _transcribeTimer?.cancel();
+      _audioSendTimer?.cancel();
+    });
+
+    final initialState = const RecordingState(
+      sessionId: 'LC-20260707-03',
+      projectName: 'G15 沈海高速改扩建 · K1120+300',
+      taskName: '混凝土坍落度试验',
+      templateName: '模板 · 普通混凝土坍落度 GB/T 50080',
+    );
+
+    await _connectWebSocket();
+    await _startRecording(initialState);
+    return state.value ?? initialState;
+  }
+
+  Future<void> _connectWebSocket() async {
+    final url = await ref.read(serverUrlProvider.future);
+    await _webSocketService.connect(url);
+  }
+
+  Future<void> _startRecording(RecordingState initialState) async {
+    try {
+      final hasPermission = await _audioCaptureService.requestPermission();
+      if (!hasPermission) {
+        state = AsyncData(
+          initialState.copyWith(
+            status: RecordingStatus.idle,
+            errorMessage: '需要麦克风权限才能进行录音',
+          ),
+        );
+        return;
+      }
+
+      await _audioCaptureService.startRecording();
+      state = AsyncData(
+        initialState.copyWith(status: RecordingStatus.recording),
+      );
+      _startTimer();
+      _startTranscribeTimer();
+      _startAudioSendTimer();
+      _listenToAudioLevel();
+      _listenToWebSocketMessages();
+    } on Exception catch (e) {
+      state = AsyncData(
+        initialState.copyWith(
+          status: RecordingStatus.idle,
+          errorMessage: '录音启动失败: $e',
+        ),
+      );
+    }
+  }
+
+  void _startTimer() {
+    _startTime = DateTime.now();
+    _timer?.cancel();
+    _timer = Timer.periodic(const Duration(seconds: 1), (_) {
+      final now = DateTime.now();
+      final elapsed =
+          _accumulated +
+          (_startTime != null ? now.difference(_startTime!) : Duration.zero);
+      _updateState((s) => s.copyWith(elapsed: elapsed));
+    });
+  }
+
+  void _startTranscribeTimer() {
+    _transcribeTimer?.cancel();
+    _transcribeTimer = Timer.periodic(AppConstants.transcribeInterval, (_) {
+      if (state.value?.isRecording ?? false) {
+        _webSocketService.send(const TranscribeMessage());
+      }
+    });
+  }
+
+  void _startAudioSendTimer() {
+    _audioSendTimer?.cancel();
+    _audioSendTimer = Timer.periodic(AppConstants.audioSendInterval, (_) async {
+      if (!(state.value?.isRecording ?? false) || _isSendingAudio) return;
+      _isSendingAudio = true;
+      try {
+        final chunk = await _audioCaptureService.readLatestChunkAsBase64();
+        if (chunk.isNotEmpty) {
+          _webSocketService.send(AudioMessage(data: chunk));
+        }
+      } on Exception catch (_) {
+        // Swallow single chunk errors; the next tick will retry.
+      } finally {
+        _isSendingAudio = false;
+      }
+    });
+  }
+
+  void _listenToAudioLevel() {
+    _audioCaptureService.audioLevelStream.listen(
+      (level) => _updateState((s) => s.copyWith(audioLevel: level)),
+      onError: (_) {},
+    );
+  }
+
+  void _listenToWebSocketMessages() {
+    _webSocketService.messageStream.listen((message) {
+      switch (message) {
+        case ConnectedMessage():
+          _updateState((s) => s.copyWith(isConnected: true));
+        case TranscriptMessage(:final text):
+          _addTranscript(text);
+        case ErrorMessage(:final message):
+          _updateState((s) => s.copyWith(errorMessage: message));
+        case ClearedMessage():
+          break;
+      }
+    }, onError: (_) {});
+  }
+
+  void _addTranscript(String text) {
+    final now = DateTime.now();
+    final timeStr = DateFormat('HH:mm').format(now);
+
+    _updateState(
+      (s) => s.copyWith(
+        items: [
+          ...s.items,
+          ConversationItem(
+            id: _uuid.v4(),
+            type: ConversationItemType.userBubble,
+            timestamp: now,
+            text: text,
+          ),
+          _buildAiResponse(text, now, timeStr),
+        ],
+      ),
+    );
+  }
+
+  ConversationItem _buildAiResponse(
+    String text,
+    DateTime time,
+    String timeStr,
+  ) {
+    if (text.contains('坍落度')) {
+      return ConversationItem(
+        id: _uuid.v4(),
+        type: ConversationItemType.aiCard,
+        timestamp: time,
+        aiLabel: 'AI',
+        aiSubLabel: '结构化 · 已校验',
+        fields: [
+          const AiField(key: '指标', value: '坍落度'),
+          const AiField(key: '仪器', value: '坍落度筒'),
+          const AiField(key: '实测', value: '180 mm'),
+          const AiField(key: '合格区间', value: '160 – 220 mm'),
+        ],
+        verdict: '✓ 合格 · 落在标准区间内',
+        verdictStatus: AiVerdictStatus.ok,
+        actions: const [
+          AiAction(label: '采纳入库', isPrimary: true),
+          AiAction(label: '修正'),
+        ],
+      );
+    }
+
+    if (text.contains('扩展度')) {
+      return ConversationItem(
+        id: _uuid.v4(),
+        type: ConversationItemType.aiCard,
+        timestamp: time,
+        aiLabel: 'AI',
+        aiSubLabel: '结构化 · 需复核',
+        fields: [
+          const AiField(key: '指标', value: '扩展度'),
+          const AiField(key: '实测', value: '545 mm'),
+          const AiField(key: '单位', value: '未口述,已默认 mm', highlight: true),
+        ],
+        verdict: '! 请确认单位,或补充坍落度等级判定',
+        verdictStatus: AiVerdictStatus.warn,
+        actions: const [
+          AiAction(label: '确认 mm', isPrimary: true),
+          AiAction(label: '修正'),
+        ],
+      );
+    }
+
+    return ConversationItem(
+      id: _uuid.v4(),
+      type: ConversationItemType.aiCard,
+      timestamp: time,
+      aiLabel: 'AI',
+      aiSubLabel: '已识别 · 上下文绑定',
+      text: '已收到语音输入:$text',
+    );
+  }
+
+  void pauseRecording() {
+    if (state.value?.status != RecordingStatus.recording) return;
+    _accumulated = state.value!.elapsed;
+    _timer?.cancel();
+    _transcribeTimer?.cancel();
+    _audioSendTimer?.cancel();
+    _audioCaptureService.stopRecording();
+    _updateState((s) => s.copyWith(status: RecordingStatus.paused));
+  }
+
+  Future<void> resumeRecording() async {
+    if (state.value?.status != RecordingStatus.paused) return;
+    try {
+      await _audioCaptureService.startRecording();
+      _startTimer();
+      _startTranscribeTimer();
+      _startAudioSendTimer();
+      _updateState((s) => s.copyWith(status: RecordingStatus.recording));
+    } on Exception catch (e) {
+      _updateState(
+        (s) => s.copyWith(
+          status: RecordingStatus.paused,
+          errorMessage: '继续录音失败: $e',
+        ),
+      );
+    }
+  }
+
+  void endRecording() {
+    _updateState((s) => s.copyWith(status: RecordingStatus.ending));
+  }
+
+  Future<void> confirmEnd() async {
+    _timer?.cancel();
+    _transcribeTimer?.cancel();
+    _audioSendTimer?.cancel();
+    await _audioCaptureService.stopRecording();
+    _webSocketService.disconnect();
+    _updateState(
+      (s) => s.copyWith(
+        status: RecordingStatus.idle,
+        elapsed: Duration.zero,
+        items: [],
+        isConnected: false,
+      ),
+    );
+  }
+
+  void cancelEnd() {
+    _updateState(
+      (s) => s.copyWith(
+        status: s.isPaused ? RecordingStatus.paused : RecordingStatus.recording,
+      ),
+    );
+  }
+
+  void clearError() {
+    _updateState((s) => s.copyWith(errorMessage: null));
+  }
+
+  void _updateState(RecordingState Function(RecordingState) update) {
+    state = AsyncData(update(state.value ?? const RecordingState()));
+  }
+}

+ 250 - 0
flutter_asr_client/lib/pages/recording/recording_page.dart

@@ -0,0 +1,250 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+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 {
+  const RecordingPage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final asyncState = ref.watch(recordingNotifierProvider);
+
+    ref.listen(recordingNotifierProvider, (previous, next) {
+      next.whenOrNull(
+        error: (error, _) => _showErrorSnackBar(context, error.toString()),
+        data: (state) {
+          if (state.errorMessage != null) {
+            _showErrorSnackBar(context, state.errorMessage!);
+            ref.read(recordingNotifierProvider.notifier).clearError();
+          }
+        },
+      );
+    });
+
+    return Scaffold(
+      backgroundColor: AppColors.bg,
+      appBar: AppBar(
+        backgroundColor: AppColors.nav,
+        foregroundColor: Colors.white,
+        elevation: 0,
+        leading: IconButton(
+          icon: const Icon(Icons.arrow_back_ios_new, size: 20),
+          onPressed: () => _onBack(context, ref, asyncState.valueOrNull),
+        ),
+        title: const Text(
+          '实时语音识别',
+          style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
+        ),
+        centerTitle: true,
+        actions: [
+          _ConnectionIndicator(
+            isConnected: asyncState.valueOrNull?.isConnected ?? false,
+          ),
+          const SizedBox(width: 12),
+        ],
+      ),
+      body: SafeArea(
+        child: asyncState.when(
+          loading: () => const _LoadingView(),
+          error: (error, _) => _ErrorView(message: error.toString()),
+          data: (state) => _RecordingBody(state: state),
+        ),
+      ),
+    );
+  }
+
+  void _showErrorSnackBar(BuildContext context, String message) {
+    if (!context.mounted) return;
+    ScaffoldMessenger.of(context).showSnackBar(
+      SnackBar(
+        content: Text(message),
+        backgroundColor: AppColors.danger,
+        behavior: SnackBarBehavior.floating,
+        duration: const Duration(seconds: 3),
+      ),
+    );
+  }
+
+  void _onBack(BuildContext context, WidgetRef ref, RecordingState? state) {
+    if (state != null && state.status != RecordingStatus.idle) {
+      _showEndConfirmDialog(context, ref);
+      return;
+    }
+    if (context.mounted) context.go('/tasks');
+  }
+
+  Future<void> _showEndConfirmDialog(
+    BuildContext context,
+    WidgetRef ref,
+  ) async {
+    final notifier = ref.read(recordingNotifierProvider.notifier);
+    notifier.endRecording();
+
+    final confirmed = await showDialog<bool>(
+      context: context,
+      builder: (context) => AlertDialog(
+        backgroundColor: AppColors.card,
+        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+        title: const Text('结束录音?'),
+        content: const Text('结束录音后将生成报告并返回首页。'),
+        actions: [
+          TextButton(
+            onPressed: () => Navigator.of(context).pop(false),
+            child: const Text('取消'),
+          ),
+          ElevatedButton(
+            onPressed: () => Navigator.of(context).pop(true),
+            style: ElevatedButton.styleFrom(backgroundColor: AppColors.danger),
+            child: const Text('结束并生成报告'),
+          ),
+        ],
+      ),
+    );
+
+    if (confirmed ?? false) {
+      await notifier.confirmEnd();
+      if (context.mounted) context.go('/reports');
+    } else {
+      notifier.cancelEnd();
+    }
+  }
+}
+
+class _ConnectionIndicator extends StatelessWidget {
+  const _ConnectionIndicator({required this.isConnected});
+
+  final bool isConnected;
+
+  @override
+  Widget build(BuildContext context) {
+    return Center(
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Container(
+            width: 7,
+            height: 7,
+            decoration: BoxDecoration(
+              shape: BoxShape.circle,
+              color: isConnected ? AppColors.ok : AppColors.warn,
+            ),
+          ),
+          const SizedBox(width: 5),
+          Text(
+            isConnected ? '已连接' : '连接中',
+            style: const TextStyle(fontSize: 12, color: Colors.white70),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _LoadingView extends StatelessWidget {
+  const _LoadingView();
+
+  @override
+  Widget build(BuildContext context) {
+    return const Center(
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          CircularProgressIndicator(color: AppColors.app),
+          SizedBox(height: 14),
+          Text('正在连接服务器并启动录音...', style: TextStyle(color: AppColors.ink2)),
+        ],
+      ),
+    );
+  }
+}
+
+class _ErrorView extends StatelessWidget {
+  const _ErrorView({required this.message});
+
+  final String message;
+
+  @override
+  Widget build(BuildContext context) {
+    return Center(
+      child: Padding(
+        padding: const EdgeInsets.all(24),
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            const Icon(Icons.error_outline, size: 48, color: AppColors.danger),
+            const SizedBox(height: 14),
+            Text(
+              message,
+              textAlign: TextAlign.center,
+              style: const TextStyle(color: AppColors.ink2),
+            ),
+            const SizedBox(height: 20),
+            ElevatedButton(
+              onPressed: () => context.go('/tasks'),
+              child: const Text('返回首页'),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class _RecordingBody extends ConsumerWidget {
+  const _RecordingBody({required this.state});
+
+  final RecordingState state;
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final notifier = ref.read(recordingNotifierProvider.notifier);
+
+    return Column(
+      children: [
+        Expanded(
+          child: SingleChildScrollView(
+            reverse: true,
+            padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.stretch,
+              children: [
+                RecordingHeader(
+                  projectName: state.projectName ?? '',
+                  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),
+              ],
+            ),
+          ),
+        ),
+        BottomControls(
+          status: state.status,
+          onPause: notifier.pauseRecording,
+          onResume: notifier.resumeRecording,
+          onEnd: () => _RecordingPageHelper.showEndConfirm(context, ref),
+        ),
+      ],
+    );
+  }
+}
+
+class _RecordingPageHelper {
+  static void showEndConfirm(BuildContext context, WidgetRef ref) {
+    const RecordingPage()._showEndConfirmDialog(context, ref);
+  }
+}

+ 143 - 0
flutter_asr_client/lib/pages/recording/widgets/ai_response_card.dart

@@ -0,0 +1,143 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/conversation.dart';
+import 'package:asr_client/pages/recording/widgets/field_grid.dart';
+import 'package:asr_client/pages/recording/widgets/verdict_badge.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class AiResponseCard extends StatelessWidget {
+  const AiResponseCard({super.key, required this.item});
+
+  final ConversationItem item;
+
+  @override
+  Widget build(BuildContext context) {
+    return Align(
+      alignment: Alignment.centerLeft,
+      child: ConstrainedBox(
+        constraints: BoxConstraints(
+          maxWidth: MediaQuery.of(context).size.width * 0.88,
+        ),
+        child: Container(
+          padding: const EdgeInsets.all(10),
+          decoration: BoxDecoration(
+            color: AppColors.card,
+            borderRadius: const BorderRadius.only(
+              topLeft: Radius.circular(4),
+              topRight: Radius.circular(14),
+              bottomLeft: Radius.circular(14),
+              bottomRight: Radius.circular(14),
+            ),
+            border: Border.all(color: AppColors.line),
+            boxShadow: [
+              BoxShadow(
+                color: AppColors.ink.withValues(alpha: 0.06),
+                blurRadius: 10,
+                offset: const Offset(0, 3),
+              ),
+            ],
+          ),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Row(
+                children: [
+                  Container(
+                    width: 16,
+                    height: 16,
+                    decoration: BoxDecoration(
+                      color: AppColors.aiBg,
+                      borderRadius: BorderRadius.circular(5),
+                    ),
+                    child: const Center(
+                      child: Text(
+                        'AI',
+                        style: TextStyle(
+                          fontSize: 9,
+                          fontWeight: FontWeight.w800,
+                          color: AppColors.ai,
+                        ),
+                      ),
+                    ),
+                  ),
+                  const SizedBox(width: 6),
+                  Text(
+                    item.aiSubLabel ?? '',
+                    style: const TextStyle(
+                      fontSize: 10,
+                      fontFamily: 'monospace',
+                      color: AppColors.ai,
+                      fontWeight: FontWeight.w700,
+                      letterSpacing: 0.08,
+                    ),
+                  ),
+                ],
+              ),
+              if (item.text != null) ...[
+                const SizedBox(height: 7),
+                Text(
+                  item.text!,
+                  style: const TextStyle(
+                    fontSize: 12.5,
+                    color: AppColors.ink2,
+                    height: 1.4,
+                  ),
+                ),
+              ],
+              if (item.fields.isNotEmpty) FieldGrid(fields: item.fields),
+              if (item.verdict != null) ...[
+                const SizedBox(height: 6),
+                VerdictBadge(
+                  verdict: item.verdict!,
+                  status: item.verdictStatus,
+                ),
+              ],
+              if (item.actions.isNotEmpty) ...[
+                const SizedBox(height: 8),
+                Row(
+                  children: item.actions
+                      .map(
+                        (action) => Expanded(
+                          child: Padding(
+                            padding: EdgeInsets.only(
+                              right: action == item.actions.last ? 0 : 6,
+                            ),
+                            child: _MiniButton(action: action),
+                          ),
+                        ),
+                      )
+                      .toList(),
+                ),
+              ],
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+class _MiniButton extends StatelessWidget {
+  const _MiniButton({required this.action});
+
+  final AiAction action;
+
+  @override
+  Widget build(BuildContext context) {
+    return TextButton(
+      onPressed: action.onTap,
+      style: TextButton.styleFrom(
+        backgroundColor: action.isPrimary ? AppColors.appBg : AppColors.card,
+        foregroundColor: action.isPrimary ? AppColors.appDark : AppColors.ink2,
+        padding: const EdgeInsets.symmetric(vertical: 7),
+        shape: RoundedRectangleBorder(
+          borderRadius: BorderRadius.circular(8),
+          side: BorderSide(
+            color: action.isPrimary ? AppColors.appBg : AppColors.line2,
+          ),
+        ),
+        textStyle: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.w700),
+      ),
+      child: Text(action.label),
+    );
+  }
+}

+ 120 - 0
flutter_asr_client/lib/pages/recording/widgets/bottom_controls.dart

@@ -0,0 +1,120 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/pages/recording/notifiers/recording_notifier.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class BottomControls extends StatelessWidget {
+  const BottomControls({
+    super.key,
+    required this.status,
+    required this.onPause,
+    required this.onResume,
+    required this.onEnd,
+  });
+
+  final RecordingStatus status;
+  final VoidCallback onPause;
+  final VoidCallback onResume;
+  final VoidCallback onEnd;
+
+  @override
+  Widget build(BuildContext context) {
+    final isPaused = status == RecordingStatus.paused;
+
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+      decoration: const BoxDecoration(
+        color: AppColors.card,
+        border: Border(top: BorderSide(color: AppColors.line)),
+      ),
+      child: SafeArea(
+        child: 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 ? '▶ 继续' : '⏸ 暂停'),
+                ),
+              ),
+            ),
+            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('■ 结束并生成报告'),
+                ),
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class _ScaleButton extends StatefulWidget {
+  const _ScaleButton({required this.child, required this.onTap});
+
+  final Widget child;
+  final VoidCallback onTap;
+
+  @override
+  State<_ScaleButton> createState() => _ScaleButtonState();
+}
+
+class _ScaleButtonState extends State<_ScaleButton> {
+  var _pressed = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      behavior: HitTestBehavior.translucent,
+      onTapDown: (_) => setState(() => _pressed = true),
+      onTapUp: (_) {
+        setState(() => _pressed = false);
+        widget.onTap();
+      },
+      onTapCancel: () => setState(() => _pressed = false),
+      child: AnimatedScale(
+        scale: _pressed ? 0.96 : 1.0,
+        duration: const Duration(milliseconds: 120),
+        curve: Curves.easeOutCubic,
+        child: widget.child,
+      ),
+    );
+  }
+}

+ 52 - 0
flutter_asr_client/lib/pages/recording/widgets/conversation_list.dart

@@ -0,0 +1,52 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/conversation.dart';
+import 'package:asr_client/pages/recording/widgets/ai_response_card.dart';
+import 'package:asr_client/pages/recording/widgets/user_bubble.dart';
+
+class ConversationList extends StatelessWidget {
+  const ConversationList({super.key, required this.items});
+
+  final List<ConversationItem> items;
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      children: items.map((item) {
+        return Padding(
+          padding: const EdgeInsets.only(bottom: 10),
+          child: AnimatedSwitcher(
+            duration: const Duration(milliseconds: 300),
+            transitionBuilder: (child, animation) {
+              return FadeTransition(
+                opacity: animation,
+                child: SlideTransition(
+                  position:
+                      Tween<Offset>(
+                        begin: const Offset(0, 0.25),
+                        end: Offset.zero,
+                      ).animate(
+                        CurvedAnimation(
+                          parent: animation,
+                          curve: Curves.easeOutCubic,
+                        ),
+                      ),
+                  child: child,
+                ),
+              );
+            },
+            child: switch (item.type) {
+              ConversationItemType.userBubble => UserBubble(
+                key: ValueKey(item.id),
+                item: item,
+              ),
+              ConversationItemType.aiCard => AiResponseCard(
+                key: ValueKey(item.id),
+                item: item,
+              ),
+            },
+          ),
+        );
+      }).toList(),
+    );
+  }
+}

+ 52 - 0
flutter_asr_client/lib/pages/recording/widgets/field_grid.dart

@@ -0,0 +1,52 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/conversation.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class FieldGrid extends StatelessWidget {
+  const FieldGrid({super.key, required this.fields});
+
+  final List<AiField> fields;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.only(top: 4, bottom: 4),
+      child: Column(
+        children: fields
+            .map(
+              (field) => Padding(
+                padding: const EdgeInsets.symmetric(vertical: 2),
+                child: Row(
+                  children: [
+                    SizedBox(
+                      width: 72,
+                      child: Text(
+                        field.key,
+                        style: const TextStyle(
+                          fontSize: 11,
+                          color: AppColors.ink3,
+                          fontFamily: 'monospace',
+                        ),
+                      ),
+                    ),
+                    Expanded(
+                      child: Text(
+                        field.value,
+                        style: TextStyle(
+                          fontSize: 12.5,
+                          fontWeight: FontWeight.w700,
+                          color: field.highlight
+                              ? AppColors.warn
+                              : AppColors.ink,
+                        ),
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            )
+            .toList(),
+      ),
+    );
+  }
+}

+ 58 - 0
flutter_asr_client/lib/pages/recording/widgets/recording_header.dart

@@ -0,0 +1,58 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class RecordingHeader extends StatelessWidget {
+  const RecordingHeader({
+    super.key,
+    required this.projectName,
+    required this.taskName,
+    required this.templateName,
+  });
+
+  final String projectName;
+  final String taskName;
+  final String templateName;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: const EdgeInsets.all(16),
+      decoration: BoxDecoration(
+        borderRadius: BorderRadius.circular(16),
+        gradient: const LinearGradient(
+          colors: [AppColors.nav, AppColors.nav2],
+          begin: Alignment.topLeft,
+          end: Alignment.bottomRight,
+        ),
+      ),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Text(
+            projectName,
+            style: const TextStyle(
+              fontSize: 11,
+              color: Color(0xFF7DB4E6),
+              fontFamily: 'monospace',
+              letterSpacing: 0.1,
+            ),
+          ),
+          const SizedBox(height: 4),
+          Text(
+            taskName,
+            style: const TextStyle(
+              fontSize: 18,
+              fontWeight: FontWeight.w800,
+              color: Colors.white,
+            ),
+          ),
+          const SizedBox(height: 2),
+          Text(
+            templateName,
+            style: const TextStyle(fontSize: 12, color: Color(0xFFB8D3EA)),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 206 - 0
flutter_asr_client/lib/pages/recording/widgets/recording_status_bar.dart

@@ -0,0 +1,206 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class RecordingStatusBar extends StatelessWidget {
+  const RecordingStatusBar({
+    super.key,
+    required this.isRecording,
+    required this.elapsed,
+    required this.audioLevel,
+  });
+
+  final bool isRecording;
+  final Duration elapsed;
+  final double audioLevel;
+
+  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) {
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+      decoration: BoxDecoration(
+        color: AppColors.card,
+        borderRadius: BorderRadius.circular(14),
+        border: Border.all(color: AppColors.line),
+      ),
+      child: Row(
+        children: [
+          _PulsingDot(isRecording: isRecording),
+          const SizedBox(width: 10),
+          Text(
+            _timerText,
+            style: const TextStyle(
+              fontSize: 22,
+              fontWeight: FontWeight.w700,
+              fontFamily: 'monospace',
+              letterSpacing: 1,
+              color: AppColors.ink,
+            ),
+          ),
+          const Spacer(),
+          _Waveform(audioLevel: audioLevel),
+        ],
+      ),
+    );
+  }
+}
+
+class _PulsingDot extends StatefulWidget {
+  const _PulsingDot({required this.isRecording});
+
+  final bool isRecording;
+
+  @override
+  State<_PulsingDot> createState() => _PulsingDotState();
+}
+
+class _PulsingDotState extends State<_PulsingDot>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+  late final Animation<double> _animation;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      duration: const Duration(milliseconds: 1400),
+      vsync: this,
+    );
+    _animation = Tween<double>(
+      begin: 0.5,
+      end: 1.0,
+    ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
+    if (widget.isRecording) {
+      _controller.repeat();
+    }
+  }
+
+  @override
+  void didUpdateWidget(covariant _PulsingDot oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.isRecording && !oldWidget.isRecording) {
+      _controller.repeat();
+    } else if (!widget.isRecording && oldWidget.isRecording) {
+      _controller.stop();
+    }
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: _animation,
+      builder: (context, child) {
+        return Container(
+          width: 12,
+          height: 12,
+          decoration: BoxDecoration(
+            shape: BoxShape.circle,
+            color: AppColors.danger,
+            boxShadow: [
+              BoxShadow(
+                color: AppColors.danger.withValues(
+                  alpha: widget.isRecording ? 0.3 * _animation.value : 0,
+                ),
+                blurRadius: 12 * _animation.value,
+                spreadRadius: 4 * _animation.value,
+              ),
+            ],
+          ),
+        );
+      },
+    );
+  }
+}
+
+class _Waveform extends StatefulWidget {
+  const _Waveform({required this.audioLevel});
+
+  final double audioLevel;
+
+  @override
+  State<_Waveform> createState() => _WaveformState();
+}
+
+class _WaveformState extends State<_Waveform>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+
+  static const _baseHeights = <double>[8, 18, 12, 22, 10, 16, 7];
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      duration: const Duration(milliseconds: 1200),
+      vsync: this,
+    )..repeat();
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: _controller,
+      builder: (context, child) {
+        return Row(
+          children: List.generate(_baseHeights.length, (index) {
+            final phase = _controller.value * 2 * math.pi + index * 0.8;
+            final wave = math.sin(phase) * 4;
+            final targetHeight =
+                _baseHeights[index] + widget.audioLevel * 14 + wave;
+            final clamped = targetHeight.clamp(4.0, 34.0);
+            return _WaveBar(height: clamped);
+          }),
+        );
+      },
+    );
+  }
+}
+
+class _WaveBar extends StatelessWidget {
+  const _WaveBar({required this.height});
+
+  final double height;
+
+  @override
+  Widget build(BuildContext context) {
+    const maxHeight = 34.0;
+    final scale = height / maxHeight;
+    return Container(
+      width: 3,
+      height: maxHeight,
+      margin: const EdgeInsets.symmetric(horizontal: 1),
+      alignment: Alignment.bottomCenter,
+      child: Transform.scale(
+        scaleY: scale,
+        alignment: Alignment.bottomCenter,
+        child: Container(
+          width: 3,
+          height: maxHeight,
+          decoration: BoxDecoration(
+            color: AppColors.app,
+            borderRadius: BorderRadius.circular(2),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 65 - 0
flutter_asr_client/lib/pages/recording/widgets/user_bubble.dart

@@ -0,0 +1,65 @@
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:asr_client/models/conversation.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class UserBubble extends StatelessWidget {
+  const UserBubble({super.key, required this.item});
+
+  final ConversationItem item;
+
+  @override
+  Widget build(BuildContext context) {
+    final time = DateFormat('HH:mm').format(item.timestamp);
+
+    return Align(
+      alignment: Alignment.centerRight,
+      child: ConstrainedBox(
+        constraints: BoxConstraints(
+          maxWidth: MediaQuery.of(context).size.width * 0.78,
+        ),
+        child: Container(
+          padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
+          decoration: BoxDecoration(
+            color: AppColors.app,
+            borderRadius: const BorderRadius.only(
+              topLeft: Radius.circular(16),
+              topRight: Radius.circular(16),
+              bottomLeft: Radius.circular(16),
+              bottomRight: Radius.circular(4),
+            ),
+            boxShadow: [
+              BoxShadow(
+                color: AppColors.app.withValues(alpha: 0.4),
+                blurRadius: 8,
+                offset: const Offset(0, 2),
+              ),
+            ],
+          ),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.end,
+            children: [
+              Text(
+                item.text ?? '',
+                style: const TextStyle(
+                  fontSize: 13.5,
+                  color: Colors.white,
+                  height: 1.4,
+                ),
+              ),
+              const SizedBox(height: 3),
+              Text(
+                time,
+                style: const TextStyle(
+                  fontSize: 10,
+                  color: Color(0xFFCFE4F6),
+                  fontFamily: 'monospace',
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 31 - 0
flutter_asr_client/lib/pages/recording/widgets/verdict_badge.dart

@@ -0,0 +1,31 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/conversation.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class VerdictBadge extends StatelessWidget {
+  const VerdictBadge({super.key, required this.verdict, required this.status});
+
+  final String verdict;
+  final AiVerdictStatus status;
+
+  @override
+  Widget build(BuildContext context) {
+    final (bg, fg) = switch (status) {
+      AiVerdictStatus.ok => (AppColors.okBg, AppColors.ok),
+      AiVerdictStatus.warn => (AppColors.dangerBg, AppColors.danger),
+      AiVerdictStatus.neutral => (AppColors.bg, AppColors.ink3),
+    };
+
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
+      decoration: BoxDecoration(
+        color: bg,
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: Text(
+        verdict,
+        style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: fg),
+      ),
+    );
+  }
+}

+ 167 - 0
flutter_asr_client/lib/pages/shell/app_shell.dart

@@ -0,0 +1,167 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class AppShell extends StatelessWidget {
+  const AppShell({super.key, required this.navigationShell});
+
+  final StatefulNavigationShell navigationShell;
+
+  int get _currentIndex => navigationShell.currentIndex;
+
+  void _onTap(BuildContext context, int index) {
+    if (index == 2) {
+      // FAB — navigate to recording page
+      GoRouter.of(context).go('/recording');
+      return;
+    }
+    // Adjust index: tab 0=tasks, 1=records, 2=FAB(skip), 3=reports→2, 4=profile→3
+    final adjustedIndex = index > 2 ? index - 1 : index;
+    navigationShell.goBranch(
+      adjustedIndex,
+      initialLocation: adjustedIndex == navigationShell.currentIndex,
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: navigationShell,
+      bottomNavigationBar: _BottomNavBar(
+        currentIndex: _currentIndex,
+        onTap: (index) => _onTap(context, index),
+      ),
+    );
+  }
+}
+
+class _BottomNavBar extends StatelessWidget {
+  const _BottomNavBar({required this.currentIndex, required this.onTap});
+
+  final int currentIndex;
+  final ValueChanged<int> onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      decoration: const BoxDecoration(
+        color: AppColors.card,
+        border: Border(top: BorderSide(color: AppColors.line)),
+      ),
+      padding: const EdgeInsets.only(bottom: 6),
+      child: SizedBox(
+        height: 64,
+        child: Row(
+          children: [
+            _TabItem(
+              icon: Icons.task_alt,
+              label: '任务',
+              isSelected: currentIndex == 0,
+              onTap: () => onTap(0),
+            ),
+            _TabItem(
+              icon: Icons.folder_open,
+              label: '记录',
+              isSelected: currentIndex == 1,
+              onTap: () => onTap(1),
+            ),
+            _FabButton(onTap: () => onTap(2)),
+            _TabItem(
+              icon: Icons.bar_chart,
+              label: '报告',
+              isSelected: currentIndex == 3,
+              onTap: () => onTap(3),
+            ),
+            _TabItem(
+              icon: Icons.person,
+              label: '我的',
+              isSelected: currentIndex == 4,
+              onTap: () => onTap(4),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class _TabItem extends StatelessWidget {
+  const _TabItem({
+    required this.icon,
+    required this.label,
+    required this.isSelected,
+    required this.onTap,
+  });
+
+  final IconData icon;
+  final String label;
+  final bool isSelected;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    return Expanded(
+      child: InkWell(
+        onTap: onTap,
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            Icon(
+              icon,
+              size: 20,
+              color: isSelected ? AppColors.app : AppColors.ink3,
+            ),
+            const SizedBox(height: 3),
+            Text(
+              label,
+              style: TextStyle(
+                fontSize: 10.5,
+                fontWeight: FontWeight.w600,
+                color: isSelected ? AppColors.app : AppColors.ink3,
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class _FabButton extends StatelessWidget {
+  const _FabButton({required this.onTap});
+
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    return Expanded(
+      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: const Icon(Icons.add, color: Colors.white, size: 24),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 118 - 0
flutter_asr_client/lib/pages/tasks/tasks_page.dart

@@ -0,0 +1,118 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:asr_client/pages/tasks/widgets/section_header.dart';
+import 'package:asr_client/pages/tasks/widgets/task_card.dart';
+import 'package:asr_client/providers/task_providers.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class TasksPage extends ConsumerWidget {
+  const TasksPage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final sections = ref.watch(taskSectionsProvider);
+
+    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,
+        actions: [
+          IconButton(
+            icon: const Icon(Icons.search, color: AppColors.ink2),
+            onPressed: () {},
+          ),
+        ],
+      ),
+      body: ListView.builder(
+        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
+        itemCount: sections.length * 2,
+        itemBuilder: (context, index) {
+          final sectionIndex = index ~/ 2;
+          final section = sections[sectionIndex];
+          if (index.isEven) {
+            return SectionHeader(title: section.title, action: section.action);
+          }
+          return Column(
+            children: section.tasks.asMap().entries.map((entry) {
+              final cardIndex = index + entry.key;
+              return _SlideFadeIn(
+                key: ValueKey(entry.value.id),
+                index: cardIndex,
+                child: TaskCard(task: entry.value),
+              );
+            }).toList(),
+          );
+        },
+      ),
+      floatingActionButton: FloatingActionButton.extended(
+        onPressed: () => context.go('/recording'),
+        backgroundColor: AppColors.app,
+        icon: const Icon(Icons.mic, color: Colors.white),
+        label: const Text(
+          '新建检测',
+          style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700),
+        ),
+      ),
+    );
+  }
+}
+
+class _SlideFadeIn extends StatefulWidget {
+  const _SlideFadeIn({super.key, required this.child, required this.index});
+
+  final Widget child;
+  final int index;
+
+  @override
+  State<_SlideFadeIn> createState() => _SlideFadeInState();
+}
+
+class _SlideFadeInState extends State<_SlideFadeIn>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+  late final Animation<double> _opacity;
+  late final Animation<Offset> _offset;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      duration: const Duration(milliseconds: 350),
+      vsync: this,
+    );
+    final delay = Duration(milliseconds: (widget.index * 40).clamp(0, 400));
+    _opacity = Tween<double>(
+      begin: 0,
+      end: 1,
+    ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
+    _offset = Tween<Offset>(
+      begin: const Offset(0, 0.15),
+      end: Offset.zero,
+    ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
+    Future.delayed(delay, () {
+      if (mounted) _controller.forward();
+    });
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return FadeTransition(
+      opacity: _opacity,
+      child: SlideTransition(position: _offset, child: widget.child),
+    );
+  }
+}

+ 38 - 0
flutter_asr_client/lib/pages/tasks/widgets/section_header.dart

@@ -0,0 +1,38 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class SectionHeader extends StatelessWidget {
+  const SectionHeader({super.key, required this.title, this.action});
+
+  final String title;
+  final String? action;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
+      child: Row(
+        children: [
+          Text(
+            title,
+            style: const TextStyle(
+              fontSize: 13,
+              fontWeight: FontWeight.w800,
+              color: AppColors.ink,
+            ),
+          ),
+          const Spacer(),
+          if (action != null)
+            Text(
+              action!,
+              style: const TextStyle(
+                fontSize: 12,
+                color: AppColors.app,
+                fontWeight: FontWeight.w700,
+              ),
+            ),
+        ],
+      ),
+    );
+  }
+}

+ 111 - 0
flutter_asr_client/lib/pages/tasks/widgets/task_card.dart

@@ -0,0 +1,111 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:asr_client/models/task.dart';
+import 'package:asr_client/pages/tasks/widgets/task_status_chip.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class TaskCard extends StatelessWidget {
+  const TaskCard({super.key, required this.task});
+
+  final Task task;
+
+  @override
+  Widget build(BuildContext context) {
+    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: InkWell(
+        onTap: () => context.go('/recording'),
+        borderRadius: BorderRadius.circular(14),
+        child: Padding(
+          padding: const EdgeInsets.all(14),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Row(
+                children: [
+                  TaskStatusChip(status: task.status),
+                  const Spacer(),
+                  if (task.timeInfo != null)
+                    Text(
+                      task.timeInfo!,
+                      style: const TextStyle(
+                        fontSize: 11,
+                        color: AppColors.ink3,
+                        fontFamily: 'monospace',
+                      ),
+                    ),
+                ],
+              ),
+              const SizedBox(height: 9),
+              Text(
+                task.title,
+                style: const TextStyle(
+                  fontSize: 15,
+                  fontWeight: FontWeight.w700,
+                  color: AppColors.ink,
+                ),
+              ),
+              const SizedBox(height: 3),
+              Text(
+                task.project,
+                style: const TextStyle(fontSize: 12, color: AppColors.ink2),
+              ),
+              if (task.status == TaskStatus.recording &&
+                  task.progress != null) ...[
+                const SizedBox(height: 12),
+                _ProgressBar(progress: task.progress!),
+              ],
+              if (task.footNote != null) ...[
+                const SizedBox(height: 9),
+                Text(
+                  task.footNote!,
+                  style: const TextStyle(fontSize: 11, color: AppColors.ink3),
+                ),
+              ],
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+class _ProgressBar extends StatelessWidget {
+  const _ProgressBar({required this.progress});
+
+  final double progress;
+
+  @override
+  Widget build(BuildContext context) {
+    final clamped = progress.clamp(0.0, 1.0);
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        ClipRRect(
+          borderRadius: BorderRadius.circular(4),
+          child: LinearProgressIndicator(
+            value: clamped,
+            backgroundColor: AppColors.line,
+            valueColor: const AlwaysStoppedAnimation<Color>(AppColors.app),
+            minHeight: 6,
+          ),
+        ),
+        const SizedBox(height: 5),
+        Text(
+          '${(clamped * 100).toInt()}%',
+          style: const TextStyle(
+            fontSize: 10.5,
+            color: AppColors.appDark,
+            fontWeight: FontWeight.w700,
+          ),
+        ),
+      ],
+    );
+  }
+}

+ 36 - 0
flutter_asr_client/lib/pages/tasks/widgets/task_status_chip.dart

@@ -0,0 +1,36 @@
+import 'package:flutter/material.dart';
+import 'package:asr_client/models/task.dart';
+import 'package:asr_client/theme/app_colors.dart';
+
+class TaskStatusChip extends StatelessWidget {
+  const TaskStatusChip({super.key, required this.status});
+
+  final TaskStatus status;
+
+  @override
+  Widget build(BuildContext context) {
+    final (label, bg, fg) = switch (status) {
+      TaskStatus.recording => ('录制中', AppColors.appBg, AppColors.appDark),
+      TaskStatus.pending => ('待检测', AppColors.warnBg, const Color(0xFF8A6D00)),
+      TaskStatus.scheduled => ('已排期', AppColors.midBg, const Color(0xFF1C7A6D)),
+      TaskStatus.completed => ('已完成', AppColors.okBg, AppColors.ok),
+      TaskStatus.failed => ('异常', AppColors.dangerBg, AppColors.danger),
+    };
+
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
+      decoration: BoxDecoration(
+        color: bg,
+        borderRadius: BorderRadius.circular(6),
+      ),
+      child: Text(
+        label,
+        style: TextStyle(
+          fontSize: 10.5,
+          fontWeight: FontWeight.w700,
+          color: fg,
+        ),
+      ),
+    );
+  }
+}

+ 16 - 0
flutter_asr_client/lib/providers/audio_providers.dart

@@ -0,0 +1,16 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/services/audio_capture_service.dart';
+
+final audioCaptureServiceProvider = Provider<AudioCaptureService>(
+  (_) => AudioCaptureServiceImpl(),
+);
+
+final audioLevelProvider = StreamProvider<double>((ref) {
+  final service = ref.watch(audioCaptureServiceProvider);
+  ref.onDispose(() {
+    if (service is AudioCaptureServiceImpl) {
+      service.dispose();
+    }
+  });
+  return service.audioLevelStream;
+});

+ 11 - 0
flutter_asr_client/lib/providers/profile_providers.dart

@@ -0,0 +1,11 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/services/mock_data_service.dart';
+import 'package:asr_client/models/user_profile.dart';
+
+final userProfileProvider = Provider<UserProfile>(
+  (_) => MockDataService.userProfile,
+);
+
+final settingsSectionsProvider = Provider<List<SettingsSection>>(
+  (_) => MockDataService.settingsSections(null),
+);

+ 45 - 0
flutter_asr_client/lib/providers/report_providers.dart

@@ -0,0 +1,45 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:asr_client/services/mock_data_service.dart';
+import 'package:asr_client/models/report.dart';
+
+final reportGroupsProvider = Provider<List<ReportDateGroup>>(
+  (_) => MockDataService.reportGroups,
+);
+
+final reportFilterProvider = StateProvider<ReportFilter>(
+  (_) => ReportFilter.all,
+);
+
+enum ReportFilter { all, ok, warn, draft }
+
+final filteredReportsProvider = Provider<List<ReportDateGroup>>((ref) {
+  final groups = ref.watch(reportGroupsProvider);
+  final filter = ref.watch(reportFilterProvider);
+
+  if (filter == ReportFilter.all) return groups;
+
+  return groups
+      .map(
+        (group) => ReportDateGroup(
+          label: group.label,
+          reports: group.reports
+              .where((report) => _matchesFilter(report.status, filter))
+              .toList(),
+        ),
+      )
+      .where((group) => group.reports.isNotEmpty)
+      .toList();
+});
+
+bool _matchesFilter(ReportStatus status, ReportFilter filter) {
+  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;
+  }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов