diff --git a/.github/actions/flutter-prepare/action.yml b/.github/actions/flutter-prepare/action.yml
new file mode 100644
index 00000000..f40b9d5a
--- /dev/null
+++ b/.github/actions/flutter-prepare/action.yml
@@ -0,0 +1,27 @@
+name: Flutter Prepare
+description: Set up Flutter from .flutter_version and fetch dependencies.
+
+inputs:
+ app-dir:
+ description: Path to the Flutter application directory.
+ required: true
+
+runs:
+ using: composite
+ steps:
+ - name: Read Flutter version
+ id: flutter_version
+ shell: bash
+ run: echo "version=$(cat ${{ inputs.app-dir }}/.flutter_version)" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ flutter-version: ${{ steps.flutter_version.outputs.version }}
+ cache: true
+
+ - name: Install dependencies
+ shell: bash
+ working-directory: ${{ inputs.app-dir }}
+ run: flutter pub get
diff --git a/.github/workflows/pr_build_all_platforms.yml b/.github/workflows/pr_build_all_platforms.yml
new file mode 100644
index 00000000..18215c22
--- /dev/null
+++ b/.github/workflows/pr_build_all_platforms.yml
@@ -0,0 +1,213 @@
+name: Build PR Artifacts
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - ready_for_review
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: pr-build-artifacts-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ APP_DIR: open_wearable
+
+jobs:
+ build_android:
+ name: Build Android
+ runs-on: ubuntu-latest
+ outputs:
+ artifact_id: ${{ steps.upload_android.outputs.artifact-id }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up Java
+ uses: actions/setup-java@v5
+ with:
+ distribution: zulu
+ java-version: "17"
+ cache: gradle
+
+ - name: Set up Flutter project
+ uses: ./.github/actions/flutter-prepare
+ with:
+ app-dir: ${{ env.APP_DIR }}
+
+ - name: Build Android APK
+ working-directory: ${{ env.APP_DIR }}
+ run: flutter build apk --release
+
+ - name: Upload Android APK
+ id: upload_android
+ uses: actions/upload-artifact@v6
+ with:
+ name: android-apk
+ path: open_wearable/build/app/outputs/flutter-apk/app-release.apk
+ if-no-files-found: error
+
+ build_linux:
+ name: Build Linux
+ runs-on: ubuntu-latest
+ outputs:
+ artifact_id: ${{ steps.upload_linux.outputs.artifact-id }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Install Linux build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ clang \
+ cmake \
+ libgtk-3-dev \
+ ninja-build \
+ pkg-config
+
+ - name: Set up Flutter project
+ uses: ./.github/actions/flutter-prepare
+ with:
+ app-dir: ${{ env.APP_DIR }}
+
+ - name: Build Linux bundle
+ working-directory: ${{ env.APP_DIR }}
+ run: flutter build linux --release
+
+ - name: Upload Linux bundle
+ id: upload_linux
+ uses: actions/upload-artifact@v6
+ with:
+ name: linux-bundle
+ path: open_wearable/build/linux/x64/release/bundle
+ if-no-files-found: error
+
+ build_windows:
+ name: Build Windows
+ runs-on: windows-latest
+ outputs:
+ artifact_id: ${{ steps.upload_windows.outputs.artifact-id }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up Flutter project
+ uses: ./.github/actions/flutter-prepare
+ with:
+ app-dir: ${{ env.APP_DIR }}
+
+ - name: Build Windows runner
+ working-directory: ${{ env.APP_DIR }}
+ run: flutter build windows --release
+
+ - name: Upload Windows runner
+ id: upload_windows
+ uses: actions/upload-artifact@v6
+ with:
+ name: windows-runner
+ path: open_wearable/build/windows/x64/runner/Release
+ if-no-files-found: error
+
+ build_web:
+ name: Build Web
+ runs-on: ubuntu-latest
+ outputs:
+ artifact_id: ${{ steps.upload_web.outputs.artifact-id }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up Flutter project
+ uses: ./.github/actions/flutter-prepare
+ with:
+ app-dir: ${{ env.APP_DIR }}
+
+ - name: Build Web bundle
+ working-directory: ${{ env.APP_DIR }}
+ run: flutter build web --release
+
+ - name: Upload Web bundle
+ id: upload_web
+ uses: actions/upload-artifact@v6
+ with:
+ name: web-bundle
+ path: open_wearable/build/web
+ if-no-files-found: error
+
+ comment_artifact_links:
+ name: Comment Artifact Links
+ runs-on: ubuntu-latest
+ needs:
+ - build_android
+ - build_linux
+ - build_windows
+ - build_web
+ if: always()
+
+ steps:
+ - name: Post or update PR comment with artifact links
+ uses: actions/github-script@v8
+ env:
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ ANDROID_ARTIFACT_ID: ${{ needs.build_android.outputs.artifact_id }}
+ LINUX_ARTIFACT_ID: ${{ needs.build_linux.outputs.artifact_id }}
+ WINDOWS_ARTIFACT_ID: ${{ needs.build_windows.outputs.artifact_id }}
+ WEB_ARTIFACT_ID: ${{ needs.build_web.outputs.artifact_id }}
+ with:
+ script: |
+ const marker = '';
+ const prNumber = context.payload.pull_request.number;
+ const runUrl = process.env.RUN_URL;
+ const repoUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}`;
+ const linkFor = (id) => id
+ ? `${repoUrl}/actions/runs/${context.runId}/artifacts/${id}`
+ : runUrl;
+
+ const body = `${marker}
+ ### PR Build Artifacts
+ - Android APK: [Download Android build](${linkFor(process.env.ANDROID_ARTIFACT_ID)})
+ - Linux Bundle: [Download Linux build](${linkFor(process.env.LINUX_ARTIFACT_ID)})
+ - Windows Runner: [Download Windows build](${linkFor(process.env.WINDOWS_ARTIFACT_ID)})
+ - Web Bundle: [Download Web build](${linkFor(process.env.WEB_ARTIFACT_ID)})
+
+ Full workflow run: ${runUrl}`;
+
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ });
+
+ const previous = comments.find(
+ (comment) =>
+ comment.user.type === 'Bot' &&
+ comment.body &&
+ comment.body.includes(marker)
+ );
+
+ if (previous) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: previous.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ body,
+ });
+ }
diff --git a/open_wearable/README.md b/open_wearable/README.md
index fc7cdbe9..653509be 100644
--- a/open_wearable/README.md
+++ b/open_wearable/README.md
@@ -1,16 +1,28 @@
-# open_wearable
+# OpenWearable App Module
-A new Flutter project.
+Flutter application module for the OpenEarable app.
-## Getting Started
+## Documentation
-This project is a starting point for a Flutter application.
+High-level architecture and state-management docs live in [`docs/`](./docs/README.md).
-A few resources to get you started if this is your first Flutter project:
+- [App Setup and Architecture](./docs/app-setup.md)
+- [State and Providers](./docs/state-and-providers.md)
-- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
-- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
+## Development Quick Start
-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.
+1. Install Flutter (stable channel).
+2. From this folder (`open_wearable/`), fetch dependencies:
+ ```bash
+ flutter pub get
+ ```
+3. Run on a connected device/emulator:
+ ```bash
+ flutter run
+ ```
+
+## Notes
+
+- Core app bootstrap is in `lib/main.dart`.
+- Route definitions are in `lib/router.dart`.
+- High-level feature state is primarily under `lib/view_models/`.
diff --git a/open_wearable/android/app/src/main/AndroidManifest.xml b/open_wearable/android/app/src/main/AndroidManifest.xml
index 8cb19723..b7029ab2 100644
--- a/open_wearable/android/app/src/main/AndroidManifest.xml
+++ b/open_wearable/android/app/src/main/AndroidManifest.xml
@@ -40,6 +40,8 @@
+
+
diff --git a/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt b/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt
index 90d484a2..71435e35 100644
--- a/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt
+++ b/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt
@@ -1,5 +1,36 @@
package edu.kit.teco.openWearable
+import android.content.Intent
+import android.provider.Settings
import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
-class MainActivity: FlutterActivity()
+class MainActivity : FlutterActivity() {
+ companion object {
+ private const val SYSTEM_SETTINGS_CHANNEL = "edu.kit.teco.open_wearable/system_settings"
+ }
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+
+ MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ SYSTEM_SETTINGS_CHANNEL,
+ ).setMethodCallHandler { call, result ->
+ if (call.method == "openBluetoothSettings") {
+ try {
+ val intent = Intent(Settings.ACTION_BLUETOOTH_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ startActivity(intent)
+ result.success(true)
+ } catch (_: Exception) {
+ result.success(false)
+ }
+ } else {
+ result.notImplemented()
+ }
+ }
+ }
+}
diff --git a/open_wearable/docs/README.md b/open_wearable/docs/README.md
new file mode 100644
index 00000000..ab3a6fb9
--- /dev/null
+++ b/open_wearable/docs/README.md
@@ -0,0 +1,21 @@
+# OpenWearable App Documentation
+
+This folder contains high-level documentation for the Flutter app in `open_wearable/`.
+
+## Documents
+
+- [App Setup and Architecture](./app-setup.md)
+ - Startup flow, dependency initialization, routing, UI shell, and lifecycle handling.
+- [State and Providers](./state-and-providers.md)
+ - How top-level providers are wired, how per-device providers are created, and how data flows from wearables to UI.
+- [Page Documentation](./pages/README.md)
+ - Detailed page-by-page contracts: needs, behavior, and outputs.
+
+## Suggested Reading Order
+
+1. [App Setup and Architecture](./app-setup.md)
+2. [State and Providers](./state-and-providers.md)
+
+## Scope
+
+These docs are intentionally focused on app-level behavior and state handling. They are not API references for `open_earable_flutter`.
diff --git a/open_wearable/docs/app-setup.md b/open_wearable/docs/app-setup.md
new file mode 100644
index 00000000..67576794
--- /dev/null
+++ b/open_wearable/docs/app-setup.md
@@ -0,0 +1,125 @@
+# App Setup and Architecture
+
+This document describes the high-level setup of the OpenWearable Flutter app (`open_wearable/`), including startup, routing, shell layout, and lifecycle behavior.
+
+## 1. Runtime Entry Point
+
+Main entry point: `lib/main.dart`
+
+Startup sequence:
+
+1. `WidgetsFlutterBinding.ensureInitialized()`
+2. Create `LogFileManager` and initialize both app and library loggers.
+3. Initialize persisted settings:
+ - `AutoConnectPreferences.initialize()`
+ - `AppShutdownSettings.initialize()`
+4. Start app with `MultiProvider`.
+
+The app wraps all screens in a single provider tree so global services/state are available everywhere.
+
+## 2. Top-Level Provider Tree
+
+Created in `main.dart`:
+
+- `WearablesProvider` (`ChangeNotifierProvider`)
+- `FirmwareUpdateRequestProvider` (`ChangeNotifierProvider`)
+- `SensorRecorderProvider` (`ChangeNotifierProvider`)
+- `WearableConnector` (`Provider.value`)
+- `AppBannerController` (`ChangeNotifierProvider`)
+- `LogFileManager` (`ChangeNotifierProvider.value`)
+
+`MyApp` is stateful and subscribes to provider streams once, then orchestrates global side effects (dialogs, toasts, banners, lifecycle reactions).
+
+## 3. App Shell and Navigation
+
+Router config: `lib/router.dart`
+
+- Router uses `GoRouter` with a global `rootNavigatorKey`.
+- `HomePage` is mounted at `/` and receives optional section query parameter (`?tab=`).
+- Primary routes:
+ - `/` home shell
+ - `/connect-devices`
+ - `/device-detail`
+ - `/log-files`
+ - `/recordings`
+ - `/settings/general`
+ - `/fota` and `/fota/update`
+
+### FOTA route guard
+
+`/fota` redirects back to `/?tab=devices` on unsupported platforms and shows a platform dialog explaining the restriction.
+
+## 4. Home Layout Structure
+
+Main shell: `lib/widgets/home_page.dart`
+
+Top-level sections:
+
+1. Overview
+2. Devices
+3. Sensors
+4. Apps
+5. Settings
+
+Behavior:
+
+- Compact screens use `PlatformTabScaffold` with bottom navigation.
+- Large screens use a `NavigationRail` + `IndexedStack` layout.
+- `SensorPageController` lets other sections deep-link into specific tabs inside the Sensors page.
+
+## 5. Lifecycle and Background Behavior
+
+Handled centrally in `_MyAppState` (`main.dart`) via `WidgetsBindingObserver`.
+
+Key behaviors:
+
+- Auto-connect is stopped on pause and resumed on app resume depending on user setting.
+- If "shut off all sensors on app close" is enabled, a grace-period timer is started when app goes inactive/paused.
+- Background execution window is managed via `AppBackgroundExecutionBridge` while shutdown or recording protection is needed.
+- If sensor shutdown completed while app was backgrounded, open app-flow screens are popped back to root on resume.
+
+## 6. Connection and Event Handling
+
+Two layers are used:
+
+- `WearableConnector` (`lib/models/wearable_connector.dart`)
+ - Direct connection API and event stream for connect/disconnect events.
+- `BluetoothAutoConnector` (`lib/models/bluetooth_auto_connector.dart`)
+ - Reconnect workflow based on remembered device names and user preference.
+
+`MyApp` subscribes to connector/provider event streams to:
+
+- Add wearables to global providers.
+- Show firmware dialogs.
+- Show app banners/toasts for important runtime events.
+
+## 7. Feature Module Layout
+
+High-level code organization under `lib/`:
+
+- `widgets/`: shared UI and top-level pages.
+- `view_models/`: provider-backed state and orchestration logic.
+- `models/`: persistence, app-level settings, connection helpers, logging helpers.
+- `apps/`: feature mini-apps (e.g., posture tracker, heart tracker).
+- `theme/`: theming.
+- `router.dart`: route table.
+- `main.dart`: bootstrap and global lifecycle/event orchestration.
+
+## 8. Persistence and Local State
+
+Persisted settings/data include:
+
+- Auto-connect preference and remembered names (`AutoConnectPreferences` + `SharedPreferences`).
+- Shutdown/graph settings (`AppShutdownSettings` + `SharedPreferences`).
+- Sensor profiles/configuration JSON files (`SensorConfigurationStorage`).
+- Log files (`LogFileManager`).
+
+## 9. Typical Runtime Flow
+
+1. App starts and initializes settings/logging.
+2. Providers are created.
+3. Router builds Home shell.
+4. User connects devices manually or auto-connect restores previous devices.
+5. `WearablesProvider` initializes per-device sensor configuration state.
+6. Sensors/config pages consume provider state and update UI.
+7. Lifecycle transitions trigger shutdown/auto-connect policies.
diff --git a/open_wearable/docs/pages/README.md b/open_wearable/docs/pages/README.md
new file mode 100644
index 00000000..b9b24f15
--- /dev/null
+++ b/open_wearable/docs/pages/README.md
@@ -0,0 +1,16 @@
+# Page Documentation
+
+This section documents the app pages in a consistent format:
+
+- `Needs`: what must be available for the page to work correctly.
+- `Does`: core behavior and responsibilities.
+- `Provides`: what the page exposes to users/other flows (navigation outcomes, side effects, state updates).
+
+## Coverage
+
+- [Shell and Navigation Pages](./shell-and-navigation-pages.md)
+- [Device Pages](./device-pages.md)
+- [Sensor Pages](./sensor-pages.md)
+- [Settings and Logging Pages](./settings-and-logging-pages.md)
+- [Apps Pages](./apps-pages.md)
+- [Firmware Update (FOTA) Pages](./fota-pages.md)
diff --git a/open_wearable/docs/pages/apps-pages.md b/open_wearable/docs/pages/apps-pages.md
new file mode 100644
index 00000000..0b56b008
--- /dev/null
+++ b/open_wearable/docs/pages/apps-pages.md
@@ -0,0 +1,57 @@
+# Apps Pages
+
+## `AppsPage` (`lib/apps/widgets/apps_page.dart`)
+- Needs:
+ - `WearablesProvider` for connected wearable names.
+ - App catalog entries (`_apps`) with compatibility metadata.
+- Does:
+ - Computes enabled/disabled app tiles based on compatible connected devices.
+ - Renders app catalog and app-level status summary.
+- Provides:
+ - Launch entry point for mini-app experiences.
+
+## `SelectEarableView` (`lib/apps/widgets/select_earable_view.dart`)
+- Needs:
+ - Constructor inputs:
+ - `startApp(Wearable, SensorConfigurationProvider)` callback
+ - `supportedDevicePrefixes`
+ - `WearablesProvider` with sensor config providers for candidate devices.
+- Does:
+ - Filters connected wearables by app compatibility.
+ - Lets user select one wearable and launches app with scoped provider context.
+- Provides:
+ - Compatibility-safe wearable picker for app flows.
+
+## `HeartTrackerPage` (`lib/apps/heart_tracker/widgets/heart_tracker_page.dart`)
+- Needs:
+ - Constructor inputs:
+ - `wearable`
+ - `ppgSensor` (required)
+ - optional accelerometer and optical temperature sensors
+ - `SensorConfigurationProvider` in scope.
+- Does:
+ - Configures required sensor streaming settings on enter.
+ - Builds OpenRing-specific or generic PPG processing pipeline.
+ - Produces heart-rate/HRV/signal-quality streams for UI charts.
+ - Restores/turns off configured streaming settings on dispose (best effort).
+- Provides:
+ - Heart metrics visualization and quality feedback workflow.
+
+## `PostureTrackerView` (`lib/apps/posture_tracker/view/posture_tracker_view.dart`)
+- Needs:
+ - Constructor input: `AttitudeTracker` implementation.
+- Does:
+ - Creates and owns `PostureTrackerViewModel`.
+ - Displays live posture state, thresholds, and tracking controls.
+ - Opens app-specific settings page.
+- Provides:
+ - Posture tracking runtime view and control surface.
+
+## `SettingsView` (Posture) (`lib/apps/posture_tracker/view/settings_view.dart`)
+- Needs:
+ - Constructor input: existing `PostureTrackerViewModel`.
+- Does:
+ - Edits posture reminder thresholds and tracking behavior.
+ - Supports calibration action and tracking start.
+- Provides:
+ - App-specific posture tuning and calibration settings.
diff --git a/open_wearable/docs/pages/device-pages.md b/open_wearable/docs/pages/device-pages.md
new file mode 100644
index 00000000..ed091416
--- /dev/null
+++ b/open_wearable/docs/pages/device-pages.md
@@ -0,0 +1,40 @@
+# Device Pages
+
+## `DevicesPage` (`lib/widgets/devices/devices_page.dart`)
+- Needs:
+ - `WearablesProvider` with current `wearables` list.
+ - `WearableDisplayGroup` helpers for pair/single grouping and ordering.
+- Does:
+ - Shows connected wearables as list (small screens) or grid (large screens).
+ - Supports pull-to-refresh by attempting `connectToSystemDevices()`.
+ - Allows pair combine/split UI mode through `WearablesProvider` stereo pair state.
+- Provides:
+ - Main device inventory view.
+ - Entry into `DeviceDetailPage` and connect flow.
+
+## `ConnectDevicesPage` (`lib/widgets/devices/connect_devices_page.dart`)
+- Needs:
+ - `WearablesProvider` (to display connected devices and add new connections).
+ - `WearableConnector` provider for explicit connect actions.
+ - `WearableManager` scan/connect capabilities and runtime BLE permissions.
+- Does:
+ - Starts BLE scanning on page open and allows manual refresh/rescan.
+ - Displays connected and available devices separately.
+ - Connects selected devices and updates global wearable state.
+- Provides:
+ - Device discovery and connection workflow.
+ - Scan status and actionable connection UI.
+
+## `DeviceDetailPage` (`lib/widgets/devices/device_detail/device_detail_page.dart`)
+- Needs:
+ - Constructor input: `Wearable device`.
+ - `WearablesProvider` (for sensor shutdown/disconnect helper flow).
+ - `FirmwareUpdateRequestProvider` (for preselecting FOTA target).
+ - Device capabilities to unlock sections (`AudioModeManager`, `RgbLed`, `StatusLed`, `Battery*`, etc.).
+- Does:
+ - Shows detailed per-device controls and metadata.
+ - Handles disconnect flow and "forget" helper (system settings handoff).
+ - Prepares firmware update target and navigates to FOTA flow.
+- Provides:
+ - Capability-aware control surface for one wearable.
+ - Path from device details to firmware update workflow.
diff --git a/open_wearable/docs/pages/fota-pages.md b/open_wearable/docs/pages/fota-pages.md
new file mode 100644
index 00000000..a9036ee1
--- /dev/null
+++ b/open_wearable/docs/pages/fota-pages.md
@@ -0,0 +1,45 @@
+# Firmware Update (FOTA) Pages
+
+## `FotaWarningPage` (`lib/widgets/fota/fota_warning_page.dart`)
+- Needs:
+ - `FirmwareUpdateRequestProvider` with selected wearable.
+ - Battery capability on selected device for pre-check (`BatteryLevelStatus`) if available.
+- Does:
+ - Presents update risk checklist and recovery guidance link.
+ - Reads battery level and enforces warning/confirmation gates below threshold.
+ - Routes to `/fota/update` when user proceeds.
+- Provides:
+ - Safety gate before update execution.
+
+## `FirmwareUpdateWidget` (`lib/widgets/fota/firmware_update.dart`)
+- Needs:
+ - `FirmwareUpdateRequestProvider` in scope.
+ - Valid selected firmware for step 0 -> step 1 transition.
+- Does:
+ - Hosts two-step flow (select firmware, install firmware).
+ - Prevents back navigation while update is active.
+ - Creates `UpdateBloc` for install step.
+ - Caches wearable metadata (name/side label) for post-update verification UX.
+- Provides:
+ - Main firmware update execution page.
+
+## `UpdateStepView` (`lib/widgets/fota/stepper_view/update_view.dart`)
+- Needs:
+ - `UpdateBloc` and `FirmwareUpdateRequestProvider` in context.
+ - Optional callbacks for update running-state reporting.
+- Does:
+ - Starts update automatically when configured (`autoStart`).
+ - Renders update timeline/history and current stage.
+ - Arms and displays post-update verification banner on successful completion.
+ - Provides link to update logger view when available.
+- Provides:
+ - Detailed progress and outcome UI for the update process.
+
+## `LoggerScreen` (`lib/widgets/fota/logger_screen/logger_screen.dart`)
+- Needs:
+ - Constructor input: `FirmwareUpdateLogger logger`.
+- Does:
+ - Reads device-side MCU logs and filters by severity.
+ - Renders log list with color-coded levels.
+- Provides:
+ - Post-update diagnostic log visibility.
diff --git a/open_wearable/docs/pages/sensor-pages.md b/open_wearable/docs/pages/sensor-pages.md
new file mode 100644
index 00000000..a759f87e
--- /dev/null
+++ b/open_wearable/docs/pages/sensor-pages.md
@@ -0,0 +1,70 @@
+# Sensor Pages
+
+## `SensorPage` (`lib/widgets/sensors/sensor_page.dart`)
+- Needs:
+ - `WearablesProvider` for connected wearables and capability checks.
+ - Optional `SensorPageController` for external tab switching.
+- Does:
+ - Hosts three sensor tabs: Configure, Live Data, Recorder.
+ - Maintains shared `(Wearable, Sensor) -> SensorDataProvider` instances.
+ - Keeps providers in sync with wearable connect/disconnect events.
+- Provides:
+ - Main sensor workspace and tab navigation.
+ - Shared live-data provider map for child tabs.
+
+## `SensorConfigurationView` (`lib/widgets/sensors/configuration/sensor_configuration_view.dart`)
+- Needs:
+ - `WearablesProvider` and its per-device `SensorConfigurationProvider` instances.
+ - Wearables with `SensorConfigurationManager` capabilities.
+- Does:
+ - Renders configuration rows per wearable/group.
+ - Supports mirrored apply behavior for stereo pairs.
+ - Applies pending/unknown config entries via `config.setConfiguration(...)`.
+- Provides:
+ - Multi-device configuration dashboard.
+ - "Apply Profiles" action to push selected settings to hardware.
+
+## `SensorConfigurationDetailView` (`lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart`)
+- Needs:
+ - Constructor `sensorConfiguration`.
+ - `SensorConfigurationProvider` in scope.
+ - Optional paired config/provider for mirrored updates.
+- Does:
+ - Edits data target options and sampling rate for one configuration.
+ - Keeps local selected state in provider and can mirror to paired device config.
+- Provides:
+ - Fine-grained configuration editor UI.
+
+## `SensorValuesPage` (`lib/widgets/sensors/values/sensor_values_page.dart`)
+- Needs:
+ - `WearablesProvider` for device/sensor discovery.
+ - `SensorDataProvider` instances (shared map from parent or owned locally).
+ - App settings listenables from `AppShutdownSettings` for graph behavior.
+- Does:
+ - Builds sensor cards for all active sensors across wearables.
+ - Supports no-graph mode and hide-empty-graphs mode.
+ - Uses merged provider listenables for efficient live refresh.
+- Provides:
+ - Live chart/value surface for all connected sensors.
+
+## `LocalRecorderView` (`lib/widgets/sensors/local_recorder/local_recorder_view.dart`)
+- Needs:
+ - `SensorRecorderProvider` and `WearablesProvider`.
+ - File-system access helpers in `local_recorder_storage.dart`.
+- Does:
+ - Starts/stops recording sessions and tracks elapsed runtime.
+ - Offers optional "stop and turn off sensors" behavior.
+ - Lists most recent recording folder and supports file/folder actions.
+- Provides:
+ - In-tab recording control center.
+ - Shortcut navigation to full recording history.
+
+## `LocalRecorderAllRecordingsPage` (`lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart`)
+- Needs:
+ - Constructor input: `isRecording`.
+ - Recording storage helpers and file action helpers.
+- Does:
+ - Displays all recording folders with expansion and file actions.
+ - Supports batch selection, share, and delete flows.
+- Provides:
+ - Complete recording history management UI.
diff --git a/open_wearable/docs/pages/settings-and-logging-pages.md b/open_wearable/docs/pages/settings-and-logging-pages.md
new file mode 100644
index 00000000..9c809ec1
--- /dev/null
+++ b/open_wearable/docs/pages/settings-and-logging-pages.md
@@ -0,0 +1,62 @@
+# Settings and Logging Pages
+
+## `SettingsPage` (`lib/widgets/settings/settings_page.dart`)
+- Needs:
+ - Callbacks from shell:
+ - `onLogsRequested`
+ - `onConnectRequested`
+ - `onGeneralSettingsRequested`
+- Does:
+ - Shows top-level app settings entry points.
+ - Routes to general settings, logs, and about flow.
+- Provides:
+ - Central settings navigation hub.
+
+## `GeneralSettingsPage` (`lib/widgets/settings/general_settings_page.dart`)
+- Needs:
+ - Static settings backends initialized at app startup:
+ - `AutoConnectPreferences`
+ - `AppShutdownSettings`
+- Does:
+ - Binds switch controls to persisted app settings.
+ - Serializes save operations via `_isSaving` guard.
+- Provides:
+ - Runtime policy controls (auto-connect, shutdown behavior, live graph behavior).
+
+## `_AboutPage` (`lib/widgets/settings/settings_page.dart`)
+- Needs:
+ - Internet/external app availability for URL launches (`url_launcher`).
+- Does:
+ - Shows app/about/legal context and external links.
+ - Exposes entry into open-source license listing page.
+- Provides:
+ - Product attribution and legal discoverability.
+
+## `_OpenSourceLicensesPage` (`lib/widgets/settings/settings_page.dart`)
+- Needs:
+ - Flutter `LicenseRegistry` data.
+- Does:
+ - Loads and groups licenses by package.
+ - Displays per-package license details.
+- Provides:
+ - In-app third-party license compliance view.
+
+## `LogFilesScreen` (`lib/widgets/logging/log_files_screen.dart`)
+- Needs:
+ - `LogFileManager` provider.
+ - File share plugin support (`share_plus`).
+- Does:
+ - Lists collected log files and metadata.
+ - Supports share/delete per file and clear-all with confirmation.
+ - Navigates to log file detail viewer.
+- Provides:
+ - Diagnostic log management for users/developers.
+
+## `LogFileDetailScreen` (`lib/widgets/logging/log_file_detail_screen.dart`)
+- Needs:
+ - Constructor input: `File file`.
+- Does:
+ - Loads file contents once and renders scrollable/selectable text.
+ - Handles empty/error states for file reading.
+- Provides:
+ - Raw log content inspection view.
diff --git a/open_wearable/docs/pages/shell-and-navigation-pages.md b/open_wearable/docs/pages/shell-and-navigation-pages.md
new file mode 100644
index 00000000..27fc7e7d
--- /dev/null
+++ b/open_wearable/docs/pages/shell-and-navigation-pages.md
@@ -0,0 +1,29 @@
+# Shell and Navigation Pages
+
+## `HomePage` (`lib/widgets/home_page.dart`)
+- Needs:
+ - Optional `initialSectionIndex` input (from router query `tab`).
+ - Child section pages to be available: Overview, Devices, Sensors, Apps, Settings.
+ - `SensorPageController` wiring for cross-section tab deep-linking.
+- Does:
+ - Hosts the main app shell and top-level section navigation.
+ - Switches between compact bottom-tab layout and large-screen navigation rail layout.
+ - Keeps section state using `PlatformTabController` and `IndexedStack`.
+- Provides:
+ - Stable root navigation context for all section pages.
+ - Public section-level navigation entry points used by Overview and Settings actions.
+
+## `OverviewPage` (`lib/widgets/home_page_overview.dart`)
+- Needs:
+ - `WearablesProvider` and `SensorRecorderProvider` in widget tree.
+ - Three callbacks from `HomePage`:
+ - `onDeviceSectionRequested`
+ - `onConnectRequested`
+ - `onSensorTabRequested(tabIndex)`
+- Does:
+ - Shows high-level session status (connected wearables and recording status).
+ - Shows guided workflow steps: connect, configure, validate live data, record.
+ - Opens device detail from overview cards.
+- Provides:
+ - Quick navigation hub to device connect flow and sensor tabs.
+ - At-a-glance health/status for the current setup session.
diff --git a/open_wearable/docs/state-and-providers.md b/open_wearable/docs/state-and-providers.md
new file mode 100644
index 00000000..f107829e
--- /dev/null
+++ b/open_wearable/docs/state-and-providers.md
@@ -0,0 +1,189 @@
+# State and Providers
+
+This document explains how state is handled in the app, with emphasis on provider responsibilities and data flow.
+
+## 1. State Management Model
+
+The app uses `provider` with a mixed approach:
+
+- Global `ChangeNotifier` providers for app-wide state.
+- Plain `Provider` for stateless services.
+- Feature-local providers created on demand (for per-device/per-sensor state).
+- Streams from `open_earable_flutter` bridged into provider state.
+
+Main provider wiring starts in `lib/main.dart`.
+
+## 2. Global Providers
+
+## `WearablesProvider`
+
+File: `lib/view_models/wearables_provider.dart`
+
+Responsibilities:
+
+- Source of truth for connected wearables.
+- Creates and stores one `SensorConfigurationProvider` per wearable.
+- Tracks stereo pair combine/split UI preference.
+- Emits high-level streams for:
+ - unsupported firmware events
+ - wearable events (time sync, errors, firmware update availability)
+- Handles capability updates and sync side effects (for example time synchronization).
+
+Used by:
+
+- Devices page
+- Sensor configuration pages
+- Sensor page orchestration
+- Mini-app device selectors
+
+## `SensorRecorderProvider`
+
+File: `lib/view_models/sensor_recorder_provider.dart`
+
+Responsibilities:
+
+- Tracks recording session state (`isRecording`, start time, output directory).
+- Manages per-wearable/per-sensor `Recorder` instances.
+- Starts/stops recording streams for all connected sensors.
+- Handles recorder setup for wearables that connect during an active recording session.
+
+Used by:
+
+- Recorder tab UI
+- App lifecycle logic in `main.dart` to prevent shutdown while recording
+
+## `FirmwareUpdateRequestProvider`
+
+Provided globally in `main.dart` (type comes from `open_earable_flutter`).
+
+Responsibilities:
+
+- Holds FOTA selection/update context used across warning/select/update screens.
+
+Used by:
+
+- FOTA flow widgets under `lib/widgets/fota/`
+
+## `AppBannerController`
+
+File: `lib/view_models/app_banner_controller.dart`
+
+Responsibilities:
+
+- Stores active transient `AppBanner` entries.
+- Provides methods to show/hide banners.
+
+Used by:
+
+- `GlobalAppBannerOverlay`
+- `main.dart` event handling (maps wearable events to banners)
+
+## `WearableConnector`
+
+File: `lib/models/wearable_connector.dart`
+
+This is provided with `Provider.value` (not `ChangeNotifier`).
+
+Responsibilities:
+
+- Encapsulates direct `WearableManager` connection calls.
+- Emits connect/disconnect events as a stream.
+
+Used by:
+
+- Device connect UI
+- `main.dart` global event wiring
+
+## `LogFileManager`
+
+Provided as `ChangeNotifierProvider.value` in `main.dart`.
+
+Responsibilities:
+
+- Owns logging file operations and state for log browsing screens.
+
+## 3. Per-Device and Per-Sensor Providers
+
+## `SensorConfigurationProvider` (per wearable)
+
+File: `lib/view_models/sensor_configuration_provider.dart`
+
+Created by `WearablesProvider` per connected wearable.
+
+Responsibilities:
+
+- Tracks selected config values.
+- Tracks pending (optimistic) edits until hardware reports matching values.
+- Tracks last reported device configuration snapshot.
+- Resolves whether a configuration is selected/applied/pending.
+
+Consumption pattern:
+
+- `SensorConfigurationView` pulls provider instances from `WearablesProvider`.
+- It passes each via `ChangeNotifierProvider.value` into row/detail widgets.
+
+## `SensorDataProvider` (per wearable + sensor)
+
+File: `lib/view_models/sensor_data_provider.dart`
+
+Created and owned by `SensorPage` as a map keyed by `(Wearable, Sensor)`.
+
+Responsibilities:
+
+- Subscribes to sensor stream (`SensorStreams.shared(sensor)`).
+- Maintains rolling time-window queue for charting and value displays.
+- Uses throttled notifications for UI smoothness.
+- Handles stale/silent stream behavior so charts age out correctly.
+
+Consumption pattern:
+
+- Passed down to live data cards/details using `ChangeNotifierProvider.value`.
+
+## 4. Data Flow: Connection -> UI
+
+1. Connection succeeds via `WearableConnector` or auto-connect.
+2. `main.dart` receives event and calls:
+ - `WearablesProvider.addWearable(...)`
+ - `SensorRecorderProvider.addWearable(...)`
+3. `WearablesProvider` creates/updates per-device `SensorConfigurationProvider` and subscriptions.
+4. UI consumers (`Consumer`, `watch`, `read`) rebuild where needed.
+5. Sensor pages create `SensorDataProvider` instances for available sensors and stream live updates.
+
+## 5. Data Flow: Config Edit -> Hardware Apply
+
+1. User modifies selections in `SensorConfigurationProvider` (local/pending state).
+2. UI marks pending values.
+3. User taps "Apply Profiles".
+4. `SensorConfigurationView` sends selected values to hardware (`config.setConfiguration(...)`).
+5. Provider stream receives hardware report; pending entries are cleared when values match.
+
+For stereo pairs, mirrored target entries can be applied alongside primary entries.
+
+## 6. Data Flow: App Lifecycle
+
+App lifecycle handling in `main.dart` coordinates provider state:
+
+- Uses `SensorRecorderProvider.isRecording` to decide whether shutdown should be deferred.
+- Uses `WearablesProvider.turnOffSensorsForAllDevices()` when close-shutdown setting is enabled.
+- Uses `AutoConnectPreferences` + `BluetoothAutoConnector` to pause/resume reconnection policy.
+
+## 7. Persistence Boundaries
+
+- `SharedPreferences`:
+ - auto-connect toggles and remembered names
+ - app shutdown/graph settings
+- File storage:
+ - sensor profile JSONs via `SensorConfigurationStorage`
+ - log files via `LogFileManager`
+
+Providers remain in-memory runtime state; persistence is delegated to model/storage helpers.
+
+## 8. Practical Rules for Adding State
+
+When introducing new state:
+
+1. Put cross-screen runtime state in a top-level provider.
+2. Keep feature-specific transient state near the widget tree that owns it.
+3. Keep persistence out of widgets; use model/storage helpers.
+4. Prefer stream-to-provider adapters over direct widget stream subscriptions.
+5. Use `ChangeNotifierProvider.value` only for existing provider instances (already-created objects).
diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock
index 764cb645..b85d3bad 100644
--- a/open_wearable/ios/Podfile.lock
+++ b/open_wearable/ios/Podfile.lock
@@ -50,6 +50,8 @@ PODS:
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
+ - record_ios (1.2.0):
+ - Flutter
- SDWebImage (5.21.5):
- SDWebImage/Core (= 5.21.5)
- SDWebImage/Core (5.21.5)
@@ -76,6 +78,7 @@ DEPENDENCIES:
- mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
+ - record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
@@ -107,6 +110,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/open_file_ios/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
+ record_ios:
+ :path: ".symlinks/plugins/record_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -127,6 +132,7 @@ SPEC CHECKSUMS:
mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
+ record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
diff --git a/open_wearable/ios/Runner/AppDelegate.swift b/open_wearable/ios/Runner/AppDelegate.swift
index 84aee014..1dffdfa3 100644
--- a/open_wearable/ios/Runner/AppDelegate.swift
+++ b/open_wearable/ios/Runner/AppDelegate.swift
@@ -3,14 +3,40 @@ import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
+ private var sensorShutdownBackgroundTask: UIBackgroundTaskIdentifier = .invalid
+ private var lifecycleChannel: FlutterMethodChannel?
+
+ private func beginSensorShutdownBackgroundTask() {
+ guard sensorShutdownBackgroundTask == .invalid else {
+ return
+ }
+
+ sensorShutdownBackgroundTask = UIApplication.shared.beginBackgroundTask(
+ withName: "SensorShutdown"
+ ) { [weak self] in
+ self?.endSensorShutdownBackgroundTask()
+ }
+ }
+
+ private func endSensorShutdownBackgroundTask() {
+ guard sensorShutdownBackgroundTask != .invalid else {
+ return
+ }
+
+ UIApplication.shared.endBackgroundTask(sensorShutdownBackgroundTask)
+ sensorShutdownBackgroundTask = .invalid
+ }
+
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
- let channel = FlutterMethodChannel(name: "edu.teco.open_folder", binaryMessenger: controller.binaryMessenger)
+ let openFolderChannel = FlutterMethodChannel(name: "edu.teco.open_folder", binaryMessenger: controller.binaryMessenger)
+ let systemSettingsChannel = FlutterMethodChannel(name: "edu.kit.teco.open_wearable/system_settings", binaryMessenger: controller.binaryMessenger)
+ lifecycleChannel = FlutterMethodChannel(name: "edu.kit.teco.open_wearable/lifecycle", binaryMessenger: controller.binaryMessenger)
- channel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
+ openFolderChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
if call.method == "openFolder", let args = call.arguments as? [String: Any], let path = args["path"] as? String {
guard let url = URL(string: path) else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Invalid folder path", details: nil))
@@ -27,6 +53,42 @@ import UIKit
}
}
+ systemSettingsChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
+ if call.method == "openBluetoothSettings" {
+ guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
+ result(false)
+ return
+ }
+
+ if UIApplication.shared.canOpenURL(settingsUrl) {
+ UIApplication.shared.open(settingsUrl, options: [:]) { success in
+ result(success)
+ }
+ } else {
+ result(false)
+ }
+ } else {
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ lifecycleChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
+ guard let self = self else {
+ result(false)
+ return
+ }
+
+ if call.method == "beginBackgroundExecution" {
+ self.beginSensorShutdownBackgroundTask()
+ result(true)
+ } else if call.method == "endBackgroundExecution" {
+ self.endSensorShutdownBackgroundTask()
+ result(true)
+ } else {
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
diff --git a/open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart b/open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart
index e3727350..ed1f991c 100644
--- a/open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart
+++ b/open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart
@@ -8,17 +8,35 @@ class BandPassFilter {
late final double a0, a1, a2, b1, b2;
double x1 = 0, x2 = 0;
double y1 = 0, y2 = 0;
+ bool _isInitialized = false;
BandPassFilter({
required this.sampleFreq,
required this.lowCut,
required this.highCut,
}) {
- final centerFreq = sqrt(lowCut * highCut);
- final bandwidth = highCut - lowCut;
+ final safeSampleFreq =
+ sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0;
+ final nyquist = safeSampleFreq / 2.0;
+
+ var safeLow = lowCut;
+ if (!safeLow.isFinite || safeLow <= 0) {
+ safeLow = 0.45;
+ }
+
+ var safeHigh = highCut;
+ if (!safeHigh.isFinite || safeHigh <= safeLow) {
+ safeHigh = safeLow + 0.6;
+ }
+ safeHigh = min(safeHigh, nyquist - 0.05);
+ safeLow = min(safeLow, safeHigh - 0.15);
+ safeLow = max(0.05, safeLow);
+
+ final centerFreq = sqrt(safeLow * safeHigh);
+ final bandwidth = max(0.15, safeHigh - safeLow);
final q = centerFreq / bandwidth;
- final omega = 2 * pi * centerFreq / sampleFreq;
+ final omega = 2 * pi * centerFreq / safeSampleFreq;
final alpha = sin(omega) / (2 * q);
final cosOmega = cos(omega);
@@ -32,7 +50,28 @@ class BandPassFilter {
}
double filter(double x) {
+ if (!x.isFinite) {
+ return _isInitialized ? y1 : 0;
+ }
+
+ if (!_isInitialized) {
+ _isInitialized = true;
+ x1 = x;
+ x2 = x;
+ y1 = 0;
+ y2 = 0;
+ return 0;
+ }
+
final y = a0 * x + a1 * x1 + a2 * x2 - b1 * y1 - b2 * y2;
+ if (!y.isFinite) {
+ _isInitialized = false;
+ x1 = x;
+ x2 = x;
+ y1 = 0;
+ y2 = 0;
+ return 0;
+ }
x2 = x1;
x1 = x;
y2 = y1;
diff --git a/open_wearable/lib/apps/heart_tracker/model/msptd_fast_v2_detector.dart b/open_wearable/lib/apps/heart_tracker/model/msptd_fast_v2_detector.dart
new file mode 100644
index 00000000..ae5e717b
--- /dev/null
+++ b/open_wearable/lib/apps/heart_tracker/model/msptd_fast_v2_detector.dart
@@ -0,0 +1,265 @@
+import 'dart:math';
+import 'dart:typed_data';
+
+/// Adapted from `msptdfastv2_beat_detector.m` in:
+/// https://github.com/peterhcharlton/ppg-beats (MIT-licensed file).
+///
+/// The original implementation is MATLAB; this is a Dart adaptation for
+/// real-time windowed use in the app.
+class MsptdFastV2Detector {
+ const MsptdFastV2Detector();
+
+ static const double _minPlausibleHeartRateBpm = 30.0;
+ static const double _targetDownsampleHz = 20.0;
+
+ List detectPeakIndices(
+ List samples, {
+ required double sampleFreqHz,
+ }) {
+ if (samples.length < 8 || !sampleFreqHz.isFinite || sampleFreqHz <= 0) {
+ return const [];
+ }
+
+ final prepared = _prepareSignal(
+ samples,
+ sampleFreqHz: sampleFreqHz,
+ );
+ final detrended = _detrend(prepared.samples);
+ final candidatePeaks = _detectPeakCandidates(
+ detrended,
+ sampleFreqHz: prepared.sampleFreqHz,
+ );
+ if (candidatePeaks.isEmpty) {
+ return const [];
+ }
+
+ final refinedPeaks = _refineInOriginalSignal(
+ candidates: candidatePeaks,
+ originalSignal: samples,
+ originalSampleFreqHz: sampleFreqHz,
+ workingSampleFreqHz: prepared.sampleFreqHz,
+ downsampleFactor: prepared.downsampleFactor,
+ );
+
+ final minDistanceSamples = max(1, (0.28 * sampleFreqHz).round());
+ return _enforceMinimumDistance(
+ refinedPeaks,
+ minDistanceSamples: minDistanceSamples,
+ );
+ }
+
+ _PreparedSignal _prepareSignal(
+ List samples, {
+ required double sampleFreqHz,
+ }) {
+ var downsampleFactor = 1;
+ if (sampleFreqHz > _targetDownsampleHz) {
+ downsampleFactor = max(1, (sampleFreqHz / _targetDownsampleHz).floor());
+ }
+
+ if (downsampleFactor <= 1) {
+ return _PreparedSignal(
+ samples: samples,
+ sampleFreqHz: sampleFreqHz,
+ downsampleFactor: 1,
+ );
+ }
+
+ final downsampled = [];
+ for (var i = 0; i < samples.length; i += downsampleFactor) {
+ downsampled.add(samples[i]);
+ }
+
+ return _PreparedSignal(
+ samples: downsampled,
+ sampleFreqHz: sampleFreqHz / downsampleFactor,
+ downsampleFactor: downsampleFactor,
+ );
+ }
+
+ List _detrend(List signal) {
+ final n = signal.length;
+ if (n < 2) {
+ return signal;
+ }
+
+ final sumX = (n - 1) * n / 2.0;
+ final sumX2 = (n - 1) * n * ((2 * n) - 1) / 6.0;
+ var sumY = 0.0;
+ var sumXY = 0.0;
+ for (var i = 0; i < n; i++) {
+ final y = signal[i];
+ sumY += y;
+ sumXY += i * y;
+ }
+
+ final denominator = (n * sumX2) - (sumX * sumX);
+ final slope = denominator.abs() < 1e-9
+ ? 0.0
+ : ((n * sumXY) - (sumX * sumY)) / denominator;
+ final intercept = (sumY - (slope * sumX)) / n;
+
+ return List.generate(
+ n,
+ (i) => signal[i] - (intercept + (slope * i)),
+ growable: false,
+ );
+ }
+
+ List _detectPeakCandidates(
+ List signal, {
+ required double sampleFreqHz,
+ }) {
+ final n = signal.length;
+ if (n < 5 || !sampleFreqHz.isFinite || sampleFreqHz <= 0) {
+ return const [];
+ }
+
+ final halfLength = (n / 2).ceil() - 1;
+ if (halfLength < 1) {
+ return const [];
+ }
+
+ final maxScale = _reduceScalesForPlausibleHeartRates(
+ halfLength: halfLength,
+ signalLength: n,
+ sampleFreqHz: sampleFreqHz,
+ );
+
+ final mMax = List.generate(
+ maxScale,
+ (_) => Uint8List(n),
+ growable: false,
+ );
+
+ for (var k = 1; k <= maxScale; k++) {
+ final row = mMax[k - 1];
+ for (var i = k; i < n - k; i++) {
+ if (signal[i] > signal[i - k] && signal[i] > signal[i + k]) {
+ row[i] = 1;
+ }
+ }
+ }
+
+ var lambdaRow = 0;
+ var bestRowSum = -1;
+ for (var rowIndex = 0; rowIndex < mMax.length; rowIndex++) {
+ var rowSum = 0;
+ final row = mMax[rowIndex];
+ for (var i = 0; i < row.length; i++) {
+ rowSum += row[i];
+ }
+ if (rowSum > bestRowSum) {
+ bestRowSum = rowSum;
+ lambdaRow = rowIndex;
+ }
+ }
+
+ final peaks = [];
+ for (var col = 0; col < n; col++) {
+ var isPeak = true;
+ for (var row = 0; row <= lambdaRow; row++) {
+ if (mMax[row][col] == 0) {
+ isPeak = false;
+ break;
+ }
+ }
+ if (isPeak) {
+ peaks.add(col);
+ }
+ }
+ return peaks;
+ }
+
+ int _reduceScalesForPlausibleHeartRates({
+ required int halfLength,
+ required int signalLength,
+ required double sampleFreqHz,
+ }) {
+ final durationSeconds = signalLength / sampleFreqHz;
+ if (!durationSeconds.isFinite || durationSeconds <= 0) {
+ return halfLength;
+ }
+ final minPlausibleHz = _minPlausibleHeartRateBpm / 60.0;
+
+ var reducedMaxScale = 1;
+ for (var k = 1; k <= halfLength; k++) {
+ final scaleFrequencyHz = (halfLength / k) / durationSeconds;
+ if (scaleFrequencyHz >= minPlausibleHz) {
+ reducedMaxScale = k;
+ }
+ }
+
+ return reducedMaxScale.clamp(1, halfLength);
+ }
+
+ List _refineInOriginalSignal({
+ required List candidates,
+ required List originalSignal,
+ required double originalSampleFreqHz,
+ required double workingSampleFreqHz,
+ required int downsampleFactor,
+ }) {
+ if (candidates.isEmpty || originalSignal.isEmpty) {
+ return const [];
+ }
+
+ final toleranceSeconds = workingSampleFreqHz < 10
+ ? 0.2
+ : (workingSampleFreqHz < 20 ? 0.1 : 0.05);
+ final toleranceSamples = max(1, (originalSampleFreqHz * toleranceSeconds).round());
+ final refined = [];
+
+ for (final candidate in candidates) {
+ final approxIndex = candidate * downsampleFactor;
+ if (approxIndex < 0 || approxIndex >= originalSignal.length) {
+ continue;
+ }
+
+ final start = max(0, approxIndex - toleranceSamples);
+ final end = min(originalSignal.length - 1, approxIndex + toleranceSamples);
+ var maxIndex = start;
+ var maxValue = originalSignal[start];
+ for (var i = start + 1; i <= end; i++) {
+ final value = originalSignal[i];
+ if (value > maxValue) {
+ maxValue = value;
+ maxIndex = i;
+ }
+ }
+ refined.add(maxIndex);
+ }
+
+ refined.sort();
+ return refined;
+ }
+
+ List _enforceMinimumDistance(
+ List peaks, {
+ required int minDistanceSamples,
+ }) {
+ if (peaks.isEmpty) {
+ return const [];
+ }
+
+ final deduped = [peaks.first];
+ for (var i = 1; i < peaks.length; i++) {
+ if (peaks[i] - deduped.last >= minDistanceSamples) {
+ deduped.add(peaks[i]);
+ }
+ }
+ return deduped;
+ }
+}
+
+class _PreparedSignal {
+ final List samples;
+ final double sampleFreqHz;
+ final int downsampleFactor;
+
+ const _PreparedSignal({
+ required this.samples,
+ required this.sampleFreqHz,
+ required this.downsampleFactor,
+ });
+}
diff --git a/open_wearable/lib/apps/heart_tracker/model/open_ring_classic_heart_processor.dart b/open_wearable/lib/apps/heart_tracker/model/open_ring_classic_heart_processor.dart
new file mode 100644
index 00000000..6a2ee52d
--- /dev/null
+++ b/open_wearable/lib/apps/heart_tracker/model/open_ring_classic_heart_processor.dart
@@ -0,0 +1,392 @@
+import 'dart:async';
+import 'dart:collection';
+import 'dart:math';
+
+import 'package:open_wearable/apps/heart_tracker/model/ppg_filter.dart';
+
+class OpenRingClassicHeartProcessor {
+ final Stream inputStream;
+ final double sampleFreq;
+
+ Stream<_OpenRingSampleOutput>? _outputStream;
+ Stream<(int, double)>? _displaySignalStream;
+ Stream? _heartRateStream;
+ Stream? _hrvStream;
+ Stream? _signalQualityStream;
+
+ OpenRingClassicHeartProcessor({
+ required this.inputStream,
+ required this.sampleFreq,
+ });
+
+ Stream<(int, double)> get displaySignalStream {
+ if (_displaySignalStream != null) {
+ return _displaySignalStream!;
+ }
+ _displaySignalStream = _sampleStream
+ .map((sample) => (sample.timestamp, sample.filteredSignal))
+ .asBroadcastStream();
+ return _displaySignalStream!;
+ }
+
+ Stream get heartRateStream {
+ if (_heartRateStream != null) {
+ return _heartRateStream!;
+ }
+ _heartRateStream =
+ _sampleStream.map((sample) => sample.heartRateBpm).distinct();
+ return _heartRateStream!;
+ }
+
+ Stream get hrvStream {
+ if (_hrvStream != null) {
+ return _hrvStream!;
+ }
+ _hrvStream = _sampleStream.map((sample) => sample.hrvRmssdMs).distinct();
+ return _hrvStream!;
+ }
+
+ Stream get signalQualityStream {
+ if (_signalQualityStream != null) {
+ return _signalQualityStream!;
+ }
+ _signalQualityStream =
+ _sampleStream.map((sample) => sample.signalQuality).distinct();
+ return _signalQualityStream!;
+ }
+
+ void dispose() {}
+
+ Stream<_OpenRingSampleOutput> get _sampleStream {
+ if (_outputStream != null) {
+ return _outputStream!;
+ }
+ _outputStream = _createOutputStream().asBroadcastStream();
+ return _outputStream!;
+ }
+
+ Stream<_OpenRingSampleOutput> _createOutputStream() async* {
+ final safeSampleFreq =
+ sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0;
+ final hrWindowSize = max(12, (safeSampleFreq * 5).round());
+ final hrUpdateIntervalSamples = max(1, safeSampleFreq.round());
+ final historyLength = 5;
+
+ final selectedSignalBuffer = ListQueue();
+ final qualitySignalBuffer = ListQueue();
+ final hrHistory = ListQueue();
+
+ var samplesSinceLastHrUpdate = 0;
+ double? currentHeartRate;
+ double? currentHrv;
+ var currentQuality = PpgSignalQuality.unavailable;
+
+ await for (final sample in inputStream) {
+ final selectedSignal = _selectHeartSignal(sample);
+ final qualitySignal = _selectQualitySignal(sample);
+
+ _pushLimited(selectedSignalBuffer, selectedSignal, hrWindowSize);
+ _pushLimited(qualitySignalBuffer, qualitySignal, hrWindowSize);
+
+ final selectedList = selectedSignalBuffer.toList(growable: false);
+ final filteredSignal = _applyPhysiologicalFilter(
+ selectedList,
+ sampleFreqHz: safeSampleFreq,
+ );
+ final filteredValue =
+ filteredSignal.isNotEmpty ? filteredSignal.last : selectedSignal;
+
+ currentQuality = _estimateSignalQuality(qualitySignalBuffer);
+ samplesSinceLastHrUpdate += 1;
+ if (samplesSinceLastHrUpdate >= hrUpdateIntervalSamples) {
+ samplesSinceLastHrUpdate = 0;
+
+ if (currentQuality == PpgSignalQuality.bad ||
+ currentQuality == PpgSignalQuality.unavailable) {
+ currentHeartRate = null;
+ currentHrv = null;
+ } else {
+ final estimate = _estimateHeartRateAndHrv(
+ filteredSignal,
+ sampleRateHz: safeSampleFreq,
+ );
+ final estimatedHeartRate = estimate.heartRateBpm;
+ if (estimatedHeartRate != null) {
+ _pushLimited(hrHistory, estimatedHeartRate, historyLength);
+ currentHeartRate = _weightedAverage(hrHistory);
+ } else {
+ currentHeartRate = null;
+ }
+ currentHrv = estimate.hrvRmssdMs;
+ }
+ }
+
+ yield _OpenRingSampleOutput(
+ timestamp: sample.timestamp,
+ filteredSignal: filteredValue,
+ heartRateBpm: currentHeartRate,
+ hrvRmssdMs: currentHrv,
+ signalQuality: currentQuality,
+ );
+ }
+ }
+
+ void _pushLimited(ListQueue queue, double value, int maxSize) {
+ queue.addLast(value);
+ while (queue.length > maxSize) {
+ queue.removeFirst();
+ }
+ }
+
+ double _weightedAverage(ListQueue values) {
+ if (values.isEmpty) {
+ return 0;
+ }
+ var weightedSum = 0.0;
+ var totalWeight = 0;
+ for (var i = 0; i < values.length; i++) {
+ final weight = i + 1;
+ weightedSum += values.elementAt(i) * weight;
+ totalWeight += weight;
+ }
+ return weightedSum / max(1, totalWeight);
+ }
+
+ double _selectHeartSignal(PpgOpticalSample sample) {
+ // Match OpenRing classical inference path: HR is derived from IR.
+ if (sample.ir.isFinite && sample.ir.abs() > 1e-6) {
+ return sample.ir;
+ }
+ // Defensive fallback only when IR is missing.
+ if (sample.red.isFinite) {
+ return sample.red;
+ }
+ if (sample.green.isFinite) {
+ return sample.green;
+ }
+ return 0;
+ }
+
+ double _selectQualitySignal(PpgOpticalSample sample) {
+ if (sample.green.isFinite) {
+ return sample.green;
+ }
+ return 0;
+ }
+
+ List _applyPhysiologicalFilter(
+ List signal, {
+ required double sampleFreqHz,
+ }) {
+ if (signal.isEmpty) {
+ return const [];
+ }
+
+ final lowPassWindow = max(3, (sampleFreqHz * 0.5).round());
+ final trendWindow = max(lowPassWindow + 2, (sampleFreqHz * 2.0).round());
+ final lowPassed = _centeredMovingAverage(signal, lowPassWindow);
+ final trend = _centeredMovingAverage(lowPassed, trendWindow);
+
+ return List.generate(
+ signal.length,
+ (index) => lowPassed[index] - trend[index],
+ growable: false,
+ );
+ }
+
+ List _centeredMovingAverage(
+ List signal,
+ int windowSize,
+ ) {
+ if (signal.isEmpty || windowSize <= 1) {
+ return List.from(signal);
+ }
+ final output = List.filled(signal.length, 0, growable: false);
+ final half = windowSize ~/ 2;
+ for (var i = 0; i < signal.length; i++) {
+ final start = max(0, i - half);
+ final end = min(signal.length, i + half + 1);
+ var sum = 0.0;
+ for (var j = start; j < end; j++) {
+ sum += signal[j];
+ }
+ output[i] = sum / max(1, end - start);
+ }
+ return output;
+ }
+
+ ({
+ double? heartRateBpm,
+ double? hrvRmssdMs,
+ }) _estimateHeartRateAndHrv(
+ List filteredSignal, {
+ required double sampleRateHz,
+ }) {
+ if (filteredSignal.length < 8 ||
+ sampleRateHz <= 0 ||
+ !sampleRateHz.isFinite) {
+ return (heartRateBpm: null, hrvRmssdMs: null);
+ }
+
+ final peaks = _detectPeaks(
+ filteredSignal,
+ thresholdRatio: 0.0,
+ sampleRateHz: sampleRateHz,
+ minIntervalSec: 0.4,
+ );
+ if (peaks.length < 2) {
+ return (heartRateBpm: null, hrvRmssdMs: null);
+ }
+
+ final intervalsSec = [];
+ for (var i = 1; i < peaks.length; i++) {
+ final intervalSec = (peaks[i] - peaks[i - 1]) / sampleRateHz;
+ if (intervalSec >= 0.3 && intervalSec <= 1.5) {
+ intervalsSec.add(intervalSec);
+ }
+ }
+ if (intervalsSec.isEmpty) {
+ return (heartRateBpm: null, hrvRmssdMs: null);
+ }
+
+ intervalsSec.sort();
+ final medianInterval = intervalsSec[intervalsSec.length ~/ 2];
+ final heartRate = 60.0 / medianInterval;
+ if (!heartRate.isFinite || heartRate < 40 || heartRate > 200) {
+ return (heartRateBpm: null, hrvRmssdMs: null);
+ }
+
+ double? rmssdMs;
+ if (intervalsSec.length >= 2) {
+ var sumSquared = 0.0;
+ var count = 0;
+ for (var i = 1; i < intervalsSec.length; i++) {
+ final delta = intervalsSec[i] - intervalsSec[i - 1];
+ sumSquared += delta * delta;
+ count += 1;
+ }
+ if (count > 0) {
+ final rmssdSec = sqrt(sumSquared / count);
+ final value = rmssdSec * 1000.0;
+ if (value.isFinite && value >= 5 && value <= 300) {
+ rmssdMs = value;
+ }
+ }
+ }
+
+ return (
+ heartRateBpm: heartRate,
+ hrvRmssdMs: rmssdMs,
+ );
+ }
+
+ List _detectPeaks(
+ List signal, {
+ required double thresholdRatio,
+ required double sampleRateHz,
+ required double minIntervalSec,
+ }) {
+ final peaks = [];
+ if (signal.length < 3) {
+ return peaks;
+ }
+
+ var minValue = double.infinity;
+ var maxValue = double.negativeInfinity;
+ var mean = 0.0;
+ for (final value in signal) {
+ if (value < minValue) minValue = value;
+ if (value > maxValue) maxValue = value;
+ mean += value;
+ }
+ mean /= signal.length;
+
+ final dynamicThreshold = mean + ((maxValue - mean) * thresholdRatio);
+ final minDistanceSamples = minIntervalSec > 0
+ ? max(1, (minIntervalSec * sampleRateHz).round())
+ : 0;
+
+ var lastPeakIndex = -minDistanceSamples;
+ var i = 0;
+ while (i < signal.length) {
+ if (signal[i] >= dynamicThreshold) {
+ var regionMaxIndex = i;
+ var regionMaxValue = signal[i];
+ var j = i + 1;
+ while (j < signal.length && signal[j] >= dynamicThreshold) {
+ if (signal[j] > regionMaxValue) {
+ regionMaxValue = signal[j];
+ regionMaxIndex = j;
+ }
+ j += 1;
+ }
+
+ if (minDistanceSamples <= 0 ||
+ peaks.isEmpty ||
+ (regionMaxIndex - lastPeakIndex) >= minDistanceSamples) {
+ peaks.add(regionMaxIndex);
+ lastPeakIndex = regionMaxIndex;
+ }
+ i = j;
+ continue;
+ }
+ i += 1;
+ }
+
+ return peaks;
+ }
+
+ PpgSignalQuality _estimateSignalQuality(ListQueue samples) {
+ if (samples.isEmpty) {
+ return PpgSignalQuality.unavailable;
+ }
+
+ var minValue = double.infinity;
+ var maxValue = double.negativeInfinity;
+ var sum = 0.0;
+ for (final value in samples) {
+ if (value < minValue) minValue = value;
+ if (value > maxValue) maxValue = value;
+ sum += value;
+ }
+ final mean = sum / samples.length;
+ final range = maxValue - minValue;
+
+ if (!mean.isFinite || !range.isFinite || range <= 1e-6) {
+ return PpgSignalQuality.unavailable;
+ }
+
+ // Mirrors OpenRing's threshold-style quality gating:
+ // quality from signal mean + dynamic range.
+ if (mean > 1000 && range > 500) {
+ if (range > 2000) {
+ return PpgSignalQuality.good;
+ }
+ if (range > 1500) {
+ return PpgSignalQuality.good;
+ }
+ if (range > 1000) {
+ return PpgSignalQuality.fair;
+ }
+ return PpgSignalQuality.bad;
+ }
+
+ return PpgSignalQuality.bad;
+ }
+}
+
+class _OpenRingSampleOutput {
+ final int timestamp;
+ final double filteredSignal;
+ final double? heartRateBpm;
+ final double? hrvRmssdMs;
+ final PpgSignalQuality signalQuality;
+
+ const _OpenRingSampleOutput({
+ required this.timestamp,
+ required this.filteredSignal,
+ required this.heartRateBpm,
+ required this.hrvRmssdMs,
+ required this.signalQuality,
+ });
+}
diff --git a/open_wearable/lib/apps/heart_tracker/model/ppg_filter.dart b/open_wearable/lib/apps/heart_tracker/model/ppg_filter.dart
index d41c06aa..29a311ea 100644
--- a/open_wearable/lib/apps/heart_tracker/model/ppg_filter.dart
+++ b/open_wearable/lib/apps/heart_tracker/model/ppg_filter.dart
@@ -1,142 +1,1182 @@
+// ignore_for_file: cancel_subscriptions
+
+import 'dart:async';
+import 'dart:collection';
import 'dart:math';
-import 'package:logger/logger.dart';
import 'package:open_wearable/apps/heart_tracker/model/band_pass_filter.dart';
+import 'package:open_wearable/apps/heart_tracker/model/high_pass_filter.dart';
+
+enum PpgSignalQuality {
+ unavailable,
+ bad,
+ fair,
+ good,
+}
+
+class PpgOpticalSample {
+ final int timestamp;
+ final double red;
+ final double ir;
+ final double green;
+ final double ambient;
+
+ const PpgOpticalSample({
+ required this.timestamp,
+ required this.red,
+ required this.ir,
+ required this.green,
+ required this.ambient,
+ });
+}
+
+class PpgVitals {
+ final double? heartRateBpm;
+ final double? hrvRmssdMs;
+ final PpgSignalQuality signalQuality;
+
+ const PpgVitals({
+ required this.heartRateBpm,
+ required this.hrvRmssdMs,
+ required this.signalQuality,
+ });
+
+ const PpgVitals.invalid({
+ this.signalQuality = PpgSignalQuality.unavailable,
+ }) : heartRateBpm = null,
+ hrvRmssdMs = null;
+}
+
+class PpgMotionSample {
+ final int timestamp;
+ final double x;
+ final double y;
+ final double z;
+
+ const PpgMotionSample({
+ required this.timestamp,
+ required this.x,
+ required this.y,
+ required this.z,
+ });
+
+ double get magnitude => sqrt((x * x) + (y * y) + (z * z));
+}
-Logger _logger = Logger();
+class PpgTemperatureSample {
+ final int timestamp;
+ final double celsius;
+
+ const PpgTemperatureSample({
+ required this.timestamp,
+ required this.celsius,
+ });
+}
class PpgFilter {
- final Stream<(int, double)> inputStream;
+ final Stream inputStream;
+ final Stream? motionStream;
+ final Stream? opticalTemperatureStream;
final double sampleFreq;
-
- final double _minProminence = 0.1;
- final int _minPeakDistanceMs = 300; // e.g., 200 BPM max
+ final int timestampExponent;
double _hrEstimate = 75.0;
- double _p = 1.0;
- final double _q = 0.01; // process noise
- final double _r = 4.0;
-
- int timestampExponent; // measurement noise
+ double _hrCovariance = 1.0;
+ final double _hrProcessNoise = 0.02;
+ final double _hrMeasurementNoise = 5.0;
- Stream<(int, double)>? _filteredStream;
+ double _hrvEstimateMs = 35.0;
+ final double _hrvSmoothingAlpha = 0.18;
+
+ StreamSubscription? _motionSubscription;
+ StreamSubscription? _temperatureSubscription;
+ Stream<_MotionAwareSample>? _processedStream;
+ Stream<(int, double)>? _rawSignalStream;
+ Stream<(int, double)>? _displaySignalStream;
+ Stream? _vitalsStream;
+
+ double? _latestOpticalTemperatureCelsius;
+ int? _latestOpticalTemperatureTimestamp;
+
+ static const double _reasonableInEarTemperatureCelsius = 32.0;
+ static const double _maxTemperatureSampleAgeSec = 20.0;
+ static const double _minBeatIntervalSec = 0.25;
+ static const double _maxBeatIntervalSec = 2.0;
PpgFilter({
required this.inputStream,
required this.sampleFreq,
required this.timestampExponent,
+ this.motionStream,
+ this.opticalTemperatureStream,
});
- Stream<(int, double)> get filteredStream {
- final filter = BandPassFilter(
- sampleFreq: sampleFreq,
+ void initialize() {
+ // Eagerly build pipelines so filters/subscriptions are ready on app start.
+ displaySignalStream;
+ _sampleStream;
+ _metricsStream;
+ }
+
+ Stream<(int, double)> get displaySignalStream {
+ if (_displaySignalStream != null) {
+ return _displaySignalStream!;
+ }
+ _displaySignalStream = _createLiveDisplaySignalStream().asBroadcastStream();
+ return _displaySignalStream!;
+ }
+
+ Stream<(int, double)> get rawSignalStream {
+ if (_rawSignalStream != null) {
+ return _rawSignalStream!;
+ }
+ _rawSignalStream = _sampleStream
+ .map((sample) => (sample.timestamp, sample.rawGreen))
+ .asBroadcastStream();
+ return _rawSignalStream!;
+ }
+
+ Stream get heartRateStream =>
+ _metricsStream.map((vitals) => vitals.heartRateBpm);
+
+ Stream get hrvStream =>
+ _metricsStream.map((vitals) => vitals.hrvRmssdMs);
+
+ Stream get signalQualityStream =>
+ _metricsStream.map((vitals) => vitals.signalQuality).distinct();
+
+ void dispose() {
+ final motionSubscription = _motionSubscription;
+ _motionSubscription = null;
+ if (motionSubscription != null) {
+ unawaited(motionSubscription.cancel());
+ }
+
+ final temperatureSubscription = _temperatureSubscription;
+ _temperatureSubscription = null;
+ if (temperatureSubscription != null) {
+ unawaited(temperatureSubscription.cancel());
+ }
+ }
+
+ Stream<_MotionAwareSample> get _sampleStream {
+ if (_processedStream != null) {
+ return _processedStream!;
+ }
+ _processedStream = _createProcessedStream().asBroadcastStream();
+ return _processedStream!;
+ }
+
+ Stream get _metricsStream {
+ if (_vitalsStream != null) {
+ return _vitalsStream!;
+ }
+ _vitalsStream = _createVitalsStream().asBroadcastStream();
+ return _vitalsStream!;
+ }
+
+ Stream<(int, double)> _createLiveDisplaySignalStream() {
+ final safeSampleFreq =
+ sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0;
+ final bandPassFilter = BandPassFilter(
+ sampleFreq: safeSampleFreq,
lowCut: 0.5,
- highCut: 4,
+ highCut: 3.2,
);
-
- if (_filteredStream == null) {
- _logger.d("Creating filtered stream");
- _filteredStream = inputStream.map((event) {
- final (timestamp, rawValue) = event;
- final filteredValue = filter.filter(rawValue);
- return (timestamp, filteredValue);
- }).asBroadcastStream();
- } else {
- _logger.d("Using existing filtered stream");
+ int? selectedChannel;
+ var lastFiniteSample = 0.0;
+
+ return inputStream.map((sample) {
+ selectedChannel ??= _pickStableDisplayChannel(sample);
+ var selectedOpticalSignal = _readDisplayChannel(
+ sample,
+ selectedChannel!,
+ );
+ if (!selectedOpticalSignal.isFinite) {
+ selectedOpticalSignal = lastFiniteSample;
+ } else {
+ lastFiniteSample = selectedOpticalSignal;
+ }
+ final bandPassed = bandPassFilter.filter(selectedOpticalSignal);
+ return (sample.timestamp, bandPassed);
+ });
+ }
+
+ int _pickStableDisplayChannel(PpgOpticalSample sample) {
+ final candidates = [sample.green, sample.red, sample.ir];
+ var bestChannel = 0;
+ var bestEnergy = -1.0;
+ for (var i = 0; i < candidates.length; i++) {
+ final value = candidates[i];
+ if (!value.isFinite) {
+ continue;
+ }
+ final energy = value.abs();
+ if (energy > bestEnergy && energy > 1e-9) {
+ bestEnergy = energy;
+ bestChannel = i;
+ }
+ }
+ return bestChannel;
+ }
+
+ double _readDisplayChannel(PpgOpticalSample sample, int channelIndex) {
+ switch (channelIndex) {
+ case 1:
+ return sample.red;
+ case 2:
+ return sample.ir;
+ case 0:
+ default:
+ return sample.green;
+ }
+ }
+
+ Stream<_MotionAwareSample> _createProcessedStream() {
+ final safeSampleFreq =
+ sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0;
+ final ambientCanceler = _AmbientLightCanceler();
+ final motionSuppressor = _MotionNoiseSuppressor();
+ final imuCanceler = _MultiReferenceMotionCanceler();
+ final opticalChannelSelector = _AdaptiveOpticalChannelSelector();
+ final dcBlockFilter = HighPassFilter(
+ cutoffFreq: 0.12,
+ sampleFreq: safeSampleFreq,
+ );
+ final bandPassFilter = BandPassFilter(
+ sampleFreq: safeSampleFreq,
+ lowCut: 0.45,
+ highCut: 5.5,
+ );
+ final normalizer = _BoundedSignalNormalizer();
+ final displayDetrender = _DisplayBaselineDetrender(
+ sampleFreqHz: safeSampleFreq,
+ timeConstantSeconds: 3.2,
+ );
+
+ if (motionStream != null) {
+ _motionSubscription = motionStream!.listen((event) {
+ motionSuppressor.updateMotionMagnitude(event.magnitude);
+ imuCanceler.updateMotion(event);
+ });
+ }
+ if (opticalTemperatureStream != null) {
+ _temperatureSubscription = opticalTemperatureStream!.listen((sample) {
+ _latestOpticalTemperatureCelsius = sample.celsius;
+ _latestOpticalTemperatureTimestamp = sample.timestamp;
+ });
}
- return _filteredStream!;
+ return inputStream.map((sample) {
+ final selectedOpticalSignal = opticalChannelSelector.select(sample);
+ final ambientCanceled = ambientCanceler.filter(
+ green: selectedOpticalSignal,
+ ambient: sample.ambient,
+ );
+ final imuCleaned = imuCanceler.filter(
+ ambientCanceled,
+ motionLevel: motionSuppressor.motionLevel,
+ );
+ final motionSuppressed = motionSuppressor.filter(imuCleaned);
+ final dcBlocked = dcBlockFilter.filter(motionSuppressed);
+ final bandPassed = bandPassFilter.filter(dcBlocked);
+ final bounded = normalizer.filter(
+ bandPassed,
+ motionLevel: motionSuppressor.motionLevel,
+ );
+ final displaySignal = displayDetrender.filter(bounded);
+ return _MotionAwareSample(
+ timestamp: sample.timestamp,
+ rawGreen: selectedOpticalSignal,
+ rawAmbient: sample.ambient,
+ signal: bounded,
+ displaySignal: displaySignal,
+ motionLevel: motionSuppressor.motionLevel,
+ );
+ });
}
- double _kalmanUpdate(double measurement) {
- if (measurement.isNaN || measurement.isInfinite) return _hrEstimate;
+ double _kalmanUpdateHeartRate(double measurement) {
+ if (!measurement.isFinite) {
+ return _hrEstimate;
+ }
- _p += _q;
- final k = _p / (_p + _r);
- _hrEstimate += k * (measurement - _hrEstimate);
- _p *= (1 - k);
+ _hrCovariance += _hrProcessNoise;
+ final gain = _hrCovariance / (_hrCovariance + _hrMeasurementNoise);
+ _hrEstimate += gain * (measurement - _hrEstimate);
+ _hrCovariance *= (1 - gain);
return _hrEstimate;
}
- List<(int, double)> smoothBuffer(List<(int, double)> raw, {int radius = 2}) {
- final smoothed = <(int, double)>[];
- for (int i = 0; i < raw.length; i++) {
- int start = max(0, i - radius);
- int end = min(raw.length - 1, i + radius);
- final avg = raw.sublist(start, end + 1).map((e) => e.$2).reduce((a, b) => a + b) / (end - start + 1);
- smoothed.add((raw[i].$1, avg));
+ double _smoothHrv(double measurementMs) {
+ if (!measurementMs.isFinite || measurementMs <= 0) {
+ return _hrvEstimateMs;
}
- return smoothed;
+ _hrvEstimateMs = (_hrvEstimateMs * (1.0 - _hrvSmoothingAlpha)) +
+ (measurementMs * _hrvSmoothingAlpha);
+ return _hrvEstimateMs;
}
- List detectPeaks(List<(int, double)> buffer) {
- buffer = smoothBuffer(buffer, radius: 4);
- final peakTimestamps = [];
+ ({double? heartRateBpm, List peakTimestamps}) _estimateHeartRateByPeak(
+ List<_MotionAwareSample> samples, {
+ required double ticksPerSecond,
+ required double estimatedSampleFreqHz,
+ }) {
+ if (samples.length < 8) {
+ return (heartRateBpm: null, peakTimestamps: const []);
+ }
- for (int i = 1; i < buffer.length - 1; i++) {
- final (tPrev, vPrev) = buffer[i - 1];
- final (tCurr, vCurr) = buffer[i];
- final (tNext, vNext) = buffer[i + 1];
+ // Simple extraction: local maxima on the filtered waveform with a fixed
+ // refractory distance and dynamic amplitude threshold.
+ final signal =
+ samples.map((sample) => sample.signal).toList(growable: false);
+ final mean = signal.reduce((a, b) => a + b) / signal.length;
+ final centered =
+ signal.map((value) => value - mean).toList(growable: false);
+
+ final signalStd = _standardDeviation(centered);
+ if (!signalStd.isFinite || signalStd < 1e-4) {
+ return (heartRateBpm: null, peakTimestamps: const []);
+ }
- // Skip too-close peaks
- final lastPeak = peakTimestamps.isNotEmpty ? peakTimestamps.last : 0;
- if (tCurr - lastPeak < _minPeakDistanceMs) continue;
+ final safeSampleFreq =
+ estimatedSampleFreqHz.isFinite && estimatedSampleFreqHz > 0
+ ? estimatedSampleFreqHz
+ : (sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0);
+ final minPeakDistanceSamples =
+ max(1, (safeSampleFreq * _minBeatIntervalSec * 0.85).round());
+ final amplitudeThreshold = max(0.04, signalStd * 0.35);
- // Simple 3-point peak
- if (vCurr > vPrev && vCurr > vNext &&
- (vCurr - vPrev) > _minProminence &&
- (vCurr - vNext) > _minProminence) {
- peakTimestamps.add(tCurr);
+ final peakIndices = [];
+ for (var i = 1; i < centered.length - 1; i++) {
+ final current = centered[i];
+ if (!current.isFinite || current < amplitudeThreshold) {
+ continue;
+ }
+ final isLocalMaximum =
+ current >= centered[i - 1] && current > centered[i + 1];
+ if (!isLocalMaximum) {
+ continue;
}
+
+ if (peakIndices.isNotEmpty &&
+ (i - peakIndices.last) < minPeakDistanceSamples) {
+ // Within refractory period keep only the stronger peak.
+ if (current > centered[peakIndices.last]) {
+ peakIndices[peakIndices.length - 1] = i;
+ }
+ continue;
+ }
+ peakIndices.add(i);
}
- return peakTimestamps;
+ final peaks = peakIndices
+ .map((index) => samples[index].timestamp)
+ .toList(growable: false);
+
+ return _estimateHeartRateFromPeakTimestamps(
+ peaks,
+ ticksPerSecond: ticksPerSecond,
+ );
}
- Stream get heartRateStream async* {
- int timestampFactor = pow(10, -timestampExponent).toInt();
- int windowDurationMs = 8 * timestampFactor; // 8 seconds
- final List<(int, double)> buffer = [];
+ ({double? heartRateBpm, List peakTimestamps})
+ _estimateHeartRateFromPeakTimestamps(
+ List peaks, {
+ required double ticksPerSecond,
+ }) {
+ if (peaks.length < 2) {
+ return (heartRateBpm: null, peakTimestamps: peaks);
+ }
- await for (final (timestamp, value) in filteredStream) {
- buffer.add((timestamp, value));
+ final intervalsSeconds = [];
+ for (var i = 1; i < peaks.length; i++) {
+ final intervalSeconds =
+ (peaks[i] - peaks[i - 1]).toDouble() / max(1.0, ticksPerSecond);
+ if (intervalSeconds >= _minBeatIntervalSec &&
+ intervalSeconds <= _maxBeatIntervalSec) {
+ intervalsSeconds.add(intervalSeconds);
+ }
+ }
+ if (intervalsSeconds.isEmpty) {
+ return (heartRateBpm: null, peakTimestamps: peaks);
+ }
- buffer.removeWhere((event) => event.$1 < timestamp - windowDurationMs);
+ intervalsSeconds.sort();
+ final medianIntervalSeconds =
+ intervalsSeconds[intervalsSeconds.length ~/ 2];
+ final heartRate = 60.0 / medianIntervalSeconds;
+ if (!heartRate.isFinite || heartRate < 30 || heartRate > 240) {
+ return (heartRateBpm: null, peakTimestamps: peaks);
+ }
+
+ return (heartRateBpm: heartRate, peakTimestamps: peaks);
+ }
+
+ double _estimateEffectiveSampleFreqHz(
+ List<_MotionAwareSample> samples, {
+ required double ticksPerSecond,
+ }) {
+ if (samples.length < 2) {
+ return sampleFreq;
+ }
+ final durationTicks =
+ (samples.last.timestamp - samples.first.timestamp).toDouble();
+ if (durationTicks <= 0) {
+ return sampleFreq;
+ }
+ final estimated = ((samples.length - 1) * ticksPerSecond) / durationTicks;
+ if (!estimated.isFinite || estimated < 5 || estimated > 200) {
+ return sampleFreq;
+ }
+ return estimated;
+ }
+
+ List _removeIbiOutliers(List ibiTicks) {
+ if (ibiTicks.length < 3) {
+ return ibiTicks;
+ }
+
+ final sorted = [...ibiTicks]..sort();
+ final median = sorted[sorted.length ~/ 2];
+ final low = median * 0.65;
+ final high = median * 1.35;
+ final filtered = ibiTicks
+ .where((ibi) => ibi >= low && ibi <= high)
+ .toList(growable: false);
+ return filtered.length >= 2 ? filtered : ibiTicks;
+ }
+
+ double? _computeRmssd(List ibiTicks) {
+ if (ibiTicks.length < 2) {
+ return null;
+ }
+
+ var sumSquared = 0.0;
+ var count = 0;
+ for (var i = 1; i < ibiTicks.length; i++) {
+ final delta = ibiTicks[i] - ibiTicks[i - 1];
+ sumSquared += delta * delta;
+ count += 1;
+ }
- if ((buffer.last.$1 - buffer.first.$1) < windowDurationMs / 2) {
- _logger.d("waiting to fill buffer, time difference: ${buffer.last.$1 - buffer.first.$1}");
+ if (count == 0) {
+ return null;
+ }
+ return sqrt(sumSquared / count);
+ }
+
+ double _standardDeviation(List values) {
+ if (values.length < 2) {
+ return 0;
+ }
+ final mean = values.reduce((a, b) => a + b) / values.length;
+ var variance = 0.0;
+ for (final value in values) {
+ final diff = value - mean;
+ variance += diff * diff;
+ }
+ variance /= values.length;
+ return sqrt(variance);
+ }
+
+ PpgSignalQuality _classifyQuality(double score) {
+ if (!score.isFinite || score <= 0) {
+ return PpgSignalQuality.unavailable;
+ }
+ if (score < 0.30) {
+ return PpgSignalQuality.bad;
+ }
+ if (score < 0.62) {
+ return PpgSignalQuality.fair;
+ }
+ return PpgSignalQuality.good;
+ }
+
+ ({double score, double averageMotion}) _estimateRecentWaveformQualityScore(
+ List<_MotionAwareSample> samples, {
+ required int latestTimestamp,
+ required double ticksPerSecond,
+ }) {
+ final qualityWindowTicks = 3.0 * ticksPerSecond;
+ final recent = samples
+ .where(
+ (sample) => sample.timestamp >= latestTimestamp - qualityWindowTicks,
+ )
+ .toList(growable: false);
+ final minimumSamples = max(8, (sampleFreq * 1.2).round());
+ if (recent.length < minimumSamples) {
+ return (score: 0.0, averageMotion: 0.0);
+ }
+
+ // Score waveform quality from the filtered signal that is displayed.
+ final filteredValues =
+ recent.map((sample) => sample.signal).toList(growable: false);
+ final meanFilteredAbs =
+ filteredValues.map((value) => value.abs()).reduce((a, b) => a + b) /
+ filteredValues.length;
+ if (!meanFilteredAbs.isFinite || meanFilteredAbs <= 1e-6) {
+ return (score: 0.0, averageMotion: 0.0);
+ }
+
+ final minFiltered = filteredValues.reduce(min);
+ final maxFiltered = filteredValues.reduce(max);
+ final rangeFiltered = maxFiltered - minFiltered;
+ final stdFiltered = _standardDeviation(filteredValues);
+
+ final averageMotion =
+ recent.map((sample) => sample.motionLevel).reduce((a, b) => a + b) /
+ recent.length;
+ // Filtered signal is normalized/bounded, so fixed thresholds are stable.
+ final rangeScore = ((rangeFiltered - 0.08) / 0.95).clamp(0.0, 1.0);
+ final stdScore = ((stdFiltered - 0.025) / 0.30).clamp(0.0, 1.0);
+ final motionScore = (1.0 - (averageMotion / 2.2)).clamp(0.0, 1.0);
+
+ var score = ((0.45 * rangeScore) + (0.35 * stdScore) + (0.20 * motionScore))
+ .clamp(0.0, 1.0);
+
+ // Make accelerometer motion a strong quality prior:
+ // heavy movement should almost always mark PPG quality as bad.
+ if (averageMotion >= 1.45) {
+ score = min(score, 0.10);
+ } else if (averageMotion >= 1.12) {
+ score = min(score, 0.24);
+ } else if (averageMotion >= 0.88) {
+ score = min(score, 0.45);
+ }
+
+ return (
+ score: score,
+ averageMotion: averageMotion,
+ );
+ }
+
+ ({bool hasFreshTemperatureSample, bool inEarByTemperature})
+ _estimateInEarByOpticalTemperature({
+ required int latestTimestamp,
+ required double ticksPerSecond,
+ }) {
+ if (opticalTemperatureStream == null) {
+ return (hasFreshTemperatureSample: false, inEarByTemperature: true);
+ }
+
+ final latestTemperature = _latestOpticalTemperatureCelsius;
+ final latestTemperatureTimestamp = _latestOpticalTemperatureTimestamp;
+ if (latestTemperature == null || latestTemperatureTimestamp == null) {
+ return (hasFreshTemperatureSample: false, inEarByTemperature: false);
+ }
+
+ final maxAgeTicks = _maxTemperatureSampleAgeSec * ticksPerSecond;
+ if (latestTimestamp - latestTemperatureTimestamp > maxAgeTicks) {
+ return (hasFreshTemperatureSample: false, inEarByTemperature: false);
+ }
+
+ return (
+ hasFreshTemperatureSample: true,
+ inEarByTemperature:
+ latestTemperature >= _reasonableInEarTemperatureCelsius,
+ );
+ }
+
+ Stream _createVitalsStream() async* {
+ final ticksPerSecond = pow(10, -timestampExponent).toDouble();
+ final ticksToMilliseconds = pow(10, timestampExponent + 3).toDouble();
+ final windowDurationTicks = 10.0 * ticksPerSecond;
+ final minimumWindowTicks = 4.0 * ticksPerSecond;
+ final evaluationPeriodTicks = max(1.0, ticksPerSecond);
+ final buffer = <_MotionAwareSample>[];
+ var lastEvaluationTick = double.negativeInfinity;
+
+ await for (final sample in _sampleStream) {
+ buffer.add(sample);
+ buffer.removeWhere(
+ (item) => item.timestamp < sample.timestamp - windowDurationTicks,
+ );
+
+ if ((sample.timestamp - lastEvaluationTick) < evaluationPeriodTicks) {
continue;
}
+ lastEvaluationTick = sample.timestamp.toDouble();
- List peakTimestamps = detectPeaks(buffer);
+ if (buffer.length < 20 ||
+ (buffer.last.timestamp - buffer.first.timestamp) <
+ minimumWindowTicks) {
+ yield const PpgVitals.invalid(
+ signalQuality: PpgSignalQuality.unavailable,
+ );
+ continue;
+ }
+ final recentQuality = _estimateRecentWaveformQualityScore(
+ buffer,
+ latestTimestamp: sample.timestamp,
+ ticksPerSecond: ticksPerSecond,
+ );
+ var qualityScore = recentQuality.score;
+ final recentMotion = recentQuality.averageMotion;
- // Need at least 2 peaks to compute HR
- if (peakTimestamps.length < 2) {
- _logger.w("not enough peaks ${peakTimestamps.length}, in buffer of size ${buffer.length}");
+ if (recentMotion >= 1.45) {
+ yield const PpgVitals.invalid(
+ signalQuality: PpgSignalQuality.bad,
+ );
continue;
}
- final ibiList = [];
- for (int i = 1; i < peakTimestamps.length; i++) {
- final ibi = (peakTimestamps[i] - peakTimestamps[i - 1]).toDouble();
- ibiList.add(ibi);
+ final inEarTemperature = _estimateInEarByOpticalTemperature(
+ latestTimestamp: sample.timestamp,
+ ticksPerSecond: ticksPerSecond,
+ );
+ if (opticalTemperatureStream != null) {
+ if (!inEarTemperature.hasFreshTemperatureSample) {
+ yield const PpgVitals.invalid(
+ signalQuality: PpgSignalQuality.unavailable,
+ );
+ continue;
+ }
+ if (!inEarTemperature.inEarByTemperature) {
+ yield const PpgVitals.invalid(
+ signalQuality: PpgSignalQuality.bad,
+ );
+ continue;
+ }
}
- final avgIbi = ibiList.reduce((a, b) => a + b) / ibiList.length;
- if (avgIbi <= 0 || avgIbi.isNaN || avgIbi.isInfinite) {
- _logger.w("unexpected avgIbi: $avgIbi");
+ final effectiveSampleFreqHz = _estimateEffectiveSampleFreqHz(
+ buffer,
+ ticksPerSecond: ticksPerSecond,
+ );
+ final peakEstimate = _estimateHeartRateByPeak(
+ buffer,
+ ticksPerSecond: ticksPerSecond,
+ estimatedSampleFreqHz: effectiveSampleFreqHz,
+ );
+ final peaks = peakEstimate.peakTimestamps;
+ final peakHeartRate = peakEstimate.heartRateBpm;
+
+ final peakScore = (peaks.length / 8.0).clamp(0.0, 1.0);
+ qualityScore =
+ ((0.78 * qualityScore) + (0.22 * peakScore)).clamp(0.0, 1.0);
+
+ final ibiTicks = [];
+ for (var i = 1; i < peaks.length; i++) {
+ final interval = (peaks[i] - peaks[i - 1]).toDouble();
+ final intervalSeconds = interval / ticksPerSecond;
+ if (interval > 0 &&
+ intervalSeconds >= _minBeatIntervalSec &&
+ intervalSeconds <= _maxBeatIntervalSec) {
+ ibiTicks.add(interval);
+ }
+ }
+ if (ibiTicks.length >= 2) {
+ final robustIbiTicks = _removeIbiOutliers(ibiTicks);
+ final meanIbiTicks =
+ robustIbiTicks.reduce((a, b) => a + b) / robustIbiTicks.length;
+ if (meanIbiTicks.isFinite && meanIbiTicks > 0) {
+ final ibiVariation =
+ _standardDeviation(robustIbiTicks) / meanIbiTicks;
+ if (ibiVariation.isFinite) {
+ final rhythmScore = (1.0 - (ibiVariation / 0.55)).clamp(0.0, 1.0);
+ qualityScore =
+ ((0.78 * qualityScore) + (0.22 * rhythmScore)).clamp(0.0, 1.0);
+ }
+ }
+ }
+
+ final classifiedQuality = _classifyQuality(qualityScore);
+ if (classifiedQuality == PpgSignalQuality.bad ||
+ classifiedQuality == PpgSignalQuality.unavailable) {
+ yield PpgVitals.invalid(
+ signalQuality: classifiedQuality,
+ );
continue;
}
- final hr = 60 * timestampFactor / avgIbi;
- final smoothedHr = _kalmanUpdate(hr);
+ if (peakHeartRate == null) {
+ yield PpgVitals.invalid(
+ signalQuality: classifiedQuality,
+ );
+ continue;
+ }
+ final smoothedHeartRate = _kalmanUpdateHeartRate(peakHeartRate);
- if (smoothedHr > 30 && smoothedHr < 220) {
- yield smoothedHr;
+ double? smoothedHrvMs;
+ if (ibiTicks.length >= 2) {
+ final robustIbiTicks = _removeIbiOutliers(ibiTicks);
+ final rmssdTicks = _computeRmssd(robustIbiTicks);
+ if (rmssdTicks != null && rmssdTicks.isFinite && rmssdTicks > 0) {
+ final hrvMs = rmssdTicks * ticksToMilliseconds;
+ if (hrvMs.isFinite && hrvMs >= 5 && hrvMs <= 300) {
+ smoothedHrvMs = _smoothHrv(hrvMs);
+ }
+ }
}
- // Optional: clear buffer for independent windows
- buffer.clear();
+ yield PpgVitals(
+ heartRateBpm: smoothedHeartRate,
+ hrvRmssdMs: smoothedHrvMs,
+ signalQuality: classifiedQuality,
+ );
}
}
}
+
+class _AdaptiveOpticalChannelSelector {
+ bool _isInitialized = false;
+ double _meanGreen = 0;
+ double _meanRed = 0;
+ double _meanIr = 0;
+ double _energyGreen = 0;
+ double _energyRed = 0;
+ double _energyIr = 0;
+
+ double select(PpgOpticalSample sample) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _meanGreen = sample.green;
+ _meanRed = sample.red;
+ _meanIr = sample.ir;
+ } else {
+ _meanGreen = _ema(_meanGreen, sample.green, 0.02);
+ _meanRed = _ema(_meanRed, sample.red, 0.02);
+ _meanIr = _ema(_meanIr, sample.ir, 0.02);
+ }
+
+ _energyGreen = _ema(_energyGreen, (sample.green - _meanGreen).abs(), 0.08);
+ _energyRed = _ema(_energyRed, (sample.red - _meanRed).abs(), 0.08);
+ _energyIr = _ema(_energyIr, (sample.ir - _meanIr).abs(), 0.08);
+
+ final strongestAltEnergy = max(_energyRed, _energyIr);
+ final greenLikelyMissing = sample.green.abs() < 1e-6 &&
+ (sample.red.abs() > 1e-3 || sample.ir.abs() > 1e-3);
+ final greenWeakComparedToAlternatives =
+ strongestAltEnergy > 1e-6 && _energyGreen < (strongestAltEnergy * 0.35);
+ if (greenLikelyMissing || greenWeakComparedToAlternatives) {
+ final preferRed = _energyRed >= _energyIr;
+ final fallback = preferRed ? sample.red : sample.ir;
+ if (fallback.isFinite) {
+ return fallback;
+ }
+ }
+
+ if (sample.green.isFinite) {
+ return sample.green;
+ }
+ if (sample.red.isFinite && sample.ir.isFinite) {
+ return sample.red.abs() >= sample.ir.abs() ? sample.red : sample.ir;
+ }
+ if (sample.red.isFinite) {
+ return sample.red;
+ }
+ if (sample.ir.isFinite) {
+ return sample.ir;
+ }
+ return 0;
+ }
+
+ double _ema(double state, double value, double alpha) {
+ return (state * (1.0 - alpha)) + (value * alpha);
+ }
+}
+
+class _MotionAwareSample {
+ final int timestamp;
+ final double rawGreen;
+ final double rawAmbient;
+ final double signal;
+ final double displaySignal;
+ final double motionLevel;
+
+ const _MotionAwareSample({
+ required this.timestamp,
+ required this.rawGreen,
+ required this.rawAmbient,
+ required this.signal,
+ required this.displaySignal,
+ required this.motionLevel,
+ });
+}
+
+class _DisplayBaselineDetrender {
+ final double _alpha;
+ bool _isInitialized = false;
+ double _baseline = 0;
+ double _lastOutput = 0;
+
+ _DisplayBaselineDetrender({
+ required double sampleFreqHz,
+ double timeConstantSeconds = 3.0,
+ }) : _alpha = _computeAlpha(
+ sampleFreqHz: sampleFreqHz,
+ timeConstantSeconds: timeConstantSeconds,
+ );
+
+ double filter(double value) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _baseline = value;
+ _lastOutput = 0;
+ return 0;
+ }
+
+ _baseline = _baseline + (_alpha * (value - _baseline));
+ final detrended = value - _baseline;
+ _lastOutput = (_lastOutput * 0.82) + (detrended * 0.18);
+ return _lastOutput;
+ }
+
+ static double _computeAlpha({
+ required double sampleFreqHz,
+ required double timeConstantSeconds,
+ }) {
+ final safeSampleFreq =
+ sampleFreqHz.isFinite && sampleFreqHz > 0 ? sampleFreqHz : 50.0;
+ final safeTau = timeConstantSeconds.isFinite && timeConstantSeconds > 0
+ ? timeConstantSeconds
+ : 3.0;
+ final alpha = 1 - exp(-1 / (safeTau * safeSampleFreq));
+ return alpha.clamp(0.001, 0.2);
+ }
+}
+
+class _AmbientLightCanceler {
+ bool _isInitialized = false;
+ double _meanGreen = 0;
+ double _meanAmbient = 0;
+ double _ambientVariance = 1.0;
+ double _greenAmbientCovariance = 0.0;
+ double _ambientGain = 0.65;
+
+ double filter({
+ required double green,
+ required double ambient,
+ }) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _meanGreen = green;
+ _meanAmbient = ambient;
+ return 0;
+ }
+
+ const meanAlpha = 0.02;
+ const covarianceAlpha = 0.04;
+
+ _meanGreen = (_meanGreen * (1.0 - meanAlpha)) + (green * meanAlpha);
+ _meanAmbient = (_meanAmbient * (1.0 - meanAlpha)) + (ambient * meanAlpha);
+
+ final centeredGreen = green - _meanGreen;
+ final centeredAmbient = ambient - _meanAmbient;
+
+ _ambientVariance = (_ambientVariance * (1.0 - covarianceAlpha)) +
+ ((centeredAmbient * centeredAmbient) * covarianceAlpha);
+ _greenAmbientCovariance =
+ (_greenAmbientCovariance * (1.0 - covarianceAlpha)) +
+ ((centeredGreen * centeredAmbient) * covarianceAlpha);
+
+ if (_ambientVariance > 1e-6) {
+ _ambientGain = (_greenAmbientCovariance / _ambientVariance).clamp(
+ 0.0,
+ 2.0,
+ );
+ }
+
+ final cleaned = centeredGreen - (_ambientGain * centeredAmbient);
+ return -cleaned;
+ }
+}
+
+class _MultiReferenceMotionCanceler {
+ final _PadasipStyleMultiInputNlmsCanceler _canceler =
+ _PadasipStyleMultiInputNlmsCanceler(
+ tapsPerAxis: 8,
+ );
+
+ bool _isInitialized = false;
+ double _gravityX = 0;
+ double _gravityY = 0;
+ double _gravityZ = 0;
+ double _dynamicX = 0;
+ double _dynamicY = 0;
+ double _dynamicZ = 0;
+ double _referenceScaleX = 0.2;
+ double _referenceScaleY = 0.2;
+ double _referenceScaleZ = 0.2;
+
+ void updateMotion(PpgMotionSample sample) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _gravityX = sample.x;
+ _gravityY = sample.y;
+ _gravityZ = sample.z;
+ _dynamicX = 0;
+ _dynamicY = 0;
+ _dynamicZ = 0;
+ _referenceScaleX = 0.2;
+ _referenceScaleY = 0.2;
+ _referenceScaleZ = 0.2;
+ return;
+ }
+
+ const gravityAlpha = 0.04;
+ const dynamicAlpha = 0.18;
+ const scaleAlpha = 0.06;
+
+ _gravityX = (_gravityX * (1.0 - gravityAlpha)) + (sample.x * gravityAlpha);
+ _gravityY = (_gravityY * (1.0 - gravityAlpha)) + (sample.y * gravityAlpha);
+ _gravityZ = (_gravityZ * (1.0 - gravityAlpha)) + (sample.z * gravityAlpha);
+
+ final hpX = sample.x - _gravityX;
+ final hpY = sample.y - _gravityY;
+ final hpZ = sample.z - _gravityZ;
+ _referenceScaleX =
+ (_referenceScaleX * (1.0 - scaleAlpha)) + (hpX.abs() * scaleAlpha);
+ _referenceScaleY =
+ (_referenceScaleY * (1.0 - scaleAlpha)) + (hpY.abs() * scaleAlpha);
+ _referenceScaleZ =
+ (_referenceScaleZ * (1.0 - scaleAlpha)) + (hpZ.abs() * scaleAlpha);
+
+ final normalizedHpX = hpX / max(0.08, _referenceScaleX);
+ final normalizedHpY = hpY / max(0.08, _referenceScaleY);
+ final normalizedHpZ = hpZ / max(0.08, _referenceScaleZ);
+ _dynamicX =
+ (_dynamicX * (1.0 - dynamicAlpha)) + (normalizedHpX * dynamicAlpha);
+ _dynamicY =
+ (_dynamicY * (1.0 - dynamicAlpha)) + (normalizedHpY * dynamicAlpha);
+ _dynamicZ =
+ (_dynamicZ * (1.0 - dynamicAlpha)) + (normalizedHpZ * dynamicAlpha);
+ }
+
+ double filter(
+ double value, {
+ required double motionLevel,
+ }) {
+ if (!_isInitialized) {
+ return value;
+ }
+ return _canceler.filter(
+ signal: value,
+ referenceX: _dynamicX,
+ referenceY: _dynamicY,
+ referenceZ: _dynamicZ,
+ motionLevel: motionLevel,
+ );
+ }
+}
+
+/// Multi-input normalized LMS adaptive canceller adapted from the update rule
+/// used in the open-source `padasip` NLMS implementation (MIT):
+/// https://github.com/matousc89/padasip
+class _PadasipStyleMultiInputNlmsCanceler {
+ static const double _baseMu = 0.08;
+ static const double _maxMu = 1.0;
+ static const double _epsilon = 1e-6;
+ static const double _leakage = 0.00025;
+
+ final int tapsPerAxis;
+ late final List _weights;
+ late final List _historyX;
+ late final List _historyY;
+ late final List _historyZ;
+ late final List _featureVector;
+
+ bool _isInitialized = false;
+ double _smoothedError = 0;
+
+ _PadasipStyleMultiInputNlmsCanceler({
+ required this.tapsPerAxis,
+ }) {
+ final length = max(3, tapsPerAxis * 3);
+ _weights = List.filled(length, 0, growable: false);
+ _historyX = List.filled(tapsPerAxis, 0, growable: false);
+ _historyY = List.filled(tapsPerAxis, 0, growable: false);
+ _historyZ = List.filled(tapsPerAxis, 0, growable: false);
+ _featureVector = List.filled(length, 0, growable: false);
+ }
+
+ double filter({
+ required double signal,
+ required double referenceX,
+ required double referenceY,
+ required double referenceZ,
+ required double motionLevel,
+ }) {
+ if (!signal.isFinite ||
+ !referenceX.isFinite ||
+ !referenceY.isFinite ||
+ !referenceZ.isFinite) {
+ return signal;
+ }
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _smoothedError = signal;
+ }
+
+ _push(_historyX, referenceX);
+ _push(_historyY, referenceY);
+ _push(_historyZ, referenceZ);
+ _composeFeatureVector();
+
+ final predictedNoise = _dot(_weights, _featureVector);
+ final error = signal - predictedNoise;
+ final norm = _epsilon + _dot(_featureVector, _featureVector);
+
+ final motionScale = (motionLevel / 1.6).clamp(0.0, 1.0);
+ final mu = _baseMu + ((_maxMu - _baseMu) * motionScale);
+ final step = (mu * error) / norm;
+
+ for (var i = 0; i < _weights.length; i++) {
+ final updatedWeight =
+ ((1.0 - _leakage) * _weights[i]) + (step * _featureVector[i]);
+ _weights[i] = updatedWeight.clamp(-4.0, 4.0);
+ }
+
+ final smoothAlpha = motionLevel > 1.0 ? 0.22 : 0.11;
+ _smoothedError =
+ (_smoothedError * (1.0 - smoothAlpha)) + (error * smoothAlpha);
+ return _smoothedError;
+ }
+
+ void _push(List history, double sample) {
+ for (var i = history.length - 1; i > 0; i--) {
+ history[i] = history[i - 1];
+ }
+ history[0] = sample;
+ }
+
+ void _composeFeatureVector() {
+ var index = 0;
+ for (var i = 0; i < tapsPerAxis; i++) {
+ _featureVector[index++] = _historyX[i];
+ }
+ for (var i = 0; i < tapsPerAxis; i++) {
+ _featureVector[index++] = _historyY[i];
+ }
+ for (var i = 0; i < tapsPerAxis; i++) {
+ _featureVector[index++] = _historyZ[i];
+ }
+ }
+
+ double _dot(List a, List b) {
+ var sum = 0.0;
+ for (var i = 0; i < a.length; i++) {
+ sum += a[i] * b[i];
+ }
+ return sum;
+ }
+}
+
+class _MotionNoiseSuppressor {
+ static const int _windowSize = 25;
+ static const double _baseOutlierSigma = 3.0;
+ static const double _baseStepScale = 5.5;
+ static const double _baseAlpha = 0.26;
+
+ final ListQueue _history = ListQueue();
+
+ bool _isInitialized = false;
+ double _lastOutput = 0;
+ double _gravityMagnitude = 9.81;
+ double _motionLevel = 0;
+
+ double get motionLevel => _motionLevel;
+
+ void updateMotionMagnitude(double magnitude) {
+ if (!_isInitialized) {
+ _gravityMagnitude = magnitude;
+ _motionLevel = 0;
+ return;
+ }
+
+ _gravityMagnitude = (_gravityMagnitude * 0.96) + (magnitude * 0.04);
+ final dynamicMagnitude = (magnitude - _gravityMagnitude).abs();
+ final dynamicScale = max(0.08, _gravityMagnitude.abs() * 0.02);
+ final normalizedDynamicMagnitude = dynamicMagnitude / dynamicScale;
+ _motionLevel =
+ ((_motionLevel * 0.85) + (normalizedDynamicMagnitude * 0.15)).clamp(
+ 0.0,
+ 8.0,
+ );
+ }
+
+ double filter(double rawValue) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _lastOutput = rawValue;
+ _history
+ ..clear()
+ ..addAll(List.filled(_windowSize, rawValue));
+ return rawValue;
+ }
+
+ if (_history.length >= _windowSize) {
+ _history.removeFirst();
+ }
+ _history.add(rawValue);
+
+ final median = _medianOf(_history);
+ final mad = _medianOf(_history.map((value) => (value - median).abs()));
+ final sigma = max(1e-3, mad * 1.4826);
+
+ final motionFactor = 1.0 + min(_motionLevel / 0.85, 2.8);
+ final outlierSigma = _baseOutlierSigma / motionFactor;
+ final minBound = median - (sigma * outlierSigma);
+ final maxBound = median + (sigma * outlierSigma);
+ final clipped = rawValue.clamp(minBound, maxBound).toDouble();
+
+ final stepLimit = max(1e-3, (_baseStepScale * sigma) / motionFactor);
+ final stepped = _lastOutput +
+ (clipped - _lastOutput).clamp(-stepLimit, stepLimit).toDouble();
+
+ final alpha = (_baseAlpha / motionFactor).clamp(0.07, _baseAlpha);
+ final smoothed = _lastOutput + (alpha * (stepped - _lastOutput));
+ _lastOutput = smoothed;
+ return smoothed;
+ }
+
+ double _medianOf(Iterable values) {
+ final sorted = values.toList(growable: false)..sort();
+ if (sorted.isEmpty) {
+ return 0;
+ }
+ final middle = sorted.length ~/ 2;
+ if (sorted.length.isOdd) {
+ return sorted[middle];
+ }
+ return (sorted[middle - 1] + sorted[middle]) / 2;
+ }
+}
+
+class _BoundedSignalNormalizer {
+ bool _isInitialized = false;
+ double _center = 0;
+ double _envelope = 0.25;
+ double _lastOutput = 0;
+
+ double filter(
+ double value, {
+ required double motionLevel,
+ }) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _center = value;
+ _envelope = max(0.18, value.abs());
+ _lastOutput = 0;
+ return 0;
+ }
+
+ const centerAlpha = 0.01;
+ const envelopeAlpha = 0.02;
+
+ _center = (_center * (1.0 - centerAlpha)) + (value * centerAlpha);
+ final centered = value - _center;
+ _envelope =
+ (_envelope * (1.0 - envelopeAlpha)) + (centered.abs() * envelopeAlpha);
+
+ final normalized = centered / max(0.12, _envelope);
+ final maxAbs = motionLevel > 1.2 ? 1.15 : 1.45;
+ final clipped = normalized.clamp(-maxAbs, maxAbs).toDouble();
+
+ final alpha = motionLevel > 1.2 ? 0.12 : 0.22;
+ final smoothed = _lastOutput + (alpha * (clipped - _lastOutput));
+ _lastOutput = smoothed;
+ return smoothed;
+ }
+}
diff --git a/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart b/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart
index 0febd165..73f42fcf 100644
--- a/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart
+++ b/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart
@@ -1,122 +1,454 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_wearable/apps/heart_tracker/model/ppg_filter.dart';
import 'package:open_wearable/apps/heart_tracker/widgets/rowling_chart.dart';
+import 'package:open_wearable/models/wearable_display_group.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
+import 'package:open_wearable/widgets/devices/devices_page.dart';
import 'package:provider/provider.dart';
class HeartTrackerPage extends StatefulWidget {
+ final Wearable wearable;
final Sensor ppgSensor;
+ final Sensor? accelerometerSensor;
+ final Sensor? opticalTemperatureSensor;
- const HeartTrackerPage({super.key, required this.ppgSensor});
+ const HeartTrackerPage({
+ super.key,
+ required this.wearable,
+ required this.ppgSensor,
+ this.accelerometerSensor,
+ this.opticalTemperatureSensor,
+ });
@override
State createState() => _HeartTrackerPageState();
}
class _HeartTrackerPageState extends State {
- late final PpgFilter ppgFilter;
+ PpgFilter? _ppgFilter;
+ Stream<(int, double)>? _displayPpgSignalStream;
+ Stream? _heartRateStream;
+ Stream? _signalQualityStream;
+ SensorConfigurationProvider? _sensorConfigProvider;
@override
void initState() {
super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) {
+ return;
+ }
+ _initializePipeline();
+ });
+ }
- final sensor = widget.ppgSensor;
+ void _initializePipeline() {
+ final configProvider =
+ Provider.of(context, listen: false);
+ _sensorConfigProvider = configProvider;
+ final ppgSensor = widget.ppgSensor;
+ final accelerometerSensor = widget.accelerometerSensor;
+ final opticalTemperatureSensor = widget.opticalTemperatureSensor;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- SensorConfigurationProvider configProvider = Provider.of(context, listen: false);
- SensorConfiguration configuration = sensor.relatedConfigurations.first;
+ final sampleFreq = _configureSensorForStreaming(
+ ppgSensor,
+ configProvider,
+ fallbackFrequency: 50.0,
+ targetFrequencyHz: 50,
+ );
+ if (accelerometerSensor != null) {
+ _configureSensorForStreaming(
+ accelerometerSensor,
+ configProvider,
+ fallbackFrequency: 50.0,
+ targetFrequencyHz: 50,
+ );
+ }
+ if (opticalTemperatureSensor != null) {
+ _configureSensorForStreaming(
+ opticalTemperatureSensor,
+ configProvider,
+ fallbackFrequency: 5.0,
+ targetFrequencyHz: 5,
+ );
+ }
- if (configuration is ConfigurableSensorConfiguration &&
- configuration.availableOptions.contains(StreamSensorConfigOption())) {
- configProvider.addSensorConfigurationOption(configuration, StreamSensorConfigOption());
- }
+ final ppgStream = ppgSensor.sensorStream
+ .map((data) {
+ final values = _sensorValuesAsDoubles(data);
+ if (values == null) {
+ return null;
+ }
+ return _extractPpgOpticalSample(ppgSensor, data, values);
+ })
+ .where((sample) => sample != null)
+ .cast()
+ .asBroadcastStream();
- List values = configProvider.getSensorConfigurationValues(configuration, distinct: true);
- configProvider.addSensorConfiguration(configuration, values.first);
- SensorConfigurationValue selectedValue = configProvider.getSelectedConfigurationValue(configuration)!;
+ Stream? accelerometerMotionStream;
+ if (accelerometerSensor != null) {
+ accelerometerMotionStream = accelerometerSensor.sensorStream
+ .map((data) {
+ final values = _sensorValuesAsDoubles(data);
+ if (values == null) {
+ return null;
+ }
+ return _extractImuMotionSample(
+ accelerometerSensor,
+ data,
+ values,
+ );
+ })
+ .where((sample) => sample != null)
+ .cast()
+ .asBroadcastStream();
+ }
+
+ Stream? opticalTemperatureStream;
+ if (opticalTemperatureSensor != null) {
+ opticalTemperatureStream = opticalTemperatureSensor.sensorStream
+ .map((data) {
+ final values = _sensorValuesAsDoubles(data);
+ if (values == null) {
+ return null;
+ }
+ return _extractOpticalTemperatureSample(
+ opticalTemperatureSensor,
+ data,
+ values,
+ );
+ })
+ .where((sample) => sample != null)
+ .cast()
+ .asBroadcastStream();
+ }
+
+ final ppgFilter = PpgFilter(
+ inputStream: ppgStream,
+ motionStream: accelerometerMotionStream,
+ opticalTemperatureStream: opticalTemperatureStream,
+ sampleFreq: sampleFreq,
+ timestampExponent: ppgSensor.timestampExponent,
+ );
+ ppgFilter.initialize();
+ if (!mounted) {
+ ppgFilter.dispose();
+ return;
+ }
+ setState(() {
+ _displayPpgSignalStream = ppgFilter.displaySignalStream;
+ _heartRateStream = ppgFilter.heartRateStream;
+ _signalQualityStream = ppgFilter.signalQualityStream;
+ _ppgFilter = ppgFilter;
+ });
+ }
+
+ @override
+ void dispose() {
+ final configProvider = _sensorConfigProvider;
+ if (configProvider != null) {
+ unawaited(configProvider.turnOffAllSensors());
+ }
+ _ppgFilter?.dispose();
+ super.dispose();
+ }
+
+ double _configureSensorForStreaming(
+ Sensor sensor,
+ SensorConfigurationProvider configProvider, {
+ required double fallbackFrequency,
+ required int targetFrequencyHz,
+ }) {
+ final configuration = sensor.relatedConfigurations.firstOrNull;
+ if (configuration == null) {
+ return fallbackFrequency;
+ }
+
+ if (configuration is ConfigurableSensorConfiguration &&
+ configuration.availableOptions.contains(StreamSensorConfigOption())) {
+ configProvider.addSensorConfigurationOption(
+ configuration,
+ StreamSensorConfigOption(),
+ markPending: false,
+ );
+ }
+
+ final values = configProvider.getSensorConfigurationValues(
+ configuration,
+ distinct: true,
+ );
+ SensorConfigurationValue? appliedValue;
+ if (values.isNotEmpty) {
+ appliedValue = _selectBestConfigurationValue(
+ values,
+ targetFrequencyHz: targetFrequencyHz,
+ );
+ configProvider.addSensorConfiguration(
+ configuration,
+ appliedValue,
+ markPending: false,
+ );
+ }
+
+ final selectedValue =
+ configProvider.getSelectedConfigurationValue(configuration) ??
+ appliedValue;
+ if (selectedValue != null) {
configuration.setConfiguration(selectedValue);
+ }
+
+ if (selectedValue is SensorFrequencyConfigurationValue) {
+ return selectedValue.frequencyHz;
+ }
+
+ return fallbackFrequency;
+ }
+
+ SensorConfigurationValue _selectBestConfigurationValue(
+ List values, {
+ required int targetFrequencyHz,
+ }) {
+ final frequencyValues =
+ values.whereType().toList();
+ if (frequencyValues.isEmpty) {
+ return values.first;
+ }
- double sampleFreq;
- if (selectedValue is SensorFrequencyConfigurationValue) {
- sampleFreq = selectedValue.frequencyHz;
- } else {
- sampleFreq = 25;
+ SensorFrequencyConfigurationValue? nextBigger;
+ SensorFrequencyConfigurationValue? maxValue;
+ for (final value in frequencyValues) {
+ if (maxValue == null || value.frequencyHz > maxValue.frequencyHz) {
+ maxValue = value;
}
+ if (value.frequencyHz >= targetFrequencyHz &&
+ (nextBigger == null || value.frequencyHz < nextBigger.frequencyHz)) {
+ nextBigger = value;
+ }
+ }
- setState(() {
- ppgFilter = PpgFilter(
- inputStream: sensor.sensorStream.asyncMap((data) {
- SensorDoubleValue sensorData = data as SensorDoubleValue;
- return (
- sensorData.timestamp,
- -(sensorData.values[2] + sensorData.values[3])
- );
- }).asBroadcastStream(),
- sampleFreq: sampleFreq,
- timestampExponent: sensor.timestampExponent,
- );
- });
- });
+ return nextBigger ?? maxValue ?? values.first;
+ }
+
+ List? _sensorValuesAsDoubles(SensorValue data) {
+ if (data is SensorDoubleValue) {
+ return data.values;
+ }
+ if (data is SensorIntValue) {
+ return data.values
+ .map((value) => value.toDouble())
+ .toList(growable: false);
+ }
+ return null;
+ }
+
+ PpgOpticalSample? _extractPpgOpticalSample(
+ Sensor sensor,
+ SensorValue data,
+ List values,
+ ) {
+ if (values.isEmpty) {
+ return null;
+ }
+
+ int? findAxisIndex(List keywords) {
+ for (var i = 0; i < sensor.axisNames.length; i++) {
+ final axis = sensor.axisNames[i].toLowerCase();
+ if (keywords.any(axis.contains)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ double valueAt(int? index, double fallback) {
+ if (index != null && index >= 0 && index < values.length) {
+ return values[index];
+ }
+ return fallback;
+ }
+
+ final fallbackRed = values[0];
+ final fallbackIr = values.length > 1 ? values[1] : fallbackRed;
+ final fallbackGreen = values.length > 2 ? values[2] : fallbackRed;
+ final fallbackAmbient = values.length > 3 ? values[3] : 0.0;
+
+ // Usually channels are [red, ir, green, ambient], but we prefer axis-name
+ // matching when available to avoid firmware-order mismatches.
+ final red = valueAt(findAxisIndex(['red']), fallbackRed);
+ final ir = valueAt(findAxisIndex(['ir', 'infrared']), fallbackIr);
+ final green = valueAt(findAxisIndex(['green']), fallbackGreen);
+ final ambient = valueAt(findAxisIndex(['ambient']), fallbackAmbient);
+
+ return PpgOpticalSample(
+ timestamp: data.timestamp,
+ red: red,
+ ir: ir,
+ green: green,
+ ambient: ambient,
+ );
+ }
+
+ PpgMotionSample _extractImuMotionSample(
+ Sensor sensor,
+ SensorValue data,
+ List values,
+ ) {
+ int? findAxisIndex(List keywords) {
+ for (var i = 0; i < sensor.axisNames.length; i++) {
+ final axis = sensor.axisNames[i].toLowerCase();
+ if (keywords.any(axis.contains)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ double valueAt(int? index, double fallback) {
+ if (index != null && index >= 0 && index < values.length) {
+ return values[index];
+ }
+ return fallback;
+ }
+
+ final fallbackX = values.isNotEmpty ? values[0] : 0.0;
+ final fallbackY = values.length > 1 ? values[1] : 0.0;
+ final fallbackZ = values.length > 2 ? values[2] : 0.0;
+
+ final x = valueAt(findAxisIndex(['x']), fallbackX);
+ final y = valueAt(findAxisIndex(['y']), fallbackY);
+ final z = valueAt(findAxisIndex(['z']), fallbackZ);
+
+ return PpgMotionSample(
+ timestamp: data.timestamp,
+ x: x,
+ y: y,
+ z: z,
+ );
+ }
+
+ PpgTemperatureSample? _extractOpticalTemperatureSample(
+ Sensor sensor,
+ SensorValue data,
+ List values,
+ ) {
+ if (values.isEmpty) {
+ return null;
+ }
+
+ int? findAxisIndex(List keywords) {
+ for (var i = 0; i < sensor.axisNames.length; i++) {
+ final axis = sensor.axisNames[i].toLowerCase();
+ if (keywords.any(axis.contains)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ final axisIndex = findAxisIndex(['temp', 'temperature']) ?? 0;
+ if (axisIndex < 0 || axisIndex >= values.length) {
+ return null;
+ }
+ final celsius = values[axisIndex];
+ if (!celsius.isFinite) {
+ return null;
+ }
+ return PpgTemperatureSample(
+ timestamp: data.timestamp,
+ celsius: celsius,
+ );
}
@override
Widget build(BuildContext context) {
+ final displayPpgSignalStream = _displayPpgSignalStream;
+ final heartRateStream = _heartRateStream;
+ final signalQualityStream = _signalQualityStream;
return PlatformScaffold(
appBar: PlatformAppBar(
- title: PlatformText("Heart Tracker"),
- ),
- body: Padding(
- padding: EdgeInsets.symmetric(horizontal: 10),
- child: ppgFilterWidget(),
+ title: PlatformText('Heart Tracker'),
),
+ body: displayPpgSignalStream == null ||
+ heartRateStream == null ||
+ signalQualityStream == null
+ ? const Center(child: PlatformCircularProgressIndicator())
+ : _buildContent(
+ context,
+ displayPpgSignalStream,
+ heartRateStream,
+ signalQualityStream,
+ ),
);
}
- Widget ppgFilterWidget() {
- if (!mounted) {
- return Center(child: PlatformCircularProgressIndicator());
- }
-
+ Widget _buildContent(
+ BuildContext context,
+ Stream<(int, double)> displayPpgSignalStream,
+ Stream heartRateStream,
+ Stream signalQualityStream,
+ ) {
return ListView(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 20),
children: [
- StreamBuilder(
- stream: ppgFilter.heartRateStream,
+ DeviceRow(
+ group: WearableDisplayGroup.single(wearable: widget.wearable),
+ ),
+ const SizedBox(height: 12),
+ StreamBuilder(
+ stream: signalQualityStream,
+ initialData: PpgSignalQuality.unavailable,
builder: (context, snapshot) {
- double bpm = snapshot.data ?? double.nan;
- return Padding(
- padding: EdgeInsets.all(10),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- // BeatingHeart(bpm: bpm.isFinite ? bpm : 60),
- PlatformText(
- "${bpm.isNaN ? "--" : bpm.toStringAsFixed(0)} BPM",
- style: Theme.of(context).textTheme.titleLarge,
- ),
- ],
- ),
+ final quality = snapshot.data ?? PpgSignalQuality.unavailable;
+ return _SignalQualityCard(quality: quality);
+ },
+ ),
+ const SizedBox(height: 12),
+ StreamBuilder(
+ stream: heartRateStream,
+ builder: (context, snapshot) {
+ final bpm = snapshot.data;
+ return _MetricCard(
+ title: 'Heart Rate',
+ icon: Icons.favorite_rounded,
+ value:
+ bpm != null && bpm.isFinite ? bpm.toStringAsFixed(0) : '--',
+ unit: 'BPM',
);
},
),
- Card(
- child: Column(
+ const SizedBox(height: 12),
+ _SignalPanelCard(
+ title: 'Filtered PPG',
+ subtitle: 'Live PPG with a basic pulse-band band-pass filter '
+ '(0.5-3.2 Hz).',
+ icon: Icons.show_chart_rounded,
+ chartStream: displayPpgSignalStream,
+ timestampExponent: widget.ppgSensor.timestampExponent,
+ fixedMeasureMin: null,
+ fixedMeasureMax: null,
+ ),
+ const SizedBox(height: 12),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Padding(
- padding: EdgeInsets.all(10),
- child: PlatformText(
- "Blood Flow",
- style: Theme.of(context).textTheme.titleMedium,
- ),
+ Icon(
+ Icons.warning_amber_rounded,
+ size: 13,
+ color: Theme.of(context).colorScheme.error,
),
- SizedBox(
- height: 200,
- child: RollingChart(
- dataSteam: ppgFilter.filteredStream,
- timestampExponent: widget.ppgSensor.timestampExponent,
- timeWindow: 5,
+ const SizedBox(width: 5),
+ Expanded(
+ child: Text(
+ 'This view is for demonstration purposes only. It is not a medical device and must not be used for diagnosis, treatment, or emergency decisions.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
),
),
],
@@ -126,3 +458,241 @@ class _HeartTrackerPageState extends State {
);
}
}
+
+class _MetricCard extends StatelessWidget {
+ final String title;
+ final IconData icon;
+ final String value;
+ final String unit;
+
+ const _MetricCard({
+ required this.title,
+ required this.icon,
+ required this.value,
+ required this.unit,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ icon,
+ size: 18,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ title,
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ value,
+ style: Theme.of(context).textTheme.headlineSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(width: 4),
+ Padding(
+ padding: const EdgeInsets.only(bottom: 3),
+ child: Text(
+ unit,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _SignalPanelCard extends StatelessWidget {
+ final String title;
+ final String subtitle;
+ final IconData icon;
+ final Stream<(int, double)> chartStream;
+ final int timestampExponent;
+ final double? fixedMeasureMin;
+ final double? fixedMeasureMax;
+
+ const _SignalPanelCard({
+ required this.title,
+ required this.subtitle,
+ required this.icon,
+ required this.chartStream,
+ required this.timestampExponent,
+ this.fixedMeasureMin,
+ this.fixedMeasureMax,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ icon,
+ size: 18,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ title,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ subtitle,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 10),
+ SizedBox(
+ height: 88,
+ child: RollingChart(
+ dataSteam: chartStream,
+ timestampExponent: timestampExponent,
+ timeWindow: 5,
+ showXAxis: false,
+ showYAxis: false,
+ fixedMeasureMin: fixedMeasureMin,
+ fixedMeasureMax: fixedMeasureMax,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _SignalQualityCard extends StatelessWidget {
+ final PpgSignalQuality quality;
+
+ const _SignalQualityCard({
+ required this.quality,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final (label, hint, icon, color) = _presentQuality(colorScheme);
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(
+ children: [
+ Icon(
+ icon,
+ size: 18,
+ color: color,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ 'Heartbeat Signal',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ Container(
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 4,
+ ),
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 6),
+ Text(
+ hint,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ (String, String, IconData, Color) _presentQuality(ColorScheme colors) {
+ switch (quality) {
+ case PpgSignalQuality.unavailable:
+ return (
+ 'Unavailable',
+ 'No stable heartbeat waveform yet. Ensure stable wearable placement.',
+ Icons.portable_wifi_off_rounded,
+ colors.onSurfaceVariant,
+ );
+ case PpgSignalQuality.bad:
+ return (
+ 'Bad',
+ 'Signal is noisy. Reduce motion and improve wearable contact.',
+ Icons.signal_cellular_connected_no_internet_4_bar_rounded,
+ colors.error,
+ );
+ case PpgSignalQuality.fair:
+ return (
+ 'Fair',
+ 'Heartbeat is partially visible. Hold still for a clearer reading.',
+ Icons.network_check_rounded,
+ Colors.orange.shade700,
+ );
+ case PpgSignalQuality.good:
+ return (
+ 'Good',
+ 'Signal quality is good for heart-rate estimation.',
+ Icons.check_circle_rounded,
+ Colors.green.shade700,
+ );
+ }
+ }
+}
diff --git a/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart b/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart
index 6455f325..7eb575bf 100644
--- a/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart
+++ b/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart
@@ -1,18 +1,27 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
-import 'package:community_charts_flutter/community_charts_flutter.dart' as charts;
+import 'package:community_charts_flutter/community_charts_flutter.dart'
+ as charts;
class RollingChart extends StatefulWidget {
final Stream<(int, double)> dataSteam;
final int timestampExponent; // e.g., 6 for microseconds to milliseconds
- final int timeWindow; // in milliseconds
+ final int timeWindow; // in seconds
+ final bool showXAxis;
+ final bool showYAxis;
+ final double? fixedMeasureMin;
+ final double? fixedMeasureMax;
const RollingChart({
super.key,
required this.dataSteam,
required this.timestampExponent,
required this.timeWindow,
+ this.showXAxis = true,
+ this.showYAxis = true,
+ this.fixedMeasureMin,
+ this.fixedMeasureMax,
});
@override
@@ -20,8 +29,9 @@ class RollingChart extends StatefulWidget {
}
class _RollingChartState extends State {
- List> _seriesList = [];
- final List<_ChartPoint> _data = [];
+ List> _seriesList = [];
+ final List<_RawChartPoint> _rawData = [];
+ List<_ChartPoint> _normalizedData = [];
StreamSubscription? _subscription;
@override
@@ -42,56 +52,122 @@ class _RollingChartState extends State {
void _listenToStream() {
_subscription = widget.dataSteam.listen((event) {
final (timestamp, value) = event;
-
+ if (!value.isFinite) {
+ return;
+ }
+
setState(() {
- _data.add(_ChartPoint(timestamp, value));
-
+ _rawData.add(_RawChartPoint(timestamp, value));
+
// Remove old data outside time window
- int cutoffTime = timestamp - (widget.timeWindow * pow(10, -widget.timestampExponent) as int);
- _data.removeWhere((data) => data.time < cutoffTime);
-
+ final ticksPerSecond = pow(10, -widget.timestampExponent).toDouble();
+ final cutoffTime =
+ timestamp - (widget.timeWindow * ticksPerSecond).round();
+ _rawData.removeWhere((data) => data.timestamp < cutoffTime);
+
_updateSeries();
});
});
}
void _updateSeries() {
+ if (_rawData.isEmpty) {
+ _normalizedData = [];
+ _seriesList = [];
+ return;
+ }
+
+ final finiteRawData = _rawData
+ .where((point) => point.value.isFinite)
+ .toList(growable: false);
+ if (finiteRawData.length < 2) {
+ _normalizedData = [];
+ _seriesList = [];
+ return;
+ }
+
+ final firstTimestamp = finiteRawData.first.timestamp;
+ final secondsPerTick = pow(10, widget.timestampExponent).toDouble();
+
+ _normalizedData = finiteRawData
+ .map(
+ (point) => _ChartPoint(
+ (point.timestamp - firstTimestamp) * secondsPerTick,
+ point.value,
+ ),
+ )
+ .toList(growable: false);
+
_seriesList = [
- charts.Series<_ChartPoint, int>(
- id: 'Live Data',
- colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault,
- domainFn: (_ChartPoint point, _) => point.time,
- measureFn: (_ChartPoint point, _) => point.value,
- data: List.of(_data),
+ charts.Series<_ChartPoint, num>(
+ id: 'Live Data',
+ colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault,
+ domainFn: (_ChartPoint point, _) => point.timeSeconds,
+ measureFn: (_ChartPoint point, _) => point.value,
+ data: _normalizedData,
),
];
}
@override
Widget build(BuildContext context) {
- final filteredPoints = _data;
+ if (_seriesList.isEmpty || _normalizedData.length < 2) {
+ return Center(
+ child: Text(
+ 'Waiting for signal...',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+ }
+
+ final filteredPoints = _normalizedData;
- final xValues = filteredPoints.map((e) => e.time).toList();
+ final xValues = filteredPoints.map((e) => e.timeSeconds).toList();
final yValues = filteredPoints.map((e) => e.value).toList();
- final int? xMin = xValues.isNotEmpty ? xValues.reduce((a, b) => a < b ? a : b) : null;
- final int? xMax = xValues.isNotEmpty ? xValues.reduce((a, b) => a > b ? a : b) : null;
+ final double xMin = 0;
+ final double xMax = max(
+ widget.timeWindow.toDouble(),
+ xValues.isNotEmpty ? xValues.reduce((a, b) => a > b ? a : b) : 0,
+ );
- final double? yMin = yValues.isNotEmpty ? yValues.reduce((a, b) => a < b ? a : b) : null;
- final double? yMax = yValues.isNotEmpty ? yValues.reduce((a, b) => a > b ? a : b) : null;
+ final double? dynamicYMin =
+ yValues.isNotEmpty ? yValues.reduce((a, b) => a < b ? a : b) : null;
+ final double? dynamicYMax =
+ yValues.isNotEmpty ? yValues.reduce((a, b) => a > b ? a : b) : null;
+ var yMin = widget.fixedMeasureMin ?? dynamicYMin;
+ var yMax = widget.fixedMeasureMax ?? dynamicYMax;
+ if (yMin != null && yMax != null && yMin >= yMax) {
+ final center = yMin;
+ final pad = max(center.abs() * 0.05, 1.0);
+ yMin = center - pad;
+ yMax = center + pad;
+ }
return charts.LineChart(
_seriesList,
animate: false,
+ defaultInteractions: false,
+ behaviors: const [],
domainAxis: charts.NumericAxisSpec(
- viewport: xMin != null && xMax != null
- ? charts.NumericExtents(xMin, xMax)
- : null,
+ viewport: charts.NumericExtents(xMin, xMax),
+ renderSpec: widget.showXAxis ? null : const charts.NoneRenderSpec(),
+ tickFormatterSpec: charts.BasicNumericTickFormatterSpec((num? value) {
+ if (value == null) return '';
+ final rounded = value.roundToDouble();
+ if ((value - rounded).abs() < 0.05) {
+ return '${rounded.toInt()}s';
+ }
+ return '${value.toStringAsFixed(1)}s';
+ }),
),
primaryMeasureAxis: charts.NumericAxisSpec(
viewport: yMin != null && yMax != null
- ? charts.NumericExtents(yMin, yMax)
- : null,
+ ? charts.NumericExtents(yMin, yMax)
+ : null,
+ renderSpec: widget.showYAxis ? null : const charts.NoneRenderSpec(),
),
);
}
@@ -103,9 +179,16 @@ class _RollingChartState extends State {
}
}
+class _RawChartPoint {
+ final int timestamp;
+ final double value;
+
+ _RawChartPoint(this.timestamp, this.value);
+}
+
class _ChartPoint {
- final int time;
+ final double timeSeconds;
final double value;
- _ChartPoint(this.time, this.value);
+ _ChartPoint(this.timeSeconds, this.value);
}
diff --git a/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart b/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart
index 0f08ded3..a1a02844 100644
--- a/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart
+++ b/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart
@@ -24,7 +24,11 @@ class EarableAttitudeTracker extends AttitudeTracker {
final bool _isLeft;
- EarableAttitudeTracker(this._sensorManager, this._sensorConfigurationProvider, this._isLeft);
+ EarableAttitudeTracker(
+ this._sensorManager,
+ this._sensorConfigurationProvider,
+ this._isLeft,
+ );
@override
void start() {
@@ -33,18 +37,33 @@ class EarableAttitudeTracker extends AttitudeTracker {
return;
}
- final Sensor accelSensor = _sensorManager.sensors.firstWhere((s) => s.sensorName.toLowerCase() == "accelerometer".toLowerCase());
+ final Sensor accelSensor = _sensorManager.sensors.firstWhere(
+ (s) => s.sensorName.toLowerCase() == "accelerometer".toLowerCase(),
+ );
final Set configurations = {};
configurations.addAll(accelSensor.relatedConfigurations);
for (final SensorConfiguration configuration in configurations) {
- if (configuration is ConfigurableSensorConfiguration && configuration.availableOptions.contains(StreamSensorConfigOption())) {
- _sensorConfigurationProvider.addSensorConfigurationOption(configuration, StreamSensorConfigOption());
+ if (configuration is ConfigurableSensorConfiguration &&
+ configuration.availableOptions.contains(StreamSensorConfigOption())) {
+ _sensorConfigurationProvider.addSensorConfigurationOption(
+ configuration,
+ StreamSensorConfigOption(),
+ markPending: false,
+ );
}
- List values = _sensorConfigurationProvider.getSensorConfigurationValues(configuration, distinct: true);
- _sensorConfigurationProvider.addSensorConfiguration(configuration, values.first);
- configuration.setConfiguration(_sensorConfigurationProvider.getSelectedConfigurationValue(configuration)!);
+ List values = _sensorConfigurationProvider
+ .getSensorConfigurationValues(configuration, distinct: true);
+ _sensorConfigurationProvider.addSensorConfiguration(
+ configuration,
+ values.first,
+ markPending: false,
+ );
+ configuration.setConfiguration(
+ _sensorConfigurationProvider
+ .getSelectedConfigurationValue(configuration)!,
+ );
}
calibrate(
diff --git a/open_wearable/lib/apps/posture_tracker/view/arc_painter.dart b/open_wearable/lib/apps/posture_tracker/view/arc_painter.dart
index 65ae2628..71c3bbde 100644
--- a/open_wearable/lib/apps/posture_tracker/view/arc_painter.dart
+++ b/open_wearable/lib/apps/posture_tracker/view/arc_painter.dart
@@ -1,90 +1,97 @@
-// ignore_for_file: unnecessary_this
-
import 'dart:math';
import 'package:flutter/material.dart';
class ArcPainter extends CustomPainter {
- /// the angle of rotation
final double angle;
final double angleThreshold;
-
- ArcPainter({required this.angle, this.angleThreshold = 0});
+ final Color circleColor;
+ final Color angleColor;
+ final Color thresholdColor;
+ final Color overshootColor;
+ final double strokeWidth;
+
+ ArcPainter({
+ required this.angle,
+ this.angleThreshold = 0,
+ this.circleColor = const Color(0xFFC3C3C3),
+ this.angleColor = Colors.blue,
+ this.thresholdColor = const Color(0x664285F4),
+ this.overshootColor = Colors.red,
+ this.strokeWidth = 5,
+ });
@override
void paint(Canvas canvas, Size size) {
- Paint circlePaint = Paint()
- ..color = const Color.fromARGB(255, 195, 195, 195)
+ final center = Offset(size.width / 2, size.height / 2);
+ final radius = min(size.width, size.height) / 2;
+ final safeAngle = angle.isFinite ? angle : 0.0;
+ final safeThreshold = angleThreshold.abs();
+ const startAngle = -pi / 2;
+
+ final circlePaint = Paint()
+ ..color = circleColor
..style = PaintingStyle.stroke
- ..strokeWidth = 5.0;
+ ..strokeWidth = strokeWidth;
+ canvas.drawCircle(center, radius, circlePaint);
- Path circlePath = Path();
- circlePath.addOval(Rect.fromCircle(center: Offset(size.width / 2, size.height / 2), radius: min(size.width, size.height) / 2));
- canvas.drawPath(circlePath, circlePaint);
-
- // Create a paint object with purple color and stroke style
- Paint anglePaint = Paint()
- ..color = Colors.purpleAccent
+ final anglePaint = Paint()
+ ..color = angleColor
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
- ..strokeWidth = 5.0;
-
- // Create a path object to draw the arc
- Path anglePath = Path();
-
- // Calculate the center and radius of the circle
- Offset center = Offset(size.width / 2, size.height / 2);
- double radius = min(size.width, size.height) / 2;
-
- // Calculate the start and end angles of the arc
- double startAngle = -pi / 2; // start from the top of the circle
- double endAngle = angle;
-
- // Add an arc to the path
- anglePath.addArc(
- Rect.fromCircle(center: center, radius: radius), // create a rectangle from the center and radius
- startAngle, // start angle
- endAngle, // sweep angle
- );
-
- Path angleOvershootPath = Path();
- angleOvershootPath.addArc(
- Rect.fromCircle(center: center, radius: radius), // create a rectangle from the center and radius
- startAngle + angle.sign * angleThreshold, // start angle
- angle.sign * (angle.abs() - angleThreshold), // sweep angle
- );
+ ..strokeWidth = strokeWidth;
- Paint angleOvershootPaint = Paint()
- ..color = Colors.red
+ final thresholdPaint = Paint()
+ ..color = thresholdColor
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
- ..strokeWidth = 5.0;
+ ..strokeWidth = strokeWidth;
- Path thresholdPath = Path();
- thresholdPath.addArc(
- Rect.fromCircle(center: center, radius: radius), // create a rectangle from the center and radius
- startAngle - angleThreshold, // start angle
- 2 * angleThreshold, // sweep angle
- );
-
- Paint thresholdPaint = Paint()
- ..color = Colors.purpleAccent[100]!
+ final angleOvershootPaint = Paint()
+ ..color = overshootColor
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
- ..strokeWidth = 5.0;
+ ..strokeWidth = strokeWidth;
+
+ final arcBounds = Rect.fromCircle(center: center, radius: radius);
+ if (safeThreshold > 0) {
+ canvas.drawArc(
+ arcBounds,
+ startAngle - safeThreshold,
+ 2 * safeThreshold,
+ false,
+ thresholdPaint,
+ );
+ }
+ canvas.drawArc(
+ arcBounds,
+ startAngle,
+ safeAngle,
+ false,
+ anglePaint,
+ );
- // Draw the path on the canvas
- canvas.drawPath(thresholdPath, thresholdPaint);
- canvas.drawPath(anglePath, anglePaint);
- if (angle.abs() > angleThreshold.abs()) {
- canvas.drawPath(angleOvershootPath, angleOvershootPaint);
+ if (safeThreshold > 0 && safeAngle.abs() > safeThreshold) {
+ canvas.drawArc(
+ arcBounds,
+ startAngle + safeAngle.sign * safeThreshold,
+ safeAngle.sign * (safeAngle.abs() - safeThreshold),
+ false,
+ angleOvershootPaint,
+ );
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
- // check if oldDelegate is an ArcPainter and if the angle is the same
- return oldDelegate is ArcPainter && oldDelegate.angle != this.angle;
+ return oldDelegate is ArcPainter &&
+ (oldDelegate.angle != angle ||
+ oldDelegate.angleThreshold != angleThreshold ||
+ oldDelegate.circleColor != circleColor ||
+ oldDelegate.angleColor != angleColor ||
+ oldDelegate.thresholdColor != thresholdColor ||
+ oldDelegate.overshootColor != overshootColor ||
+ oldDelegate.strokeWidth != strokeWidth);
}
}
diff --git a/open_wearable/lib/apps/posture_tracker/view/posture_roll_view.dart b/open_wearable/lib/apps/posture_tracker/view/posture_roll_view.dart
index 495538fa..5cd7ff38 100644
--- a/open_wearable/lib/apps/posture_tracker/view/posture_roll_view.dart
+++ b/open_wearable/lib/apps/posture_tracker/view/posture_roll_view.dart
@@ -1,14 +1,11 @@
-// ignore_for_file: unnecessary_this
-
import 'dart:math';
import 'package:flutter/material.dart';
-import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:open_wearable/apps/posture_tracker/view/arc_painter.dart';
/// A widget that displays the roll of the head and neck.
class PostureRollView extends StatelessWidget {
- static final double _maxRoll = pi / 2;
+ static const double _maxRoll = pi / 2;
/// The roll of the head and neck in radians.
final double roll;
@@ -17,6 +14,9 @@ class PostureRollView extends StatelessWidget {
final String headAssetPath;
final String neckAssetPath;
final AlignmentGeometry headAlignment;
+ final double visualSize;
+ final Color? goodColor;
+ final Color? badColor;
const PostureRollView({
super.key,
@@ -25,42 +25,61 @@ class PostureRollView extends StatelessWidget {
required this.headAssetPath,
required this.neckAssetPath,
this.headAlignment = Alignment.center,
+ this.visualSize = 118,
+ this.goodColor,
+ this.badColor,
});
@override
Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final boundedRoll =
+ roll.isFinite ? roll.clamp(-_maxRoll, _maxRoll).toDouble() : 0.0;
+ final hasOvershoot = roll.abs() > angleThreshold.abs();
+ final healthyColor = goodColor ?? const Color(0xFF2F8F5B);
+ final unhealthyColor = badColor ?? colorScheme.error;
+ final displayColor = hasOvershoot ? unhealthyColor : healthyColor;
+
return Column(
children: [
- PlatformText(
- "${(this.roll * 180 / 3.14).abs().toStringAsFixed(0)}°",
- style: TextStyle(
- // use proper color matching the background
- color: Theme.of(context).colorScheme.onSurface,
- fontSize: 30,
- fontWeight: FontWeight.bold,
- ),
+ Text(
+ '${(roll * 180 / pi).abs().toStringAsFixed(0)}°',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: displayColor,
+ ),
),
+ const SizedBox(height: 2),
CustomPaint(
- painter:
- ArcPainter(angle: this.roll, angleThreshold: this.angleThreshold),
- child: Padding(
- padding: EdgeInsets.all(10),
+ painter: ArcPainter(
+ angle: roll,
+ angleThreshold: angleThreshold,
+ circleColor: colorScheme.outlineVariant.withValues(alpha: 0.65),
+ angleColor: displayColor,
+ thresholdColor: displayColor.withValues(alpha: 0.35),
+ overshootColor: unhealthyColor,
+ ),
+ child: SizedBox.square(
+ dimension: visualSize,
child: ClipOval(
child: Container(
- color: roll.abs() > _maxRoll
- ? Colors.red.withValues(alpha: 0.5)
- : Colors.transparent,
+ color: hasOvershoot
+ ? unhealthyColor.withValues(alpha: 0.18)
+ : healthyColor.withValues(alpha: 0.12),
child: Stack(
+ fit: StackFit.expand,
children: [
- Image.asset(this.neckAssetPath),
+ Image.asset(
+ neckAssetPath,
+ fit: BoxFit.contain,
+ ),
Transform.rotate(
- angle: this.roll.isFinite
- ? roll.abs() < _maxRoll
- ? this.roll
- : roll.sign * _maxRoll
- : 0,
- alignment: this.headAlignment,
- child: Image.asset(this.headAssetPath),
+ angle: boundedRoll,
+ alignment: headAlignment,
+ child: Image.asset(
+ headAssetPath,
+ fit: BoxFit.contain,
+ ),
),
],
),
diff --git a/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart b/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart
index bb178e16..7c62b093 100644
--- a/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart
+++ b/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart
@@ -1,4 +1,5 @@
-// ignore_for_file: unnecessary_this
+import 'dart:async';
+import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
@@ -7,6 +8,8 @@ import 'package:open_wearable/apps/posture_tracker/model/bad_posture_reminder.da
import 'package:open_wearable/apps/posture_tracker/view/posture_roll_view.dart';
import 'package:open_wearable/apps/posture_tracker/view/settings_view.dart';
import 'package:open_wearable/apps/posture_tracker/view_model/posture_tracker_view_model.dart';
+import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
+import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
import 'package:provider/provider.dart';
class PostureTrackerView extends StatefulWidget {
@@ -19,108 +22,473 @@ class PostureTrackerView extends StatefulWidget {
}
class _PostureTrackerViewState extends State {
+ static const Color _goodPostureColor = Color(0xFF2F8F5B);
+ late final SensorConfigurationProvider _sensorConfigurationProvider;
+
+ @override
+ void initState() {
+ super.initState();
+ _sensorConfigurationProvider = context.read();
+ }
+
+ @override
+ void dispose() {
+ unawaited(_sensorConfigurationProvider.turnOffAllSensors());
+ super.dispose();
+ }
+
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
- create: (context) => PostureTrackerViewModel(widget._tracker,
- BadPostureReminder(attitudeTracker: widget._tracker),),
- builder: (context, child) => Consumer(
- builder: (context, postureTrackerViewModel, child) => Scaffold(
- appBar: AppBar(
- title: PlatformText("Posture Tracker"),
- actions: [
- IconButton(
- onPressed: () => Navigator.of(context).push(
- MaterialPageRoute(
- builder: (context) =>
- SettingsView(postureTrackerViewModel),),),
- icon: Icon(Icons.settings),),
+ create: (context) => PostureTrackerViewModel(
+ widget._tracker,
+ BadPostureReminder(attitudeTracker: widget._tracker),
+ ),
+ builder: (context, child) => Consumer(
+ builder: (context, postureTrackerViewModel, child) => PlatformScaffold(
+ appBar: PlatformAppBar(
+ title: const Text('Posture Tracker'),
+ trailingActions: [
+ PlatformIconButton(
+ icon: const Icon(Icons.settings_outlined),
+ onPressed: () {
+ Navigator.of(context).push(
+ platformPageRoute(
+ context: context,
+ builder: (context) =>
+ SettingsView(postureTrackerViewModel),
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ body: _buildContentView(context, postureTrackerViewModel),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildContentView(
+ BuildContext context,
+ PostureTrackerViewModel postureTrackerViewModel,
+ ) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final isGoodPosture = _isGoodPosture(postureTrackerViewModel);
+ final status = _trackingStatus(
+ isAvailable: postureTrackerViewModel.isAvailable,
+ isTracking: postureTrackerViewModel.isTracking,
+ isGoodPosture: isGoodPosture,
+ );
+ final rollWithinThreshold = _isWithinThreshold(
+ value: postureTrackerViewModel.attitude.roll,
+ thresholdDegrees: postureTrackerViewModel
+ .badPostureSettings.rollAngleThreshold
+ .toDouble(),
+ );
+ final pitchWithinThreshold = _isWithinThreshold(
+ value: postureTrackerViewModel.attitude.pitch,
+ thresholdDegrees: postureTrackerViewModel
+ .badPostureSettings.pitchAngleThreshold
+ .toDouble(),
+ );
+ final infoText = postureTrackerViewModel.isTracking
+ ? 'Live feedback for head tilt and neck alignment.'
+ : 'Start tracking for live posture feedback. Calibration is optional.';
+
+ return Padding(
+ padding: SensorPageSpacing.pagePadding,
+ child: Column(
+ children: [
+ Card(
+ color: postureTrackerViewModel.isTracking && isGoodPosture
+ ? _goodPostureColor.withValues(alpha: 0.12)
+ : null,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ 'Live Posture Feedback',
+ style:
+ Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ _TrackingStatusChip(
+ label: status.label,
+ color: status.color,
+ ),
],
),
- body: Center(
- child: this._buildContentView(postureTrackerViewModel),
+ const SizedBox(height: 4),
+ Text(
+ infoText,
+ style: Theme.of(context).textTheme.bodySmall,
),
- backgroundColor: Theme.of(context).colorScheme.surface,
- ),),);
- }
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ _AngleMetricPill(
+ label: 'Roll',
+ valueRadians: postureTrackerViewModel.attitude.roll,
+ accentColor: postureTrackerViewModel.isTracking
+ ? (rollWithinThreshold
+ ? _goodPostureColor
+ : colorScheme.error)
+ : colorScheme.primary,
+ ),
+ _AngleMetricPill(
+ label: 'Pitch',
+ valueRadians: postureTrackerViewModel.attitude.pitch,
+ accentColor: postureTrackerViewModel.isTracking
+ ? (pitchWithinThreshold
+ ? _goodPostureColor
+ : colorScheme.error)
+ : colorScheme.primary,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
+ Expanded(
+ child: Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Head Posture',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ if (!postureTrackerViewModel.isAvailable) ...[
+ const SizedBox(height: 4),
+ Text(
+ 'No compatible OpenEarable connected.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.error,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ const SizedBox(height: 6),
+ Expanded(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ const headGap = 8.0;
+ const headChromeHeight = 52.0;
+ final headSpecs =
+ _createHeadSpecs(postureTrackerViewModel);
+ final viewWidth = min(constraints.maxWidth, 300.0);
+ final perHeadHeight =
+ (constraints.maxHeight - headGap) / 2;
+ final previewByHeight =
+ perHeadHeight - headChromeHeight;
+ final previewByWidth = viewWidth - 20;
+ final previewSize =
+ min(previewByHeight, previewByWidth)
+ .clamp(72.0, 200.0);
- Widget _buildContentView(PostureTrackerViewModel postureTrackerViewModel) {
- var headViews = this._createHeadViews(postureTrackerViewModel);
- return Column(mainAxisAlignment: MainAxisAlignment.center, children: [
- ...headViews.map((e) => FractionallySizedBox(
- widthFactor: .7,
- child: e,
- ),),
- this._buildTrackingButton(postureTrackerViewModel),
- ],);
+ return Align(
+ alignment: Alignment.center,
+ child: SizedBox(
+ width: viewWidth,
+ child: Column(
+ children: [
+ Expanded(
+ child: Center(
+ child: _buildHeadView(
+ context,
+ headSpecs[0],
+ previewSize: previewSize,
+ ),
+ ),
+ ),
+ const SizedBox(height: headGap),
+ Expanded(
+ child: Center(
+ child: _buildHeadView(
+ context,
+ headSpecs[1],
+ previewSize: previewSize,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
+ SafeArea(
+ top: false,
+ child: Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
+ child: Column(
+ children: [
+ SizedBox(
+ width: double.infinity,
+ child: PlatformElevatedButton(
+ onPressed: postureTrackerViewModel.isAvailable
+ ? () {
+ if (postureTrackerViewModel.isTracking) {
+ postureTrackerViewModel.stopTracking();
+ return;
+ }
+ postureTrackerViewModel.startTracking();
+ }
+ : null,
+ color: postureTrackerViewModel.isTracking
+ ? colorScheme.error
+ : null,
+ child: Text(
+ postureTrackerViewModel.isTracking
+ ? 'Stop Tracking'
+ : 'Start Tracking',
+ ),
+ ),
+ ),
+ const SizedBox(height: 6),
+ SizedBox(
+ width: double.infinity,
+ child: PlatformTextButton(
+ onPressed: postureTrackerViewModel.isTracking
+ ? postureTrackerViewModel.calibrate
+ : null,
+ child: const Text('Calibrate (Optional)'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
}
- Widget _buildHeadView(String headAssetPath, String neckAssetPath,
- AlignmentGeometry headAlignment, double roll, double angleThreshold,) {
- return Padding(
- padding: const EdgeInsets.all(5),
- child: PostureRollView(
- roll: roll,
- angleThreshold: angleThreshold * 3.14 / 180,
- headAssetPath: headAssetPath,
- neckAssetPath: neckAssetPath,
- headAlignment: headAlignment,
- ),
+ Widget _buildHeadView(
+ BuildContext context,
+ _HeadPreviewSpec spec, {
+ required double previewSize,
+ }) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final withinThreshold = _isWithinThreshold(
+ value: spec.roll,
+ thresholdDegrees: spec.angleThreshold,
+ );
+ final accentColor = withinThreshold ? _goodPostureColor : colorScheme.error;
+
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ color: accentColor,
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ spec.label,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: accentColor,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 2),
+ PostureRollView(
+ roll: spec.roll,
+ angleThreshold: _degreesToRadians(spec.angleThreshold),
+ headAssetPath: spec.headAssetPath,
+ neckAssetPath: spec.neckAssetPath,
+ headAlignment: spec.headAlignment,
+ visualSize: previewSize,
+ goodColor: _goodPostureColor,
+ badColor: colorScheme.error,
+ ),
+ ],
);
}
- List _createHeadViews(PostureTrackerViewModel postureTrackerViewModel) {
+ List<_HeadPreviewSpec> _createHeadSpecs(
+ PostureTrackerViewModel postureTrackerViewModel,
+ ) {
return [
- this._buildHeadView(
- "lib/apps/posture_tracker/assets/Head_Front.png",
- "lib/apps/posture_tracker/assets/Neck_Front.png",
- Alignment.center.add(Alignment(0, 0.3)),
- postureTrackerViewModel.attitude.roll,
- postureTrackerViewModel.badPostureSettings.rollAngleThreshold
- .toDouble(),),
- this._buildHeadView(
- "lib/apps/posture_tracker/assets/Head_Side.png",
- "lib/apps/posture_tracker/assets/Neck_Side.png",
- Alignment.center.add(Alignment(0, 0.3)),
- -postureTrackerViewModel.attitude.pitch,
- postureTrackerViewModel.badPostureSettings.pitchAngleThreshold
- .toDouble(),),
+ _HeadPreviewSpec(
+ label: 'Side-to-Side Tilt',
+ headAssetPath: 'lib/apps/posture_tracker/assets/Head_Front.png',
+ neckAssetPath: 'lib/apps/posture_tracker/assets/Neck_Front.png',
+ headAlignment: Alignment.center.add(const Alignment(0, 0.3)),
+ roll: postureTrackerViewModel.attitude.roll,
+ angleThreshold: postureTrackerViewModel
+ .badPostureSettings.rollAngleThreshold
+ .toDouble(),
+ ),
+ _HeadPreviewSpec(
+ label: 'Forward/Backward Tilt',
+ headAssetPath: 'lib/apps/posture_tracker/assets/Head_Side.png',
+ neckAssetPath: 'lib/apps/posture_tracker/assets/Neck_Side.png',
+ headAlignment: Alignment.center.add(const Alignment(0, 0.3)),
+ roll: -postureTrackerViewModel.attitude.pitch,
+ angleThreshold: postureTrackerViewModel
+ .badPostureSettings.pitchAngleThreshold
+ .toDouble(),
+ ),
];
}
- Widget _buildTrackingButton(PostureTrackerViewModel postureTrackerViewModel) {
- return Column(children: [
- ElevatedButton(
- onPressed: postureTrackerViewModel.isAvailable
- ? () {
- postureTrackerViewModel.isTracking
- ? postureTrackerViewModel.stopTracking()
- : postureTrackerViewModel.startTracking();
- }
- : null,
- style: ElevatedButton.styleFrom(
- backgroundColor: !postureTrackerViewModel.isTracking
- ? Color(0xff77F2A1)
- : Color(0xfff27777),
- foregroundColor: Colors.black,
- ),
- child: postureTrackerViewModel.isTracking
- ? PlatformText("Stop Tracking")
- : PlatformText("Start Tracking"),
+ bool _isGoodPosture(PostureTrackerViewModel postureTrackerViewModel) {
+ final rollWithinThreshold = _isWithinThreshold(
+ value: postureTrackerViewModel.attitude.roll,
+ thresholdDegrees: postureTrackerViewModel
+ .badPostureSettings.rollAngleThreshold
+ .toDouble(),
+ );
+ final pitchWithinThreshold = _isWithinThreshold(
+ value: postureTrackerViewModel.attitude.pitch,
+ thresholdDegrees: postureTrackerViewModel
+ .badPostureSettings.pitchAngleThreshold
+ .toDouble(),
+ );
+ return rollWithinThreshold && pitchWithinThreshold;
+ }
+
+ bool _isWithinThreshold({
+ required double value,
+ required double thresholdDegrees,
+ }) {
+ return value.abs() <= _degreesToRadians(thresholdDegrees).abs();
+ }
+
+ double _degreesToRadians(double value) {
+ return value * pi / 180;
+ }
+
+ ({String label, Color color}) _trackingStatus({
+ required bool isAvailable,
+ required bool isTracking,
+ required bool isGoodPosture,
+ }) {
+ if (!isAvailable) {
+ return (label: 'Unavailable', color: Theme.of(context).colorScheme.error);
+ }
+ if (!isTracking) {
+ return (label: 'Ready', color: Theme.of(context).colorScheme.primary);
+ }
+ if (isGoodPosture) {
+ return (label: 'Good Posture', color: _goodPostureColor);
+ }
+ return (
+ label: 'Adjust Posture',
+ color: Theme.of(context).colorScheme.error
+ );
+ }
+}
+
+class _HeadPreviewSpec {
+ final String label;
+ final String headAssetPath;
+ final String neckAssetPath;
+ final AlignmentGeometry headAlignment;
+ final double roll;
+ final double angleThreshold;
+
+ const _HeadPreviewSpec({
+ required this.label,
+ required this.headAssetPath,
+ required this.neckAssetPath,
+ required this.headAlignment,
+ required this.roll,
+ required this.angleThreshold,
+ });
+}
+
+class _TrackingStatusChip extends StatelessWidget {
+ final String label;
+ final Color color;
+
+ const _TrackingStatusChip({
+ required this.label,
+ required this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(999),
+ border: Border.all(color: color.withValues(alpha: 0.28)),
),
- Visibility(
- visible: !postureTrackerViewModel.isAvailable,
- maintainState: true,
- maintainAnimation: true,
- maintainSize: true,
- child: PlatformText(
- "No Earable Connected",
- style: TextStyle(
- color: Colors.red,
- fontSize: 12,
- ),
- ),
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w700,
+ ),
),
- ],);
+ );
+ }
+}
+
+class _AngleMetricPill extends StatelessWidget {
+ final String label;
+ final double valueRadians;
+ final Color accentColor;
+
+ const _AngleMetricPill({
+ required this.label,
+ required this.valueRadians,
+ required this.accentColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final value = (valueRadians * 180 / pi).toStringAsFixed(0);
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: accentColor.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Text(
+ '$label $value°',
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: accentColor.withValues(alpha: 0.95),
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ );
}
}
diff --git a/open_wearable/lib/apps/posture_tracker/view/settings_view.dart b/open_wearable/lib/apps/posture_tracker/view/settings_view.dart
index 0a029d4d..0873675a 100644
--- a/open_wearable/lib/apps/posture_tracker/view/settings_view.dart
+++ b/open_wearable/lib/apps/posture_tracker/view/settings_view.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
-import 'package:open_wearable/apps/posture_tracker/model/bad_posture_reminder.dart';
import 'package:open_wearable/apps/posture_tracker/view_model/posture_tracker_view_model.dart';
+import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
import 'package:provider/provider.dart';
class SettingsView extends StatefulWidget {
@@ -19,213 +19,325 @@ class _SettingsViewState extends State {
late final TextEditingController _badPostureTimeThresholdController;
late final TextEditingController _goodPostureTimeThresholdController;
- late final PostureTrackerViewModel _viewModel;
+ late PostureTrackerViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = widget._viewModel;
- _rollAngleThresholdController = TextEditingController(
- text: _viewModel.badPostureSettings.rollAngleThreshold.toString(),);
- _pitchAngleThresholdController = TextEditingController(
- text: _viewModel.badPostureSettings.pitchAngleThreshold.toString(),);
- _badPostureTimeThresholdController = TextEditingController(
- text: _viewModel.badPostureSettings.timeThreshold.toString(),);
- _goodPostureTimeThresholdController = TextEditingController(
- text: _viewModel.badPostureSettings.resetTimeThreshold.toString(),);
+ _rollAngleThresholdController = TextEditingController();
+ _pitchAngleThresholdController = TextEditingController();
+ _badPostureTimeThresholdController = TextEditingController();
+ _goodPostureTimeThresholdController = TextEditingController();
+ _syncControllersFromViewModel();
+ }
+
+ @override
+ void didUpdateWidget(covariant SettingsView oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget._viewModel == widget._viewModel) {
+ return;
+ }
+ _viewModel = widget._viewModel;
+ _syncControllersFromViewModel();
+ }
+
+ void _syncControllersFromViewModel() {
+ _rollAngleThresholdController.text =
+ _viewModel.badPostureSettings.rollAngleThreshold.toString();
+ _pitchAngleThresholdController.text =
+ _viewModel.badPostureSettings.pitchAngleThreshold.toString();
+ _badPostureTimeThresholdController.text =
+ _viewModel.badPostureSettings.timeThreshold.toString();
+ _goodPostureTimeThresholdController.text =
+ _viewModel.badPostureSettings.resetTimeThreshold.toString();
}
@override
Widget build(BuildContext context) {
return PlatformScaffold(
- appBar: PlatformAppBar(title: PlatformText("Posture Tracker Settings")),
+ appBar: PlatformAppBar(title: const Text('Posture Tracker Settings')),
body: ChangeNotifierProvider.value(
- value: _viewModel,
- builder: (context, child) => Consumer(
- builder: (context, postureTrackerViewModel, child) =>
- _buildSettingsView(),
- ),),
- backgroundColor: Theme.of(context).colorScheme.surface,
+ value: _viewModel,
+ builder: (context, child) => Consumer(
+ builder: (context, postureTrackerViewModel, child) =>
+ _buildSettingsView(context, postureTrackerViewModel),
+ ),
+ ),
);
}
- Widget _buildSettingsView() {
- return Padding(
- padding: EdgeInsets.all(10),
- child: Column(
+ Widget _buildSettingsView(
+ BuildContext context,
+ PostureTrackerViewModel postureTrackerViewModel,
+ ) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final statusColor = !postureTrackerViewModel.isAvailable
+ ? colorScheme.error
+ : postureTrackerViewModel.isTracking
+ ? const Color(0xFF2F8F5B)
+ : colorScheme.primary;
+ final statusLabel = postureTrackerViewModel.isTracking
+ ? 'Tracking'
+ : postureTrackerViewModel.isAvailable
+ ? 'Ready'
+ : 'Unavailable';
+
+ return ListView(
+ padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
children: [
Card(
- child: PlatformListTile(
- title: PlatformText("Status"),
- trailing: PlatformText(_viewModel.isTracking
- ? "Tracking"
- : _viewModel.isAvailable
- ? "Available"
- : "Unavailable",),
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Tracker status',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Adjust reminder thresholds and calibrate your posture baseline.',
+ style: theme.textTheme.bodyMedium,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ _SettingsStatusChip(label: statusLabel, color: statusColor),
+ ],
+ ),
),
),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
Card(
- child: Column(children: [
- // add a switch to control the `isActive` property of the `BadPostureSettings`
- PlatformListTile(
- title: PlatformText("Bad Posture Reminder"),
- trailing: PlatformSwitch(
- value: _viewModel.badPostureSettings.isActive,
- onChanged: (value) {
- BadPostureSettings settings = _viewModel.badPostureSettings;
- settings.isActive = value;
- _viewModel.setBadPostureSettings(settings);
- },
- ),
- ),
- Visibility(
- visible: _viewModel.badPostureSettings.isActive,
- child: Column(children: [
- PlatformListTile(
- title: PlatformText("Roll Angle Threshold (in degrees)"),
- trailing: SizedBox(
- height: 37.0,
- width: 52,
- //TODO: use cupertino text field on ios
- child: TextField(
- controller: _rollAngleThresholdController,
- textAlign: TextAlign.end,
- style: TextStyle(color: Colors.black),
- decoration: InputDecoration(
- contentPadding: EdgeInsets.all(10),
- floatingLabelBehavior:
- FloatingLabelBehavior.never,
- border: OutlineInputBorder(),
- labelText: 'Roll',
- filled: true,
- labelStyle: TextStyle(color: Colors.black),
- fillColor: Colors.white,),
- keyboardType: TextInputType.number,
- onChanged: (_) {
- _updatePostureSettings();
- },
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ 'Bad Posture Reminder',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
),
),
- ),
- PlatformListTile(
- title: PlatformText("Pitch Angle Threshold (in degrees)"),
- trailing: SizedBox(
- height: 37.0,
- width: 52,
- //TODO: use cupertino text field on ios
- child: TextField(
- controller: _pitchAngleThresholdController,
- textAlign: TextAlign.end,
- style: TextStyle(color: Colors.black),
- decoration: InputDecoration(
- contentPadding: EdgeInsets.all(10),
- floatingLabelBehavior:
- FloatingLabelBehavior.never,
- border: OutlineInputBorder(),
- labelText: 'Pitch',
- filled: true,
- labelStyle: TextStyle(color: Colors.black),
- fillColor: Colors.white,),
- keyboardType: TextInputType.number,
- onChanged: (_) {
- _updatePostureSettings();
- },
- ),
+ PlatformSwitch(
+ value:
+ postureTrackerViewModel.badPostureSettings.isActive,
+ onChanged: (value) {
+ final settings = _viewModel.badPostureSettings;
+ settings.isActive = value;
+ _viewModel.setBadPostureSettings(settings);
+ },
),
+ ],
+ ),
+ if (postureTrackerViewModel.badPostureSettings.isActive) ...[
+ const SizedBox(height: 10),
+ Divider(
+ height: 1,
+ thickness: 0.6,
+ color: colorScheme.outlineVariant.withValues(alpha: 0.55),
),
- PlatformListTile(
- title: PlatformText("Bad Posture Time Threshold (in seconds)"),
- trailing: SizedBox(
- height: 37.0,
- width: 52,
- //TODO: use cupertino text field on ios
- child: TextField(
- controller: _badPostureTimeThresholdController,
- textAlign: TextAlign.end,
- style: TextStyle(color: Colors.black),
- decoration: InputDecoration(
- contentPadding: EdgeInsets.all(10),
- floatingLabelBehavior:
- FloatingLabelBehavior.never,
- border: OutlineInputBorder(),
- labelText: 'Seconds',
- filled: true,
- labelStyle: TextStyle(color: Colors.black),
- fillColor: Colors.white,),
- keyboardType: TextInputType.number,
- onChanged: (_) {
- _updatePostureSettings();
- },
- ),
- ),
+ const SizedBox(height: 10),
+ _buildNumericSettingRow(
+ context: context,
+ label: 'Roll angle threshold',
+ controller: _rollAngleThresholdController,
+ suffix: '°',
),
- PlatformListTile(
- title: PlatformText("Good Posture Time Threshold (in seconds)"),
- trailing: SizedBox(
- height: 37.0,
- width: 52,
- //TODO: use cupertino text field on ios
- child: TextField(
- controller: _goodPostureTimeThresholdController,
- textAlign: TextAlign.end,
- style: TextStyle(color: Colors.black),
- decoration: InputDecoration(
- contentPadding: EdgeInsets.all(10),
- floatingLabelBehavior:
- FloatingLabelBehavior.never,
- border: OutlineInputBorder(),
- labelText: 'Seconds',
- filled: true,
- labelStyle: TextStyle(color: Colors.black),
- fillColor: Colors.white,),
- keyboardType: TextInputType.number,
- onChanged: (_) {
- _updatePostureSettings();
- },
- ),
+ const SizedBox(height: 8),
+ _buildNumericSettingRow(
+ context: context,
+ label: 'Pitch angle threshold',
+ controller: _pitchAngleThresholdController,
+ suffix: '°',
+ ),
+ const SizedBox(height: 8),
+ _buildNumericSettingRow(
+ context: context,
+ label: 'Bad posture time threshold',
+ controller: _badPostureTimeThresholdController,
+ suffix: 's',
+ ),
+ const SizedBox(height: 8),
+ _buildNumericSettingRow(
+ context: context,
+ label: 'Good posture reset threshold',
+ controller: _goodPostureTimeThresholdController,
+ suffix: 's',
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Calibration',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ postureTrackerViewModel.isTracking
+ ? 'Use your current head position as the neutral posture reference.'
+ : 'Start tracking to calibrate your neutral posture reference.',
+ style: theme.textTheme.bodyMedium,
+ ),
+ const SizedBox(height: 10),
+ SizedBox(
+ width: double.infinity,
+ child: PlatformElevatedButton(
+ onPressed: postureTrackerViewModel.isTracking
+ ? _calibrateAndClose
+ : postureTrackerViewModel.isAvailable
+ ? postureTrackerViewModel.startTracking
+ : null,
+ child: Text(
+ postureTrackerViewModel.isTracking
+ ? 'Calibrate as Main Posture'
+ : 'Start Tracking',
),
),
- ],),),
- ],),),
- Padding(
- padding: EdgeInsets.only(top: 8.0),
- child: Row(children: [
- Expanded(
- child: PlatformElevatedButton(
- color: _viewModel.isTracking
- ? Colors.green[300]
- : Colors.blue[300],
- onPressed: _viewModel.isTracking
- ? () {
- _viewModel.calibrate();
- Navigator.of(context).pop();
- }
- : () => _viewModel.startTracking(),
- child: PlatformText(_viewModel.isTracking
- ? "Calibrate as Main Posture"
- : "Start Calibration",),
- ),
+ ),
+ ],
),
- ],),
+ ),
),
],
- ),
);
}
+ Widget _buildNumericSettingRow({
+ required BuildContext context,
+ required String label,
+ required TextEditingController controller,
+ required String suffix,
+ }) {
+ return Row(
+ children: [
+ Expanded(
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ SizedBox(
+ width: 92,
+ child: TextField(
+ controller: controller,
+ textAlign: TextAlign.end,
+ keyboardType: TextInputType.number,
+ onChanged: (_) {
+ _updatePostureSettings();
+ },
+ decoration: InputDecoration(
+ isDense: true,
+ suffixText: suffix,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ void _calibrateAndClose() {
+ _viewModel.calibrate();
+ if (!mounted) {
+ return;
+ }
+ Navigator.of(context).pop();
+ }
+
void _updatePostureSettings() {
- BadPostureSettings settings = _viewModel.badPostureSettings;
- settings.rollAngleThreshold = int.parse(_rollAngleThresholdController.text);
- settings.pitchAngleThreshold =
- int.parse(_pitchAngleThresholdController.text);
- settings.timeThreshold = int.parse(_badPostureTimeThresholdController.text);
- settings.resetTimeThreshold =
- int.parse(_goodPostureTimeThresholdController.text);
+ final rollAngleThreshold =
+ int.tryParse(_rollAngleThresholdController.text.trim());
+ final pitchAngleThreshold =
+ int.tryParse(_pitchAngleThresholdController.text.trim());
+ final badPostureTimeThreshold =
+ int.tryParse(_badPostureTimeThresholdController.text.trim());
+ final goodPostureTimeThreshold =
+ int.tryParse(_goodPostureTimeThresholdController.text.trim());
+
+ if (rollAngleThreshold == null ||
+ pitchAngleThreshold == null ||
+ badPostureTimeThreshold == null ||
+ goodPostureTimeThreshold == null) {
+ return;
+ }
+
+ if (rollAngleThreshold < 0 ||
+ pitchAngleThreshold < 0 ||
+ badPostureTimeThreshold < 0 ||
+ goodPostureTimeThreshold < 0) {
+ return;
+ }
+
+ final settings = _viewModel.badPostureSettings;
+ settings.rollAngleThreshold = rollAngleThreshold;
+ settings.pitchAngleThreshold = pitchAngleThreshold;
+ settings.timeThreshold = badPostureTimeThreshold;
+ settings.resetTimeThreshold = goodPostureTimeThreshold;
_viewModel.setBadPostureSettings(settings);
}
@override
void dispose() {
+ _rollAngleThresholdController.dispose();
+ _pitchAngleThresholdController.dispose();
+ _badPostureTimeThresholdController.dispose();
+ _goodPostureTimeThresholdController.dispose();
super.dispose();
}
}
+
+class _SettingsStatusChip extends StatelessWidget {
+ final String label;
+ final Color color;
+
+ const _SettingsStatusChip({
+ required this.label,
+ required this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(999),
+ border: Border.all(color: color.withValues(alpha: 0.28)),
+ ),
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ );
+ }
+}
diff --git a/open_wearable/lib/apps/widgets/app_compatibility.dart b/open_wearable/lib/apps/widgets/app_compatibility.dart
new file mode 100644
index 00000000..8a0e85ff
--- /dev/null
+++ b/open_wearable/lib/apps/widgets/app_compatibility.dart
@@ -0,0 +1,40 @@
+import 'package:open_wearable/models/device_name_formatter.dart';
+
+bool wearableNameStartsWithPrefix(String wearableName, String prefix) {
+ final normalizedPrefix = prefix.trim().toLowerCase();
+ final normalizedWearableName = wearableName.trim().toLowerCase();
+ if (normalizedWearableName.isEmpty || normalizedPrefix.isEmpty) {
+ return false;
+ }
+
+ if (normalizedWearableName.startsWith(normalizedPrefix)) {
+ return true;
+ }
+
+ final formattedWearableName =
+ formatWearableDisplayName(wearableName).trim().toLowerCase();
+ if (formattedWearableName.isEmpty) {
+ return false;
+ }
+
+ return formattedWearableName.startsWith(normalizedPrefix);
+}
+
+bool wearableIsCompatibleWithApp({
+ required String wearableName,
+ required List supportedDevicePrefixes,
+}) {
+ if (supportedDevicePrefixes.isEmpty) return true;
+ return supportedDevicePrefixes.any(
+ (prefix) => wearableNameStartsWithPrefix(wearableName, prefix),
+ );
+}
+
+bool hasConnectedWearableForPrefix({
+ required String devicePrefix,
+ required Iterable connectedWearableNames,
+}) {
+ return connectedWearableNames.any(
+ (name) => wearableNameStartsWithPrefix(name, devicePrefix),
+ );
+}
diff --git a/open_wearable/lib/apps/widgets/app_tile.dart b/open_wearable/lib/apps/widgets/app_tile.dart
index 45fd370a..3c878bfe 100644
--- a/open_wearable/lib/apps/widgets/app_tile.dart
+++ b/open_wearable/lib/apps/widgets/app_tile.dart
@@ -1,34 +1,265 @@
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:open_wearable/apps/widgets/app_compatibility.dart';
import 'package:open_wearable/apps/widgets/apps_page.dart';
+import 'package:open_wearable/models/app_launch_session.dart';
class AppTile extends StatelessWidget {
final AppInfo app;
+ final bool isEnabled;
+ final List connectedWearableNames;
- const AppTile({super.key, required this.app});
+ const AppTile({
+ super.key,
+ required this.app,
+ required this.isEnabled,
+ required this.connectedWearableNames,
+ });
@override
Widget build(BuildContext context) {
- return PlatformListTile(
- title: PlatformText(app.title),
- subtitle: PlatformText(app.description),
- leading: SizedBox(
- height: 50.0,
- width: 50.0,
- child: ClipRRect(
- borderRadius: BorderRadius.circular(8.0),
- child: Image.asset(
- app.logoPath,
- fit: BoxFit.cover,
+ final theme = Theme.of(context);
+ final orderedSupportedDevices = _orderedSupportedDevices(
+ app.supportedDevices,
+ connectedWearableNames,
+ );
+ final titleStyle = theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ color:
+ isEnabled ? theme.textTheme.titleMedium?.color : theme.disabledColor,
+ );
+
+ return Card(
+ margin: const EdgeInsets.only(bottom: 8),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: isEnabled
+ ? () {
+ AppLaunchSession.markAppFlowOpened();
+ Navigator.push(
+ context,
+ platformPageRoute(
+ context: context,
+ builder: (context) => app.widget,
+ ),
+ ).whenComplete(AppLaunchSession.markAppFlowClosed);
+ }
+ : null,
+ child: Opacity(
+ opacity: isEnabled ? 1 : 0.62,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Row(
+ children: [
+ Container(
+ height: 62,
+ width: 62,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: isEnabled
+ ? app.accentColor.withValues(alpha: 0.28)
+ : theme.disabledColor.withValues(alpha: 0.35),
+ ),
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(13),
+ child: app.logoPath.toLowerCase().endsWith('.svg')
+ ? Padding(
+ padding: EdgeInsets.all(app.svgIconInset ?? 10),
+ child: Transform.scale(
+ scale: app.svgIconScale ?? 1,
+ child: SvgPicture.asset(
+ app.logoPath,
+ fit: BoxFit.contain,
+ ),
+ ),
+ )
+ : Image.asset(
+ app.logoPath,
+ fit: BoxFit.cover,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ app.title,
+ style: titleStyle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ _LaunchAffordance(
+ accentColor: app.accentColor,
+ isEnabled: isEnabled,
+ ),
+ ],
+ ),
+ const SizedBox(height: 3),
+ Text(
+ app.description,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: isEnabled
+ ? theme.textTheme.bodyMedium?.color
+ : theme.disabledColor,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Supported devices',
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: isEnabled
+ ? theme.textTheme.bodySmall?.color
+ ?.withValues(alpha: 0.72)
+ : theme.disabledColor,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ children: orderedSupportedDevices
+ .map(
+ (device) => _SupportedDeviceChip(
+ text: device,
+ isConnected: hasConnectedWearableForPrefix(
+ devicePrefix: device,
+ connectedWearableNames:
+ connectedWearableNames,
+ ),
+ isEnabled: isEnabled,
+ ),
+ )
+ .toList(),
+ ),
+ ],
+ ),
+ ),
+ ],
),
),
),
- onTap: () {
- Navigator.push(
- context,
- platformPageRoute(context: context, builder: (context) => app.widget),
- );
- },
+ ),
+ );
+ }
+
+ List _orderedSupportedDevices(
+ List supportedDevices,
+ List connectedWearables,
+ ) {
+ final connected = [];
+ final notConnected = [];
+
+ for (final device in supportedDevices) {
+ final isConnected = hasConnectedWearableForPrefix(
+ devicePrefix: device,
+ connectedWearableNames: connectedWearables,
);
+ if (isConnected) {
+ connected.add(device);
+ } else {
+ notConnected.add(device);
+ }
+ }
+
+ return [...connected, ...notConnected];
+ }
+}
+
+class _LaunchAffordance extends StatelessWidget {
+ final Color accentColor;
+ final bool isEnabled;
+
+ const _LaunchAffordance({
+ required this.accentColor,
+ required this.isEnabled,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final disabledColor = Theme.of(context).disabledColor;
+ return Container(
+ margin: const EdgeInsets.only(left: 8),
+ height: 30,
+ width: 30,
+ decoration: BoxDecoration(
+ color: isEnabled
+ ? accentColor.withValues(alpha: 0.12)
+ : disabledColor.withValues(alpha: 0.18),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Icon(
+ Icons.arrow_forward_rounded,
+ size: 18,
+ color: isEnabled
+ ? accentColor.withValues(alpha: 0.9)
+ : disabledColor.withValues(alpha: 0.9),
+ ),
+ );
+ }
+}
+
+class _SupportedDeviceChip extends StatelessWidget {
+ final String text;
+ final bool isConnected;
+ final bool isEnabled;
+
+ const _SupportedDeviceChip({
+ required this.text,
+ required this.isConnected,
+ required this.isEnabled,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ const connectedPillColor = Color(0xFF2E7D32);
+ final disabledColor = theme.disabledColor;
+ final isConnectedAndEnabled = isEnabled && isConnected;
+ final connectedBackgroundColor = connectedPillColor.withValues(alpha: 0.15);
+ final isDark = theme.brightness == Brightness.dark;
+ final mutedBackgroundColor =
+ isDark ? const Color(0xFF3A3F45) : const Color(0xFFE3E7EC);
+ final mutedTextColor =
+ isDark ? const Color(0xFFD0D5DC) : const Color(0xFF5E6670);
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: isConnectedAndEnabled
+ ? connectedBackgroundColor
+ : isEnabled
+ ? mutedBackgroundColor
+ : disabledColor.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ text,
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: isConnectedAndEnabled
+ ? connectedPillColor
+ : isEnabled
+ ? mutedTextColor
+ : disabledColor.withValues(alpha: 0.95),
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ );
}
}
diff --git a/open_wearable/lib/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart
index 9400bf94..5190f309 100644
--- a/open_wearable/lib/apps/widgets/apps_page.dart
+++ b/open_wearable/lib/apps/widgets/apps_page.dart
@@ -1,4 +1,3 @@
-import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:go_router/go_router.dart';
@@ -6,52 +5,157 @@ import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_wearable/apps/heart_tracker/widgets/heart_tracker_page.dart';
import 'package:open_wearable/apps/posture_tracker/model/earable_attitude_tracker.dart';
import 'package:open_wearable/apps/posture_tracker/view/posture_tracker_view.dart';
+import 'package:open_wearable/apps/widgets/app_compatibility.dart';
import 'package:open_wearable/apps/widgets/select_earable_view.dart';
import 'package:open_wearable/apps/widgets/app_tile.dart';
-
+import 'package:open_wearable/view_models/wearables_provider.dart';
+import 'package:open_wearable/widgets/recording_activity_indicator.dart';
+import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
+import 'package:provider/provider.dart';
class AppInfo {
final String logoPath;
final String title;
final String description;
+ final List supportedDevices;
+ final Color accentColor;
final Widget widget;
+ final double? svgIconInset;
+ final double? svgIconScale;
+ final Color? iconBackgroundColor;
AppInfo({
required this.logoPath,
required this.title,
required this.description,
+ required this.supportedDevices,
+ required this.accentColor,
required this.widget,
+ this.svgIconInset,
+ this.svgIconScale,
+ this.iconBackgroundColor,
});
}
-List _apps = [
+const Color _appAccentColor = Color(0xFF9A6F6B);
+const List _postureSupportedDevices = [
+ "OpenEarable",
+];
+const List _heartSupportedDevices = [
+ "OpenEarable",
+ "OpenRing",
+];
+
+Sensor? _findOpticalTemperatureSensor(List sensors) {
+ String normalizeToken(String input) {
+ return input
+ .trim()
+ .toUpperCase()
+ .replaceAll(RegExp(r'[^A-Z0-9]+'), '_')
+ .replaceAll(RegExp(r'_+'), '_')
+ .replaceAll(RegExp(r'^_|_$'), '');
+ }
+
+ const preferredNames = {
+ 'OPTICAL_TEMPERATURE_SENSOR',
+ 'TEMPERATURE_OPTICAL_SENSOR',
+ };
+ for (final sensor in sensors) {
+ final sensorName = normalizeToken(sensor.sensorName);
+ final chartName = normalizeToken(sensor.chartTitle);
+ if (preferredNames.contains(sensorName) ||
+ preferredNames.contains(chartName)) {
+ return sensor;
+ }
+ }
+
+ for (final sensor in sensors) {
+ final text = '${sensor.sensorName} ${sensor.chartTitle}'.toLowerCase();
+ final hasOptical = text.contains('optical');
+ final hasTemperature =
+ text.contains('temperature') || text.contains('temp');
+ if (hasOptical && hasTemperature) {
+ return sensor;
+ }
+ }
+
+ return null;
+}
+
+final List _apps = [
AppInfo(
logoPath: "lib/apps/posture_tracker/assets/logo.png",
title: "Posture Tracker",
description: "Get feedback on bad posture",
- widget: SelectEarableView(startApp: (wearable, sensorConfigProvider) {
- return PostureTrackerView(
- EarableAttitudeTracker(
- wearable.requireCapability(),
- sensorConfigProvider,
- wearable.name.endsWith("L"),
- ),
- );
- },),
+ supportedDevices: _postureSupportedDevices,
+ accentColor: _appAccentColor,
+ widget: SelectEarableView(
+ supportedDevicePrefixes: _postureSupportedDevices,
+ startApp: (wearable, sensorConfigProvider) async {
+ return PostureTrackerView(
+ EarableAttitudeTracker(
+ wearable.requireCapability(),
+ sensorConfigProvider,
+ wearable.hasCapability() &&
+ await wearable.requireCapability().position ==
+ DevicePosition.left,
+ ),
+ );
+ },
+ ),
),
AppInfo(
logoPath: "lib/apps/heart_tracker/assets/logo.png",
title: "Heart Tracker",
- description: "Track your heart rate and other vitals",
+ description: "Heart rate and HRV visualization",
+ supportedDevices: _heartSupportedDevices,
+ accentColor: _appAccentColor,
widget: SelectEarableView(
- startApp: (wearable, _) {
+ supportedDevicePrefixes: _heartSupportedDevices,
+ startApp: (wearable, _) async {
if (wearable.hasCapability()) {
- //TODO: show alert if no ppg sensor is found
- Sensor ppgSensor = wearable.requireCapability().sensors.firstWhere(
- (s) => s.sensorName.toLowerCase() == "photoplethysmography".toLowerCase(),
- );
+ final sensors = wearable.requireCapability().sensors;
+ Sensor? ppgSensor;
+ for (final sensor in sensors) {
+ final text =
+ '${sensor.sensorName} ${sensor.chartTitle}'.toLowerCase();
+ if (text.contains('photoplethysmography') ||
+ text.contains('ppg') ||
+ text.contains('pulse')) {
+ ppgSensor = sensor;
+ break;
+ }
+ }
+
+ if (ppgSensor == null) {
+ return PlatformScaffold(
+ appBar: PlatformAppBar(
+ title: PlatformText('Heart Tracker'),
+ ),
+ body: Center(
+ child: PlatformText('No PPG sensor found on this wearable'),
+ ),
+ );
+ }
- return HeartTrackerPage(ppgSensor: ppgSensor);
+ Sensor? accelerometerSensor;
+ for (final sensor in sensors) {
+ final text =
+ '${sensor.sensorName} ${sensor.chartTitle}'.toLowerCase();
+ if (text.contains('accelerometer') || text.contains('acc')) {
+ accelerometerSensor = sensor;
+ break;
+ }
+ }
+ final opticalTemperatureSensor =
+ _findOpticalTemperatureSensor(sensors);
+
+ return HeartTrackerPage(
+ wearable: wearable,
+ ppgSensor: ppgSensor,
+ accelerometerSensor: accelerometerSensor,
+ opticalTemperatureSensor: opticalTemperatureSensor,
+ );
}
return PlatformScaffold(
appBar: PlatformAppBar(
@@ -66,16 +170,57 @@ List _apps = [
),
];
+int getAvailableAppsCount() => _apps.length;
+
+int getCompatibleAppsCountForWearables(Iterable wearables) {
+ final names = wearables.map((wearable) => wearable.name).toList();
+ if (names.isEmpty) return 0;
+
+ return _apps.where((app) {
+ return names.any(
+ (name) => wearableIsCompatibleWithApp(
+ wearableName: name,
+ supportedDevicePrefixes: app.supportedDevices,
+ ),
+ );
+ }).length;
+}
+
class AppsPage extends StatelessWidget {
const AppsPage({super.key});
@override
Widget build(BuildContext context) {
+ final connectedWearables = context.watch().wearables;
+ final connectedCount = connectedWearables.length;
+ final connectedWearableNames = connectedWearables
+ .map((wearable) => wearable.name)
+ .toList(growable: false);
+
+ final enabledApps = <_AppListEntry>[];
+ final disabledApps = <_AppListEntry>[];
+ for (final app in _apps) {
+ final isEnabled = connectedWearableNames.any(
+ (wearableName) => wearableIsCompatibleWithApp(
+ wearableName: wearableName,
+ supportedDevicePrefixes: app.supportedDevices,
+ ),
+ );
+ final entry = _AppListEntry(app: app, isEnabled: isEnabled);
+ if (isEnabled) {
+ enabledApps.add(entry);
+ } else {
+ disabledApps.add(entry);
+ }
+ }
+ final orderedApps = [...enabledApps, ...disabledApps];
+
return PlatformScaffold(
appBar: PlatformAppBar(
title: PlatformText("Apps"),
trailingActions: [
- PlatformIconButton(
+ const AppBarRecordingIndicator(),
+ PlatformIconButton(
icon: Icon(context.platformIcons.bluetooth),
onPressed: () {
context.push('/connect-devices');
@@ -83,14 +228,165 @@ class AppsPage extends StatelessWidget {
),
],
),
- body: Padding(
- padding: EdgeInsets.all(10),
- child: ListView.builder(
- itemCount: _apps.length,
- itemBuilder: (context, index) {
- return AppTile(app: _apps[index]);
- },
+ body: ListView(
+ padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
+ children: [
+ _AppsHeroCard(
+ totalApps: _apps.length,
+ connectedDevices: connectedCount,
+ ),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
+ Padding(
+ padding: const EdgeInsets.only(left: 2, bottom: 8),
+ child: Text(
+ 'Available apps',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ...orderedApps.map(
+ (entry) => AppTile(
+ app: entry.app,
+ isEnabled: entry.isEnabled,
+ connectedWearableNames: connectedWearableNames,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _AppListEntry {
+ final AppInfo app;
+ final bool isEnabled;
+
+ const _AppListEntry({
+ required this.app,
+ required this.isEnabled,
+ });
+}
+
+class _AppsHeroCard extends StatelessWidget {
+ final int totalApps;
+ final int connectedDevices;
+
+ const _AppsHeroCard({
+ required this.totalApps,
+ required this.connectedDevices,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return Container(
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(18),
+ gradient: const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ Color(0xFF835B58),
+ Color(0xFFB48A86),
+ ],
),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.12),
+ blurRadius: 14,
+ offset: const Offset(0, 8),
+ ),
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ height: 34,
+ width: 34,
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.16),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: const Icon(
+ Icons.auto_awesome,
+ color: Colors.white,
+ size: 20,
+ ),
+ ),
+ const SizedBox(width: 10),
+ Text(
+ 'App Studio',
+ style: theme.textTheme.titleMedium?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Text(
+ 'Launch wearable experiences from one place.',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: Colors.white.withValues(alpha: 0.9),
+ ),
+ ),
+ const SizedBox(height: 14),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ _HeroStatPill(
+ label: '$totalApps apps',
+ icon: Icons.widgets_outlined,
+ ),
+ _HeroStatPill(
+ label: '$connectedDevices wearables connected',
+ icon: Icons.link_rounded,
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _HeroStatPill extends StatelessWidget {
+ final String label;
+ final IconData icon;
+
+ const _HeroStatPill({
+ required this.label,
+ required this.icon,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 15, color: Colors.white),
+ const SizedBox(width: 6),
+ Text(
+ label,
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
),
);
}
diff --git a/open_wearable/lib/apps/widgets/select_earable_view.dart b/open_wearable/lib/apps/widgets/select_earable_view.dart
index 49653a88..cf7649c5 100644
--- a/open_wearable/lib/apps/widgets/select_earable_view.dart
+++ b/open_wearable/lib/apps/widgets/select_earable_view.dart
@@ -1,17 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart';
+import 'package:open_wearable/apps/widgets/app_compatibility.dart';
+import 'package:open_wearable/models/device_name_formatter.dart';
+import 'package:open_wearable/models/wearable_display_group.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
import 'package:open_wearable/view_models/wearables_provider.dart';
+import 'package:open_wearable/widgets/devices/device_status_pills.dart';
+import 'package:open_wearable/widgets/devices/wearable_icon.dart';
import 'package:provider/provider.dart';
-class SelectEarableView extends StatefulWidget {
- /// Callback to start the app
- /// -- [wearable] the selected wearable
- /// returns a [Widget] of the home page of the app
- final Widget Function(Wearable, SensorConfigurationProvider) startApp;
+class SelectEarableView extends StatefulWidget {
+ final Future Function(
+ Wearable,
+ SensorConfigurationProvider,
+ ) startApp;
+ final List supportedDevicePrefixes;
- const SelectEarableView({super.key, required this.startApp});
+ const SelectEarableView({
+ super.key,
+ required this.startApp,
+ this.supportedDevicePrefixes = const [],
+ });
@override
State createState() => _SelectEarableViewState();
@@ -19,61 +29,435 @@ class SelectEarableView extends StatefulWidget {
class _SelectEarableViewState extends State {
Wearable? _selectedWearable;
+ Future>? _groupsFuture;
+ String _groupFingerprint = '';
+ bool _isStartingApp = false;
@override
Widget build(BuildContext context) {
return PlatformScaffold(
appBar: PlatformAppBar(
- title: PlatformText("Select Earable"),
+ title: PlatformText('Select Wearable'),
),
- body: Consumer(
- builder: (context, WearablesProvider wearablesProvider, child) =>
- Column(
+ body: Consumer(
+ builder: (context, wearablesProvider, _) {
+ final compatibleWearables = wearablesProvider.wearables
+ .where(
+ (wearable) => wearableIsCompatibleWithApp(
+ wearableName: wearable.name,
+ supportedDevicePrefixes: widget.supportedDevicePrefixes,
+ ),
+ )
+ .toList(growable: false);
+
+ _refreshGroupFutureIfNeeded(compatibleWearables);
+ final selectedDeviceId = _selectedWearable?.deviceId;
+ final hasSelectedCompatibleWearable = selectedDeviceId != null &&
+ compatibleWearables.any(
+ (wearable) => wearable.deviceId == selectedDeviceId,
+ );
+
+ return Column(
children: [
- ListView.builder(
- shrinkWrap: true,
- physics: NeverScrollableScrollPhysics(),
- itemCount: wearablesProvider.wearables.length,
- itemBuilder: (context, index) {
- Wearable wearable = wearablesProvider.wearables[index];
- return PlatformListTile(
- title: PlatformText(wearable.name),
- subtitle: PlatformText(wearable.deviceId), //TODO: use device ID
- trailing: _selectedWearable == wearable
- ? Icon(Icons.check)
+ Expanded(
+ child: _buildBody(
+ context,
+ compatibleWearables: compatibleWearables,
+ wearablesProvider: wearablesProvider,
+ ),
+ ),
+ SafeArea(
+ top: false,
+ minimum: const EdgeInsets.fromLTRB(16, 0, 16, 12),
+ child: SizedBox(
+ width: double.infinity,
+ child: PlatformElevatedButton(
+ onPressed: hasSelectedCompatibleWearable && !_isStartingApp
+ ? () => _startSelectedApp(
+ wearablesProvider,
+ compatibleWearables,
+ )
: null,
- onTap: () => setState(() {
- _selectedWearable = wearable;
- }),
- );
- },
+ child: _isStartingApp
+ ? const SizedBox(
+ height: 20,
+ width: 20,
+ child: PlatformCircularProgressIndicator(),
+ )
+ : PlatformText('Start App'),
+ ),
+ ),
),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ void _refreshGroupFutureIfNeeded(List wearables) {
+ final fingerprint = wearables
+ .map((wearable) => '${wearable.deviceId}:${wearable.name}')
+ .join('|');
+ if (_groupsFuture != null && _groupFingerprint == fingerprint) {
+ return;
+ }
+
+ _groupFingerprint = fingerprint;
+ _groupsFuture = buildWearableDisplayGroups(
+ wearables,
+ shouldCombinePair: (_, __) => false,
+ );
+ }
+
+ Widget _buildBody(
+ BuildContext context, {
+ required List compatibleWearables,
+ required WearablesProvider wearablesProvider,
+ }) {
+ if (compatibleWearables.isEmpty) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ child: Text(
+ 'No compatible wearables connected for this app.',
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ ),
+ );
+ }
+
+ return FutureBuilder>(
+ future: _groupsFuture,
+ builder: (context, snapshot) {
+ final groups = _sortGroupsForSelection(
+ snapshot.data ??
+ compatibleWearables
+ .map(
+ (wearable) =>
+ WearableDisplayGroup.single(wearable: wearable),
+ )
+ .toList(growable: false),
+ );
+
+ if (groups.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ final selectedId = _selectedWearable?.deviceId;
+
+ return ListView.builder(
+ padding: const EdgeInsets.all(10),
+ itemCount: groups.length,
+ itemBuilder: (context, index) {
+ final group = groups[index];
+ final wearable = group.primary;
+ final isSelected = selectedId == wearable.deviceId;
+
+ return _SelectableWearableCard(
+ wearable: wearable,
+ position: group.primaryPosition,
+ selected: isSelected,
+ onTap: () {
+ setState(() {
+ _selectedWearable = wearable;
+ });
+ },
+ );
+ },
+ );
+ },
+ );
+ }
+
+ List _sortGroupsForSelection(
+ List groups,
+ ) {
+ final indexed = groups.asMap().entries.toList();
+
+ String normalizedName(String name) {
+ var value = name.trim();
+ value = value.replaceFirst(
+ RegExp(r'\s*\((left|right|l|r)\)$', caseSensitive: false),
+ '',
+ );
+ value = value.replaceFirst(
+ RegExp(r'[\s_-]+(left|right|l|r)$', caseSensitive: false),
+ '',
+ );
+ value = value.trim();
+ return value.isEmpty ? name.trim() : value;
+ }
+
+ int positionRank(DevicePosition? position) {
+ return switch (position) {
+ DevicePosition.left => 0,
+ DevicePosition.right => 1,
+ _ => 2,
+ };
+ }
+
+ indexed.sort((a, b) {
+ final aBase = normalizedName(a.value.primary.name).toLowerCase();
+ final bBase = normalizedName(b.value.primary.name).toLowerCase();
+ final byBase = aBase.compareTo(bBase);
+ if (byBase != 0) {
+ return byBase;
+ }
+
+ final byPosition = positionRank(a.value.primaryPosition)
+ .compareTo(positionRank(b.value.primaryPosition));
+ if (byPosition != 0) {
+ return byPosition;
+ }
+
+ final byName = a.value.primary.name
+ .toLowerCase()
+ .compareTo(b.value.primary.name.toLowerCase());
+ if (byName != 0) {
+ return byName;
+ }
+
+ return a.key.compareTo(b.key);
+ });
+
+ return indexed.map((entry) => entry.value).toList(growable: false);
+ }
+
+ Future _startSelectedApp(
+ WearablesProvider wearablesProvider,
+ List compatibleWearables,
+ ) async {
+ final selectedId = _selectedWearable?.deviceId;
+ if (selectedId == null) {
+ return;
+ }
+
+ final selectedWearable = compatibleWearables
+ .where((wearable) => wearable.deviceId == selectedId)
+ .firstOrNull;
+
+ if (selectedWearable == null) {
+ return;
+ }
+
+ final sensorConfigProvider =
+ wearablesProvider.getSensorConfigurationProvider(selectedWearable);
+ final navigator = Navigator.of(context);
+
+ setState(() {
+ _isStartingApp = true;
+ });
+
+ navigator.push(
+ platformPageRoute(
+ context: context,
+ builder: (context) => const _AppStartupLoadingScreen(),
+ ),
+ );
+
+ try {
+ final app = await widget.startApp(
+ selectedWearable,
+ sensorConfigProvider,
+ );
+
+ if (!mounted) {
+ return;
+ }
+
+ navigator.pushReplacement(
+ platformPageRoute(
+ context: context,
+ builder: (context) => ChangeNotifierProvider.value(
+ value: sensorConfigProvider,
+ child: app,
+ ),
+ ),
+ );
+ } catch (_) {
+ if (navigator.canPop()) {
+ navigator.pop();
+ }
+ rethrow;
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isStartingApp = false;
+ });
+ }
+ }
+ }
+}
+
+class _AppStartupLoadingScreen extends StatelessWidget {
+ const _AppStartupLoadingScreen();
+
+ @override
+ Widget build(BuildContext context) {
+ return PlatformScaffold(
+ appBar: PlatformAppBar(
+ title: const Text('Starting App'),
+ ),
+ body: const Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ PlatformCircularProgressIndicator(),
+ SizedBox(height: 12),
+ Text('Preparing app...'),
+ ],
+ ),
+ ),
+ );
+ }
+}
- PlatformElevatedButton(
- child: PlatformText("Start App"),
- onPressed: () {
- if (_selectedWearable != null) {
- Navigator.push(
- context,
- platformPageRoute(
- context: context,
- builder: (context) {
- return ChangeNotifierProvider.value(
- value: wearablesProvider.getSensorConfigurationProvider(_selectedWearable!),
- child: widget.startApp(
- _selectedWearable!,
- wearablesProvider.getSensorConfigurationProvider(_selectedWearable!),
+class _SelectableWearableCard extends StatelessWidget {
+ final Wearable wearable;
+ final DevicePosition? position;
+ final bool selected;
+ final VoidCallback onTap;
+
+ const _SelectableWearableCard({
+ required this.wearable,
+ required this.position,
+ required this.selected,
+ required this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final iconVariant = _iconVariantForPosition(position);
+ final hasWearableIcon = _hasWearableIcon(iconVariant);
+ final cardColor = selected
+ ? colorScheme.primaryContainer.withValues(alpha: 0.34)
+ : colorScheme.surface;
+ final pills = _buildDeviceStatusPills();
+
+ return Card(
+ color: cardColor,
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (hasWearableIcon) ...[
+ Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: SizedBox(
+ width: 56,
+ height: 56,
+ child: WearableIcon(
+ wearable: wearable,
+ initialVariant: iconVariant,
+ hideWhileResolvingStereoPosition: true,
+ hideWhenResolvedVariantIsSingle: true,
+ fallback: const SizedBox.shrink(),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ ],
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Text(
+ formatWearableDisplayName(wearable.name),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 170),
+ child: Text(
+ wearable.deviceId,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.right,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
),
- );
- },
- ),
- );
- }
- },
+ ),
+ ),
+ if (selected) ...[
+ const SizedBox(width: 6),
+ Padding(
+ padding: const EdgeInsets.only(top: 1),
+ child: Icon(
+ Icons.check_circle_rounded,
+ color: colorScheme.primary,
+ size: 18,
+ ),
+ ),
+ ],
+ ],
+ ),
+ if (pills.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ _buildStatusPillLine(pills),
+ ],
+ ],
+ ),
),
],
),
+ ),
),
);
}
+
+ WearableIconVariant _iconVariantForPosition(DevicePosition? position) {
+ return switch (position) {
+ DevicePosition.left => WearableIconVariant.left,
+ DevicePosition.right => WearableIconVariant.right,
+ _ => WearableIconVariant.single,
+ };
+ }
+
+ bool _hasWearableIcon(WearableIconVariant initialVariant) {
+ final variantPath = wearable.getWearableIconPath(variant: initialVariant);
+ if (variantPath != null && variantPath.isNotEmpty) {
+ return true;
+ }
+ final fallbackPath = wearable.getWearableIconPath();
+ return fallbackPath != null && fallbackPath.isNotEmpty;
+ }
+
+ List _buildDeviceStatusPills() {
+ String? sideLabel;
+ if (position == DevicePosition.left) {
+ sideLabel = 'L';
+ } else if (position == DevicePosition.right) {
+ sideLabel = 'R';
+ }
+
+ return buildDeviceStatusPills(
+ wearable: wearable,
+ sideLabel: sideLabel,
+ showStereoPosition: sideLabel == null,
+ batteryLiveUpdates: true,
+ batteryShowBackground: true,
+ );
+ }
+
+ Widget _buildStatusPillLine(List pills) {
+ return DevicePillLine(pills: pills);
+ }
}
diff --git a/open_wearable/lib/assets/devices/pair.png b/open_wearable/lib/assets/devices/pair.png
new file mode 100644
index 00000000..cd34dc91
Binary files /dev/null and b/open_wearable/lib/assets/devices/pair.png differ
diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart
index 70bead86..9bea2f14 100644
--- a/open_wearable/lib/main.dart
+++ b/open_wearable/lib/main.dart
@@ -4,12 +4,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
+import 'package:open_wearable/models/app_background_execution_bridge.dart';
+import 'package:open_wearable/models/app_launch_session.dart';
+import 'package:open_wearable/models/app_shutdown_settings.dart';
+import 'package:open_wearable/models/auto_connect_preferences.dart';
import 'package:open_wearable/models/log_file_manager.dart';
-import 'package:open_wearable/models/wearable_connector.dart';
+import 'package:open_wearable/models/fota_post_update_verification.dart';
+import 'package:open_wearable/models/wearable_connector.dart'
+ hide WearableEvent;
import 'package:open_wearable/router.dart';
+import 'package:open_wearable/theme/app_theme.dart';
import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
import 'package:open_wearable/widgets/app_banner.dart';
import 'package:open_wearable/widgets/global_app_banner_overlay.dart';
+import 'package:open_wearable/widgets/app_toast.dart';
+import 'package:open_wearable/widgets/fota/fota_verification_banner.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -23,6 +32,8 @@ void main() async {
LogFileManager logFileManager = await LogFileManager.create();
initOpenWearableLogger(logFileManager.libLogger);
initLogger(logFileManager.logger);
+ await AutoConnectPreferences.initialize();
+ await AppShutdownSettings.initialize();
runApp(
MultiProvider(
@@ -59,6 +70,19 @@ class _MyAppState extends State with WidgetsBindingObserver {
late final BluetoothAutoConnector _autoConnector;
late final Future _prefsFuture;
late final StreamSubscription _wearableProvEventSub;
+ late final WearablesProvider _wearablesProvider;
+ late final SensorRecorderProvider _sensorRecorderProvider;
+ bool _closingSensorShutdownInProgress = false;
+ bool _shouldCloseOpenScreensOnResume = false;
+ Timer? _pendingCloseShutdownTimer;
+ DateTime? _backgroundEnteredAt;
+ bool _backgroundExecutionRequestedForShutdown = false;
+ bool _backgroundExecutionRequestedForRecording = false;
+ bool _isBackgroundExecutionActive = false;
+
+ static const Duration _closeShutdownGracePeriod = Duration(
+ seconds: 10,
+ );
@override
void initState() {
@@ -67,10 +91,11 @@ class _MyAppState extends State with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
// Read provider without listening, allowed in initState with Provider
- final wearablesProvider = context.read();
+ _wearablesProvider = context.read();
+ _sensorRecorderProvider = context.read();
_unsupportedFirmwareSub =
- wearablesProvider.unsupportedFirmwareStream.listen((evt) {
+ _wearablesProvider.unsupportedFirmwareStream.listen((evt) {
// No async/await here. No widget context usage either.
final nav = rootNavigatorKey.currentState;
if (nav == null || !mounted) return;
@@ -98,7 +123,8 @@ class _MyAppState extends State with WidgetsBindingObserver {
);
});
- _wearableProvEventSub = wearablesProvider.wearableEventStream.listen((event) {
+ _wearableProvEventSub =
+ _wearablesProvider.wearableEventStream.listen((event) {
if (!mounted) return;
// Handle firmware update available events with a dialog
@@ -127,7 +153,8 @@ class _MyAppState extends State with WidgetsBindingObserver {
child: const Text('Update Now'),
onPressed: () {
// Set the selected peripheral for firmware update
- final updateProvider = Provider.of(
+ final updateProvider =
+ Provider.of(
rootNavigatorKey.currentContext!,
listen: false,
);
@@ -147,67 +174,268 @@ class _MyAppState extends State with WidgetsBindingObserver {
final appBannerController = context.read();
appBannerController.showBanner(
(id) {
- late final Color backgroundColor;
- if (event is WearableErrorEvent) {
- backgroundColor = Theme.of(context).colorScheme.error;
- } else {
- backgroundColor = Theme.of(context).colorScheme.primary;
- }
-
- late final Color textColor;
- if (event is WearableErrorEvent) {
- textColor = Theme.of(context).colorScheme.onError;
- } else {
- textColor = Theme.of(context).colorScheme.onPrimary;
- }
+ final colorScheme = Theme.of(context).colorScheme;
+ final bool isError = event is WearableErrorEvent;
+ final bool isTimeSync = event is WearableTimeSynchronizedEvent;
+ const timeSyncBackground = Color(0xFFEDE4FF);
+ const timeSyncForeground = Color(0xFF5A2EA6);
+ final backgroundColor = isError
+ ? colorScheme.errorContainer
+ : isTimeSync
+ ? timeSyncBackground
+ : colorScheme.primaryContainer;
+ final textColor = isError
+ ? colorScheme.onErrorContainer
+ : isTimeSync
+ ? timeSyncForeground
+ : colorScheme.onPrimaryContainer;
+ final icon = isError
+ ? Icons.error_outline_rounded
+ : isTimeSync
+ ? Icons.schedule_rounded
+ : Icons.info_outline_rounded;
+ final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: textColor,
+ fontWeight: FontWeight.w600,
+ );
return AppBanner(
- content: Text(
- event.description,
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: textColor,
- ),
+ content: _buildBannerContent(
+ event: event,
+ textColor: textColor,
+ textStyle: textStyle,
+ accentColor: textColor,
),
backgroundColor: backgroundColor,
+ foregroundColor: textColor,
+ leadingIcon: icon,
key: ValueKey(id),
);
},
- duration: const Duration(seconds: 3),
+ duration: const Duration(seconds: 4),
);
});
final WearableConnector connector = context.read();
- final SensorRecorderProvider sensorRecorderProvider =
- context.read();
_autoConnector = BluetoothAutoConnector(
navStateGetter: () => rootNavigatorKey.currentState,
wearableManager: WearableManager(),
- connector: connector,
prefsFuture: _prefsFuture,
- onWearableConnected: (wearable) {
- wearablesProvider.addWearable(wearable);
- sensorRecorderProvider.addWearable(wearable);
- },
+ onWearableConnected: _handleWearableConnected,
+ );
+ AutoConnectPreferences.autoConnectEnabledListenable.addListener(
+ _syncAutoConnectorWithSetting,
);
_wearableEventSub = connector.events.listen((event) {
if (event is WearableConnectEvent) {
- wearablesProvider.addWearable(event.wearable);
- sensorRecorderProvider.addWearable(event.wearable);
+ _handleWearableConnected(event.wearable);
}
});
- _autoConnector.start();
+ _syncAutoConnectorWithSetting();
+ }
+
+ void _syncAutoConnectorWithSetting() {
+ if (AutoConnectPreferences.autoConnectEnabled) {
+ _autoConnector.start();
+ return;
+ }
+ _autoConnector.stop();
+ }
+
+ void _handleWearableConnected(Wearable wearable) {
+ _wearablesProvider.addWearable(wearable);
+ _sensorRecorderProvider.addWearable(wearable);
+ _maybeFinalizePostUpdateVerification(wearable);
+ }
+
+ Future _maybeFinalizePostUpdateVerification(Wearable wearable) async {
+ final result = await FotaPostUpdateVerificationCoordinator.instance
+ .verifyOnWearableConnected(wearable);
+ if (!mounted || result == null) {
+ return;
+ }
+
+ dismissFotaVerificationBannerById(context, result.verificationId);
+ final accentColor = result.success
+ ? const Color(0xFF1E6A3A)
+ : Theme.of(context).colorScheme.onErrorContainer;
+ AppToast.showContent(
+ context,
+ content: _buildPostUpdateVerificationToastContent(
+ result: result,
+ accentColor: accentColor,
+ ),
+ type: result.success ? AppToastType.success : AppToastType.error,
+ icon:
+ result.success ? Icons.verified_rounded : Icons.error_outline_rounded,
+ duration: result.success
+ ? const Duration(seconds: 6)
+ : const Duration(seconds: 8),
+ );
+ }
+
+ Future _maybeTurnOffAllSensorsOnAppClose() async {
+ if (_closingSensorShutdownInProgress) {
+ return;
+ }
+
+ if (!AppShutdownSettings.shutOffAllSensorsOnAppClose ||
+ _sensorRecorderProvider.isRecording) {
+ _setBackgroundExecutionForShutdown(false);
+ return;
+ }
+
+ _closingSensorShutdownInProgress = true;
+ try {
+ await _wearablesProvider.turnOffSensorsForAllDevices();
+ _shouldCloseOpenScreensOnResume = true;
+ } catch (e, st) {
+ logger.w('Failed to shut off sensors on app close: $e\n$st');
+ } finally {
+ _setBackgroundExecutionForShutdown(false);
+ }
+ }
+
+ void _closeOpenScreensAfterSensorShutdownIfNeeded() {
+ if (!_shouldCloseOpenScreensOnResume) {
+ return;
+ }
+
+ _shouldCloseOpenScreensOnResume = false;
+ if (!AppLaunchSession.hasOpenAppFlow) {
+ return;
+ }
+
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ final nav = rootNavigatorKey.currentState;
+ if (nav == null || !nav.canPop() || !AppLaunchSession.hasOpenAppFlow) {
+ return;
+ }
+ nav.popUntil((route) => route.isFirst);
+ AppLaunchSession.reset();
+ });
+ }
+
+ void _setBackgroundExecutionForShutdown(bool enabled) {
+ if (_backgroundExecutionRequestedForShutdown == enabled) {
+ return;
+ }
+ _backgroundExecutionRequestedForShutdown = enabled;
+ _syncBackgroundExecutionWindow();
+ }
+
+ void _setBackgroundExecutionForRecording(bool enabled) {
+ if (_backgroundExecutionRequestedForRecording == enabled) {
+ return;
+ }
+ _backgroundExecutionRequestedForRecording = enabled;
+ _syncBackgroundExecutionWindow();
+ }
+
+ void _syncBackgroundExecutionWindow() {
+ final shouldHoldBackgroundExecution =
+ _backgroundExecutionRequestedForShutdown ||
+ _backgroundExecutionRequestedForRecording;
+ if (shouldHoldBackgroundExecution == _isBackgroundExecutionActive) {
+ return;
+ }
+
+ _isBackgroundExecutionActive = shouldHoldBackgroundExecution;
+ if (shouldHoldBackgroundExecution) {
+ unawaited(AppBackgroundExecutionBridge.beginSensorShutdownWindow());
+ return;
+ }
+ unawaited(AppBackgroundExecutionBridge.endSensorShutdownWindow());
+ }
+
+ void _scheduleCloseShutdownIfNeeded() {
+ _pendingCloseShutdownTimer?.cancel();
+ _pendingCloseShutdownTimer = null;
+
+ if (_sensorRecorderProvider.isRecording ||
+ !AppShutdownSettings.shutOffAllSensorsOnAppClose) {
+ _setBackgroundExecutionForShutdown(false);
+ return;
+ }
+
+ _setBackgroundExecutionForShutdown(true);
+ _pendingCloseShutdownTimer = Timer(_closeShutdownGracePeriod, () {
+ _pendingCloseShutdownTimer = null;
+ unawaited(_maybeTurnOffAllSensorsOnAppClose());
+ });
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
- _autoConnector.start();
+ final backgroundEnteredAt = _backgroundEnteredAt;
+ _backgroundEnteredAt = null;
+
+ _pendingCloseShutdownTimer?.cancel();
+ _pendingCloseShutdownTimer = null;
+ _setBackgroundExecutionForShutdown(false);
+ _setBackgroundExecutionForRecording(false);
+
+ final shouldCatchUpShutdown =
+ AppShutdownSettings.shutOffAllSensorsOnAppClose &&
+ !_shouldCloseOpenScreensOnResume &&
+ !_closingSensorShutdownInProgress &&
+ !_sensorRecorderProvider.isRecording &&
+ backgroundEnteredAt != null &&
+ DateTime.now().difference(backgroundEnteredAt) >=
+ _closeShutdownGracePeriod;
+ if (shouldCatchUpShutdown) {
+ unawaited(
+ () async {
+ await _maybeTurnOffAllSensorsOnAppClose();
+ if (!mounted) {
+ return;
+ }
+ _closeOpenScreensAfterSensorShutdownIfNeeded();
+ _closingSensorShutdownInProgress = false;
+ _syncAutoConnectorWithSetting();
+ }(),
+ );
+ return;
+ }
+
+ _closingSensorShutdownInProgress = false;
+ _closeOpenScreensAfterSensorShutdownIfNeeded();
+ _syncAutoConnectorWithSetting();
+ } else if (state == AppLifecycleState.inactive) {
+ _backgroundEnteredAt ??= DateTime.now();
+ if (_sensorRecorderProvider.isRecording) {
+ _pendingCloseShutdownTimer?.cancel();
+ _pendingCloseShutdownTimer = null;
+ _setBackgroundExecutionForShutdown(false);
+ _setBackgroundExecutionForRecording(true);
+ } else {
+ _setBackgroundExecutionForRecording(false);
+ _scheduleCloseShutdownIfNeeded();
+ }
} else if (state == AppLifecycleState.paused) {
_autoConnector.stop();
+ _backgroundEnteredAt ??= DateTime.now();
+ if (_sensorRecorderProvider.isRecording) {
+ _pendingCloseShutdownTimer?.cancel();
+ _pendingCloseShutdownTimer = null;
+ _setBackgroundExecutionForShutdown(false);
+ _setBackgroundExecutionForRecording(true);
+ } else {
+ _setBackgroundExecutionForRecording(false);
+ _scheduleCloseShutdownIfNeeded();
+ }
+ } else if (state == AppLifecycleState.detached) {
+ _backgroundEnteredAt = null;
+ _pendingCloseShutdownTimer?.cancel();
+ _pendingCloseShutdownTimer = null;
+ _setBackgroundExecutionForShutdown(false);
+ _setBackgroundExecutionForRecording(false);
+ _autoConnector.stop();
}
}
@@ -230,12 +458,178 @@ class _MyAppState extends State with WidgetsBindingObserver {
}
}
+ Widget _buildPostUpdateVerificationToastContent({
+ required FotaPostUpdateVerificationResult result,
+ required Color accentColor,
+ }) {
+ final titleStyle = Theme.of(context).textTheme.titleSmall?.copyWith(
+ color: accentColor,
+ fontWeight: FontWeight.w800,
+ height: 1.05,
+ );
+ final detailStyle = Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: accentColor,
+ fontWeight: FontWeight.w600,
+ );
+ final statusLabel = result.success ? 'Update verified' : 'Update failed';
+
+ final detailText = _verificationToastDetail(result);
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ Text(
+ '$statusLabel: ${result.wearableName}',
+ style: titleStyle,
+ ),
+ if (result.sideLabel != null)
+ _ToastStereoSideBadge(
+ sideLabel: result.sideLabel!,
+ accentColor: accentColor,
+ ),
+ ],
+ ),
+ if (detailText != null) ...[
+ const SizedBox(height: 6),
+ Text(detailText, style: detailStyle),
+ ],
+ ],
+ );
+ }
+
+ String? _verificationToastDetail(FotaPostUpdateVerificationResult result) {
+ if (result.success) {
+ final version = result.detectedFirmwareVersion;
+ if (version == null) {
+ return null;
+ }
+ return 'Firmware version: $version';
+ }
+
+ final detected = result.detectedFirmwareVersion;
+ final expected = result.expectedFirmwareVersion;
+ final expectedLabel = expected ?? 'unknown';
+ final detectedLabel = detected ?? 'unknown';
+ return 'Expected: $expectedLabel. Detected: $detectedLabel.';
+ }
+
+ Widget _buildBannerContent({
+ required WearableEvent event,
+ required Color textColor,
+ required Color accentColor,
+ TextStyle? textStyle,
+ }) {
+ final isTimeSync = event is WearableTimeSynchronizedEvent;
+ final normalizedDescription = isTimeSync
+ ? _removeTrailingPeriod(event.description)
+ : _ensureSentenceEndsWithPeriod(event.description);
+ final resolvedTextStyle = textStyle ??
+ Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: textColor,
+ fontWeight: FontWeight.w600,
+ ) ??
+ TextStyle(
+ color: textColor,
+ fontWeight: FontWeight.w600,
+ );
+
+ if (!isTimeSync) {
+ return Text(normalizedDescription, style: resolvedTextStyle);
+ }
+
+ final parsed = _ParsedStereoSyncMessage.tryParse(normalizedDescription);
+ if (parsed == null) {
+ return Text(normalizedDescription, style: resolvedTextStyle);
+ }
+
+ return Text.rich(
+ TextSpan(
+ style: resolvedTextStyle,
+ children: [
+ if (parsed.prefix.isNotEmpty) TextSpan(text: '${parsed.prefix} '),
+ WidgetSpan(
+ alignment: PlaceholderAlignment.middle,
+ child: _ToastStereoSideBadge(
+ sideLabel: parsed.sideLabel,
+ accentColor: accentColor,
+ ),
+ ),
+ if (parsed.suffix.isNotEmpty) TextSpan(text: ' ${parsed.suffix}'),
+ ],
+ ),
+ );
+ }
+
+ String _ensureSentenceEndsWithPeriod(String text) {
+ final trimmed = text.trimRight();
+ if (trimmed.isEmpty) {
+ return trimmed;
+ }
+
+ final lastChar = trimmed[trimmed.length - 1];
+ if (lastChar == '.' || lastChar == '!' || lastChar == '?') {
+ return trimmed;
+ }
+
+ if (trimmed.length > 1 &&
+ (lastChar == '"' ||
+ lastChar == '\'' ||
+ lastChar == ')' ||
+ lastChar == ']')) {
+ final previousChar = trimmed[trimmed.length - 2];
+ if (previousChar == '.' || previousChar == '!' || previousChar == '?') {
+ return trimmed;
+ }
+ }
+
+ return '$trimmed.';
+ }
+
+ String _removeTrailingPeriod(String text) {
+ final trimmed = text.trimRight();
+ if (trimmed.isEmpty) {
+ return trimmed;
+ }
+
+ final lastChar = trimmed[trimmed.length - 1];
+ if (lastChar == '.') {
+ return trimmed.substring(0, trimmed.length - 1);
+ }
+
+ if (trimmed.length > 1 &&
+ (lastChar == '"' ||
+ lastChar == '\'' ||
+ lastChar == ')' ||
+ lastChar == ']')) {
+ final previousChar = trimmed[trimmed.length - 2];
+ if (previousChar == '.') {
+ return '${trimmed.substring(0, trimmed.length - 2)}$lastChar';
+ }
+ }
+
+ return trimmed;
+ }
+
@override
void dispose() {
_unsupportedFirmwareSub.cancel();
_wearableEventSub.cancel();
_wearableProvEventSub.cancel();
+ AutoConnectPreferences.autoConnectEnabledListenable.removeListener(
+ _syncAutoConnectorWithSetting,
+ );
WidgetsBinding.instance.removeObserver(this);
+ _pendingCloseShutdownTimer?.cancel();
+ _pendingCloseShutdownTimer = null;
+ _backgroundEnteredAt = null;
+ _setBackgroundExecutionForShutdown(false);
+ _setBackgroundExecutionForRecording(false);
_autoConnector.stop();
super.dispose();
}
@@ -244,17 +638,15 @@ class _MyAppState extends State with WidgetsBindingObserver {
Widget build(BuildContext context) {
return PlatformProvider(
settings: PlatformSettingsData(
- iosUsesMaterialWidgets: true,
+ platformStyle: const PlatformStyleData(
+ ios: PlatformStyle.Material,
+ macos: PlatformStyle.Material,
+ ),
),
builder: (context) => PlatformTheme(
- materialLightTheme: ThemeData(
- useMaterial3: true,
- colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
- cardTheme: const CardThemeData(
- color: Colors.white,
- elevation: 0,
- ),
- ),
+ materialLightTheme: AppTheme.lightTheme(),
+ materialDarkTheme: AppTheme.darkTheme(),
+ themeMode: ThemeMode.light,
builder: (context) => GlobalAppBannerOverlay(
child: PlatformApp.router(
routerConfig: router,
@@ -270,3 +662,72 @@ class _MyAppState extends State with WidgetsBindingObserver {
);
}
}
+
+class _ParsedStereoSyncMessage {
+ final String prefix;
+ final String sideLabel;
+ final String suffix;
+
+ const _ParsedStereoSyncMessage({
+ required this.prefix,
+ required this.sideLabel,
+ required this.suffix,
+ });
+
+ static _ParsedStereoSyncMessage? tryParse(String message) {
+ final match = RegExp(r'\((Left|Right)\)').firstMatch(message);
+ if (match == null) return null;
+
+ final sideWord = match.group(1);
+ final sideLabel = switch (sideWord) {
+ 'Left' => 'L',
+ 'Right' => 'R',
+ _ => null,
+ };
+ if (sideLabel == null) return null;
+
+ final prefix = message.substring(0, match.start).trimRight();
+ final suffix = message.substring(match.end).trimLeft();
+
+ return _ParsedStereoSyncMessage(
+ prefix: prefix,
+ sideLabel: sideLabel,
+ suffix: suffix,
+ );
+ }
+}
+
+class _ToastStereoSideBadge extends StatelessWidget {
+ final String sideLabel;
+ final Color accentColor;
+
+ const _ToastStereoSideBadge({
+ required this.sideLabel,
+ required this.accentColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final foreground = accentColor;
+ final background = foreground.withValues(alpha: 0.16);
+ final border = foreground.withValues(alpha: 0.34);
+
+ return Container(
+ margin: const EdgeInsets.symmetric(horizontal: 1),
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: background,
+ borderRadius: BorderRadius.circular(999),
+ border: Border.all(color: border),
+ ),
+ child: Text(
+ sideLabel,
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: foreground,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 0.1,
+ ),
+ ),
+ );
+ }
+}
diff --git a/open_wearable/lib/models/app_background_execution_bridge.dart b/open_wearable/lib/models/app_background_execution_bridge.dart
new file mode 100644
index 00000000..6430f1fb
--- /dev/null
+++ b/open_wearable/lib/models/app_background_execution_bridge.dart
@@ -0,0 +1,27 @@
+import 'package:flutter/services.dart';
+
+/// Best-effort platform bridge for temporary background execution windows.
+///
+/// Used when the app needs short background time for sensor shutdown or
+/// recorder-related handoff.
+class AppBackgroundExecutionBridge {
+ static const MethodChannel _channel = MethodChannel(
+ 'edu.kit.teco.open_wearable/lifecycle',
+ );
+
+ static Future beginSensorShutdownWindow() async {
+ try {
+ await _channel.invokeMethod('beginBackgroundExecution');
+ } catch (_) {
+ // Best-effort bridge. Missing plugin / unsupported platform is fine.
+ }
+ }
+
+ static Future endSensorShutdownWindow() async {
+ try {
+ await _channel.invokeMethod('endBackgroundExecution');
+ } catch (_) {
+ // Best-effort bridge. Missing plugin / unsupported platform is fine.
+ }
+ }
+}
diff --git a/open_wearable/lib/models/app_launch_session.dart b/open_wearable/lib/models/app_launch_session.dart
new file mode 100644
index 00000000..9e37447e
--- /dev/null
+++ b/open_wearable/lib/models/app_launch_session.dart
@@ -0,0 +1,25 @@
+/// Tracks whether an in-app feature flow is currently open.
+///
+/// This is used by lifecycle logic to decide whether temporary flow screens
+/// should be closed after background-triggered sensor shutdown.
+class AppLaunchSession {
+ static int _openAppFlowCount = 0;
+
+ static bool get hasOpenAppFlow => _openAppFlowCount > 0;
+
+ static void markAppFlowOpened() {
+ _openAppFlowCount += 1;
+ }
+
+ static void markAppFlowClosed() {
+ if (_openAppFlowCount <= 0) {
+ _openAppFlowCount = 0;
+ return;
+ }
+ _openAppFlowCount -= 1;
+ }
+
+ static void reset() {
+ _openAppFlowCount = 0;
+ }
+}
diff --git a/open_wearable/lib/models/app_shutdown_settings.dart b/open_wearable/lib/models/app_shutdown_settings.dart
new file mode 100644
index 00000000..f2fa9384
--- /dev/null
+++ b/open_wearable/lib/models/app_shutdown_settings.dart
@@ -0,0 +1,112 @@
+import 'package:flutter/foundation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// Persisted app-wide shutdown and live-data display settings.
+///
+/// Needs:
+/// - `SharedPreferences` availability.
+///
+/// Does:
+/// - Loads/saves settings and mirrors them through `ValueNotifier`s.
+///
+/// Provides:
+/// - Synchronous getters and `ValueListenable`s consumed by UI/lifecycle code.
+class AppShutdownSettings {
+ static const String _shutOffAllSensorsOnAppCloseKey =
+ 'app_shut_off_all_sensors_on_close';
+ static const String _disableLiveDataGraphsKey =
+ 'app_disable_live_data_graphs';
+ static const String _hideLiveDataGraphsWithoutDataKey =
+ 'app_hide_live_data_graphs_without_data';
+
+ static final ValueNotifier _shutOffAllSensorsOnAppCloseNotifier =
+ ValueNotifier(false);
+ static final ValueNotifier _disableLiveDataGraphsNotifier =
+ ValueNotifier(false);
+ static final ValueNotifier _hideLiveDataGraphsWithoutDataNotifier =
+ ValueNotifier(false);
+
+ static ValueListenable get shutOffAllSensorsOnAppCloseListenable =>
+ _shutOffAllSensorsOnAppCloseNotifier;
+ static ValueListenable get disableLiveDataGraphsListenable =>
+ _disableLiveDataGraphsNotifier;
+ static ValueListenable get hideLiveDataGraphsWithoutDataListenable =>
+ _hideLiveDataGraphsWithoutDataNotifier;
+
+ static bool get shutOffAllSensorsOnAppClose =>
+ _shutOffAllSensorsOnAppCloseNotifier.value;
+ static bool get disableLiveDataGraphs => _disableLiveDataGraphsNotifier.value;
+ static bool get hideLiveDataGraphsWithoutData =>
+ _hideLiveDataGraphsWithoutDataNotifier.value;
+
+ static Future initialize() async {
+ await Future.wait([
+ loadShutOffAllSensorsOnAppClose(),
+ loadDisableLiveDataGraphs(),
+ loadHideLiveDataGraphsWithoutData(),
+ ]);
+ }
+
+ static Future loadShutOffAllSensorsOnAppClose() async {
+ final prefs = await SharedPreferences.getInstance();
+ final enabled = prefs.getBool(_shutOffAllSensorsOnAppCloseKey) ?? false;
+ _setShutOffAllSensorsOnAppClose(enabled);
+ return enabled;
+ }
+
+ static Future saveShutOffAllSensorsOnAppClose(bool enabled) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_shutOffAllSensorsOnAppCloseKey, enabled);
+ _setShutOffAllSensorsOnAppClose(enabled);
+ return enabled;
+ }
+
+ static Future loadDisableLiveDataGraphs() async {
+ final prefs = await SharedPreferences.getInstance();
+ final enabled = prefs.getBool(_disableLiveDataGraphsKey) ?? false;
+ _setDisableLiveDataGraphs(enabled);
+ return enabled;
+ }
+
+ static Future saveDisableLiveDataGraphs(bool enabled) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_disableLiveDataGraphsKey, enabled);
+ _setDisableLiveDataGraphs(enabled);
+ return enabled;
+ }
+
+ static Future loadHideLiveDataGraphsWithoutData() async {
+ final prefs = await SharedPreferences.getInstance();
+ final enabled = prefs.getBool(_hideLiveDataGraphsWithoutDataKey) ?? false;
+ _setHideLiveDataGraphsWithoutData(enabled);
+ return enabled;
+ }
+
+ static Future saveHideLiveDataGraphsWithoutData(bool enabled) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_hideLiveDataGraphsWithoutDataKey, enabled);
+ _setHideLiveDataGraphsWithoutData(enabled);
+ return enabled;
+ }
+
+ static void _setShutOffAllSensorsOnAppClose(bool enabled) {
+ if (_shutOffAllSensorsOnAppCloseNotifier.value == enabled) {
+ return;
+ }
+ _shutOffAllSensorsOnAppCloseNotifier.value = enabled;
+ }
+
+ static void _setDisableLiveDataGraphs(bool enabled) {
+ if (_disableLiveDataGraphsNotifier.value == enabled) {
+ return;
+ }
+ _disableLiveDataGraphsNotifier.value = enabled;
+ }
+
+ static void _setHideLiveDataGraphsWithoutData(bool enabled) {
+ if (_hideLiveDataGraphsWithoutDataNotifier.value == enabled) {
+ return;
+ }
+ _hideLiveDataGraphsWithoutDataNotifier.value = enabled;
+ }
+}
diff --git a/open_wearable/lib/models/auto_connect_preferences.dart b/open_wearable/lib/models/auto_connect_preferences.dart
new file mode 100644
index 00000000..420932ac
--- /dev/null
+++ b/open_wearable/lib/models/auto_connect_preferences.dart
@@ -0,0 +1,131 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// Persistent preferences for Bluetooth auto-connect behavior.
+///
+/// Needs:
+/// - `SharedPreferences` storage.
+///
+/// Does:
+/// - Stores auto-connect enabled state.
+/// - Stores remembered wearable names used for reconnect targeting.
+/// - Emits change notifications for connector logic.
+///
+/// Provides:
+/// - Static getters/listenables and helper methods for preference updates.
+class AutoConnectPreferences {
+ static const String connectedDeviceNamesKey = 'connectedDeviceNames';
+ static const String autoConnectEnabledKey = 'auto_connect_enabled';
+ static final StreamController _changesController =
+ StreamController.broadcast();
+ static final ValueNotifier _autoConnectEnabledNotifier =
+ ValueNotifier(true);
+
+ static Stream get changes => _changesController.stream;
+ static ValueListenable get autoConnectEnabledListenable =>
+ _autoConnectEnabledNotifier;
+ static bool get autoConnectEnabled => _autoConnectEnabledNotifier.value;
+
+ static Future initialize() async {
+ await loadAutoConnectEnabled();
+ }
+
+ static Future loadAutoConnectEnabled() async {
+ final prefs = await SharedPreferences.getInstance();
+ final enabled = prefs.getBool(autoConnectEnabledKey) ?? true;
+ _setAutoConnectEnabled(enabled);
+ return enabled;
+ }
+
+ static Future saveAutoConnectEnabled(bool enabled) async {
+ final prefs = await SharedPreferences.getInstance();
+ final success = await prefs.setBool(autoConnectEnabledKey, enabled);
+ if (success) {
+ _setAutoConnectEnabled(enabled);
+ _changesController.add(null);
+ }
+ return enabled;
+ }
+
+ static List readRememberedDeviceNames(SharedPreferences prefs) {
+ final names =
+ prefs.getStringList(connectedDeviceNamesKey) ?? const [];
+ final normalizedNames = [];
+
+ for (final name in names) {
+ final normalizedName = name.trim();
+ if (normalizedName.isEmpty) {
+ continue;
+ }
+ normalizedNames.add(normalizedName);
+ }
+
+ return normalizedNames;
+ }
+
+ static int countRememberedDeviceName(
+ SharedPreferences prefs,
+ String deviceName,
+ ) {
+ final normalizedName = deviceName.trim();
+ if (normalizedName.isEmpty) {
+ return 0;
+ }
+ final names = readRememberedDeviceNames(prefs);
+ return names.where((name) => name == normalizedName).length;
+ }
+
+ static Future rememberDeviceName(
+ SharedPreferences prefs,
+ String deviceName,
+ ) async {
+ final normalizedName = deviceName.trim();
+ if (normalizedName.isEmpty) {
+ return;
+ }
+
+ final names = readRememberedDeviceNames(prefs);
+
+ final success = await prefs.setStringList(connectedDeviceNamesKey, [
+ ...names,
+ normalizedName,
+ ]);
+ if (success) {
+ _changesController.add(null);
+ }
+ }
+
+ static Future forgetDeviceName(
+ SharedPreferences prefs,
+ String deviceName,
+ ) async {
+ final normalizedName = deviceName.trim();
+ if (normalizedName.isEmpty) {
+ return;
+ }
+
+ final names = readRememberedDeviceNames(prefs);
+ final index = names.indexOf(normalizedName);
+ if (index < 0) {
+ return;
+ }
+ final updatedNames = [...names]..removeAt(index);
+
+ final success = await prefs.setStringList(
+ connectedDeviceNamesKey,
+ updatedNames,
+ );
+ if (success) {
+ _changesController.add(null);
+ }
+ }
+
+ static void _setAutoConnectEnabled(bool enabled) {
+ if (_autoConnectEnabledNotifier.value == enabled) {
+ return;
+ }
+ _autoConnectEnabledNotifier.value = enabled;
+ }
+}
diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart
index bc68d6b8..b7534d42 100644
--- a/open_wearable/lib/models/bluetooth_auto_connector.dart
+++ b/open_wearable/lib/models/bluetooth_auto_connector.dart
@@ -3,120 +3,300 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
-import 'package:shared_preferences/shared_preferences.dart'; // New dependency for persistence
+import 'package:shared_preferences/shared_preferences.dart';
+import 'auto_connect_preferences.dart';
import 'logger.dart';
-import 'wearable_connector.dart';
-
-const String _connectedDeviceNamesKey = "connectedDeviceNames";
+/// Background reconnect orchestrator for remembered Bluetooth wearables.
+///
+/// Needs:
+/// - `WearableManager` scanning/connection APIs.
+/// - `AutoConnectPreferences` values and change stream.
+/// - Navigation access for permission dialogs.
+///
+/// Does:
+/// - Tracks target wearable names from preferences.
+/// - Scans and connects matching devices with retry logic.
+/// - Maintains per-session connection bookkeeping and disconnect recovery.
+///
+/// Provides:
+/// - `start()` / `stop()` lifecycle control and `onWearableConnected` callback.
class BluetoothAutoConnector {
+ static const Duration _scanRetryInterval = Duration(seconds: 3);
+
final NavigatorState? Function() navStateGetter;
final WearableManager wearableManager;
- final WearableConnector connector;
final Future prefsFuture;
final void Function(Wearable wearable) onWearableConnected;
StreamSubscription? _connectSubscription;
StreamSubscription? _scanSubscription;
+ StreamSubscription? _preferencesSubscription;
+ Timer? _scanRetryTimer;
bool _isConnecting = false;
+ bool _isAttemptingConnection = false;
bool _askedPermissionsThisSession = false;
+ int _sessionToken = 0;
// Names to look for during scanning
List _targetNames = [];
+ Map _targetNameCounts = const {};
+ final Set _connectedDeviceIds = {};
+ final Map _connectedNameCounts = {};
+ final Set _pendingDeviceIds = {};
BluetoothAutoConnector({
required this.navStateGetter,
required this.wearableManager,
- required this.connector,
required this.prefsFuture,
required this.onWearableConnected,
});
void start() async {
- stop();
+ final token = ++_sessionToken;
+ _stopInternal();
+ _connectedDeviceIds.clear();
+ _connectedNameCounts.clear();
+ _pendingDeviceIds.clear();
// Load the last connected names
- final prefs = await prefsFuture;
- _targetNames = prefs.getStringList(_connectedDeviceNamesKey) ?? [];
+ await _reloadTargetNames(token: token, reloadPrefs: false);
+ if (token != _sessionToken) {
+ return;
+ }
// Start listening for successful connections (to save names and set disconnect logic)
_connectSubscription =
wearableManager.connectStream.listen(_onDeviceConnected);
+ _preferencesSubscription = AutoConnectPreferences.changes.listen((_) {
+ unawaited(_syncTargetsWithPreferences(token: token, restartScan: true));
+ });
+ _ensureScanRetryLoop(token: token);
// Initiate the connection sequence
- _attemptConnection();
+ _attemptConnection(token: token);
}
void stop() {
+ _sessionToken++;
+ _stopInternal();
+ }
+
+ void _stopInternal() {
_connectSubscription?.cancel();
_connectSubscription = null;
- _scanSubscription?.cancel();
- _scanSubscription = null;
- // Stop any ongoing scan initiated by this class
- // Use the public WearableManager function to stop the scan
- wearableManager.setAutoConnect([]);
+ _preferencesSubscription?.cancel();
+ _preferencesSubscription = null;
+ _isAttemptingConnection = false;
+ _isConnecting = false;
+ _pendingDeviceIds.clear();
+ _scanRetryTimer?.cancel();
+ _scanRetryTimer = null;
+ _stopScanning();
+ }
- // Cancel the local listener to prevent further triggers
- _scanSubscription?.cancel();
- _scanSubscription = null;
+ String _normalizeDeviceId(String id) => id.trim().toUpperCase();
+
+ Map _buildNameCounts(List names) {
+ final counts = {};
+ for (final name in names) {
+ counts[name] = (counts[name] ?? 0) + 1;
+ }
+ return counts;
}
- /// Called when the WearableManager successfully connects to a device.
- void _onDeviceConnected(Wearable wearable) async {
+ int _requiredConnectionsForName(String name) => _targetNameCounts[name] ?? 0;
+
+ void _markConnected({
+ required String deviceId,
+ required String deviceName,
+ }) {
+ final normalizedId = _normalizeDeviceId(deviceId);
+ final inserted = _connectedDeviceIds.add(normalizedId);
+ if (!inserted) {
+ return;
+ }
+ _connectedNameCounts[deviceName] =
+ (_connectedNameCounts[deviceName] ?? 0) + 1;
+ }
+
+ void _markDisconnected({
+ required String deviceId,
+ required String deviceName,
+ }) {
+ final normalizedId = _normalizeDeviceId(deviceId);
+ final removed = _connectedDeviceIds.remove(normalizedId);
+ if (!removed) {
+ return;
+ }
+ final current = _connectedNameCounts[deviceName];
+ if (current == null) {
+ return;
+ }
+ if (current <= 1) {
+ _connectedNameCounts.remove(deviceName);
+ return;
+ }
+ _connectedNameCounts[deviceName] = current - 1;
+ }
+
+ bool _hasUnconnectedTargets() {
+ if (_targetNameCounts.isEmpty) {
+ return false;
+ }
+ return _targetNameCounts.entries.any((entry) {
+ return (_connectedNameCounts[entry.key] ?? 0) < entry.value;
+ });
+ }
+
+ String _deviceErrorMessageSafe(Object error, DiscoveredDevice device) {
+ try {
+ return wearableManager.deviceErrorMessage(error, device.name);
+ } catch (_) {
+ final fallback = error.toString().trim();
+ if (fallback.isEmpty) {
+ return 'Unknown connection error.';
+ }
+ return fallback;
+ }
+ }
+
+ bool _isAlreadyConnectedMessage(String message) =>
+ message.toLowerCase().contains('already connected');
+
+ Future _reloadTargetNames({
+ required int token,
+ bool reloadPrefs = true,
+ }) async {
final prefs = await prefsFuture;
+ if (token != _sessionToken) {
+ return;
+ }
+ if (reloadPrefs) {
+ await prefs.reload();
+ if (token != _sessionToken) {
+ return;
+ }
+ }
+ _targetNames = AutoConnectPreferences.readRememberedDeviceNames(prefs);
+ _targetNameCounts = _buildNameCounts(_targetNames);
+ }
- List deviceNames =
- prefs.getStringList(_connectedDeviceNamesKey) ?? [];
- if (!deviceNames.contains(wearable.name)) {
- deviceNames.add(wearable.name);
- await prefs.setStringList(_connectedDeviceNamesKey, deviceNames);
+ Future _syncTargetsWithPreferences({
+ required int token,
+ bool restartScan = false,
+ }) async {
+ await _reloadTargetNames(token: token);
+ if (token != _sessionToken) {
+ return;
}
- // Stop scanning immediately when a successful connection is made
- if (_scanSubscription != null) {
- // stop scan
- wearableManager.setAutoConnect([]);
+ if (_targetNames.isEmpty || !_hasUnconnectedTargets()) {
+ _stopScanning();
+ return;
+ }
- _scanSubscription?.cancel();
- _scanSubscription = null;
+ if (restartScan) {
+ await _restartScanIfNeeded();
+ }
+ }
- _scanSubscription?.cancel();
- _scanSubscription = null;
+ void _ensureScanRetryLoop({required int token}) {
+ _scanRetryTimer?.cancel();
+ _scanRetryTimer = Timer.periodic(_scanRetryInterval, (timer) {
+ if (token != _sessionToken) {
+ timer.cancel();
+ return;
+ }
+ if (_isAttemptingConnection || _isConnecting) {
+ return;
+ }
+ unawaited(_syncTargetsWithPreferences(token: token, restartScan: true));
+ });
+ }
+
+ /// Called when the WearableManager successfully connects to a device.
+ void _onDeviceConnected(Wearable wearable) async {
+ final token = _sessionToken;
+ _markConnected(deviceId: wearable.deviceId, deviceName: wearable.name);
+
+ final prefs = await prefsFuture;
+ if (token != _sessionToken) {
+ return;
+ }
+ final rememberedCount = AutoConnectPreferences.countRememberedDeviceName(
+ prefs,
+ wearable.name,
+ );
+ final connectedCount = _connectedNameCounts[wearable.name] ?? 0;
+ if (connectedCount > rememberedCount) {
+ await AutoConnectPreferences.rememberDeviceName(prefs, wearable.name);
}
+ // Stop scanning immediately when a successful connection is made
+ _stopScanning();
+
// Set up the disconnect listener to trigger a scan for the saved name.
- wearable.addDisconnectListener(() {
+ wearable.addDisconnectListener(() async {
+ if (token != _sessionToken) {
+ return;
+ }
logger.i(
"Device ${wearable.name} disconnected. Initiating reconnection scan.",
);
+ _markDisconnected(deviceId: wearable.deviceId, deviceName: wearable.name);
- prefs.reload();
- _targetNames = prefs.getStringList(_connectedDeviceNamesKey) ?? [];
+ await _syncTargetsWithPreferences(token: token);
- _attemptConnection();
+ if (_hasUnconnectedTargets()) {
+ _attemptConnection();
+ }
});
}
- Future _attemptConnection() async {
+ Future _attemptConnection({int? token}) async {
+ final activeToken = token ?? _sessionToken;
+ if (activeToken != _sessionToken) {
+ return;
+ }
+ if (_isAttemptingConnection) {
+ return;
+ }
+
+ _isAttemptingConnection = true;
if (!Platform.isIOS) {
final hasPerm = await wearableManager.hasPermissions();
+ if (activeToken != _sessionToken) {
+ _isAttemptingConnection = false;
+ return;
+ }
if (!hasPerm) {
if (!_askedPermissionsThisSession) {
_askedPermissionsThisSession = true;
_showPermissionsDialog();
}
logger.w('Skipping auto-connect: no permissions granted yet.');
+ _isAttemptingConnection = false;
return;
}
}
- await connector.connectToSystemDevices();
+ try {
+ await _syncTargetsWithPreferences(token: activeToken);
+ if (activeToken != _sessionToken) {
+ return;
+ }
- if (_targetNames.isNotEmpty) {
- _setupScanListener();
- await wearableManager.startScan();
+ if (_targetNames.isNotEmpty && _hasUnconnectedTargets()) {
+ _setupScanListener();
+ await wearableManager.startScan();
+ }
+ } catch (error, stackTrace) {
+ logger.w('Auto-connect attempt failed: $error\n$stackTrace');
+ } finally {
+ _isAttemptingConnection = false;
}
}
@@ -126,31 +306,77 @@ class BluetoothAutoConnector {
_scanSubscription = wearableManager.scanStream.listen((device) {
if (_isConnecting) return;
- if (_targetNames.contains(device.name)) {
- _isConnecting = true;
- // stop scan
- wearableManager.setAutoConnect([]);
- _scanSubscription?.cancel();
- _scanSubscription = null;
+ final normalizedId = _normalizeDeviceId(device.id);
+ if (_pendingDeviceIds.contains(normalizedId) ||
+ _connectedDeviceIds.contains(normalizedId)) {
+ return;
+ }
+ final requiredConnections = _requiredConnectionsForName(device.name);
+ if (requiredConnections == 0) {
+ return;
+ }
+ if ((_connectedNameCounts[device.name] ?? 0) >= requiredConnections) {
+ return;
+ }
- logger.i(
- "Match found for ${device.name}. Connecting using rotating ID: ${device.id}",
- );
+ _isConnecting = true;
+ _pendingDeviceIds.add(normalizedId);
+ _stopScanning();
+
+ logger.i(
+ "Match found for ${device.name}. Connecting using rotating ID: ${device.id}",
+ );
- wearableManager
- .connectToDevice(device)
- .then(onWearableConnected)
- .catchError((e) {
- logger.e(
- "Failed to connect to ${device.id}: ${wearableManager.deviceErrorMessage(e, device.name)}",
+ wearableManager.connectToDevice(device).then((wearable) {
+ _markConnected(
+ deviceId: wearable.deviceId,
+ deviceName: wearable.name,
+ );
+ onWearableConnected(wearable);
+ }).catchError((error, stackTrace) {
+ final message = _deviceErrorMessageSafe(error, device);
+ if (_isAlreadyConnectedMessage(message)) {
+ _markConnected(deviceId: device.id, deviceName: device.name);
+ logger.i(
+ 'Skipping auto-connect for ${device.id}: $message',
);
- }).whenComplete(() {
- _isConnecting = false;
- });
- }
+ return;
+ }
+ logger.w(
+ 'Failed to connect to ${device.id}: $message\n$stackTrace',
+ );
+ }).whenComplete(() {
+ _pendingDeviceIds.remove(normalizedId);
+ _isConnecting = false;
+ unawaited(_restartScanIfNeeded());
+ });
});
}
+ Future _restartScanIfNeeded() async {
+ if (_isConnecting || _isAttemptingConnection) {
+ return;
+ }
+ if (_scanSubscription != null) {
+ return;
+ }
+ if (!_hasUnconnectedTargets()) {
+ return;
+ }
+ try {
+ _setupScanListener();
+ await wearableManager.startScan();
+ } catch (error, stackTrace) {
+ logger.w('Failed to restart auto-connect scan: $error\n$stackTrace');
+ _stopScanning();
+ }
+ }
+
+ void _stopScanning() {
+ _scanSubscription?.cancel();
+ _scanSubscription = null;
+ }
+
void _showPermissionsDialog() {
final nav = navStateGetter();
final navCtx = nav?.context;
diff --git a/open_wearable/lib/models/device_name_formatter.dart b/open_wearable/lib/models/device_name_formatter.dart
new file mode 100644
index 00000000..abcaf800
--- /dev/null
+++ b/open_wearable/lib/models/device_name_formatter.dart
@@ -0,0 +1,31 @@
+/// Normalizes wearable names for UI display.
+///
+/// Converts legacy `bcl...` prefixes into `OpenRing-...` and keeps all other
+/// names unchanged.
+String formatWearableDisplayName(String rawName) {
+ final trimmed = rawName.trim();
+ if (trimmed.isEmpty) {
+ return trimmed;
+ }
+
+ final replaced = trimmed.replaceFirst(
+ RegExp(r'^bcl[-_\s]*', caseSensitive: false),
+ 'OpenRing-',
+ );
+
+ if (replaced == 'OpenRing-') {
+ return 'OpenRing';
+ }
+
+ return replaced;
+}
+
+/// Returns a normalized display name or `null` when the input is empty.
+String? formatWearableDisplayNameOrNull(String? rawName) {
+ final trimmed = rawName?.trim();
+ if (trimmed == null || trimmed.isEmpty) {
+ return null;
+ }
+
+ return formatWearableDisplayName(trimmed);
+}
diff --git a/open_wearable/lib/models/fota_post_update_verification.dart b/open_wearable/lib/models/fota_post_update_verification.dart
new file mode 100644
index 00000000..81524661
--- /dev/null
+++ b/open_wearable/lib/models/fota_post_update_verification.dart
@@ -0,0 +1,569 @@
+import 'dart:async';
+
+import 'package:open_earable_flutter/open_earable_flutter.dart';
+import 'package:open_wearable/models/device_name_formatter.dart';
+
+/// Metadata returned when a post-update verification check is armed.
+class ArmedFotaPostUpdateVerification {
+ final String verificationId;
+ final String wearableName;
+ final String? sideLabel;
+
+ const ArmedFotaPostUpdateVerification({
+ required this.verificationId,
+ required this.wearableName,
+ this.sideLabel,
+ });
+}
+
+/// Result of matching a newly connected wearable against pending FOTA
+/// verification expectations.
+class FotaPostUpdateVerificationResult {
+ final String verificationId;
+ final String wearableName;
+ final String? sideLabel;
+ final String? expectedFirmwareVersion;
+ final String? detectedFirmwareVersion;
+ final bool success;
+ final String message;
+
+ const FotaPostUpdateVerificationResult({
+ required this.verificationId,
+ required this.wearableName,
+ required this.sideLabel,
+ required this.expectedFirmwareVersion,
+ required this.detectedFirmwareVersion,
+ required this.success,
+ required this.message,
+ });
+}
+
+/// Coordinates post-FOTA verification matching across reconnect events.
+///
+/// Needs:
+/// - Update request metadata and connected wearable capability reads.
+///
+/// Does:
+/// - Arms verifications after update start/success.
+/// - Matches newly connected devices by id/name/side.
+/// - Compares detected firmware against expected version.
+///
+/// Provides:
+/// - Verification arming metadata for UI banners.
+/// - Verification results consumed by toasts/banners in app lifecycle logic.
+class FotaPostUpdateVerificationCoordinator {
+ FotaPostUpdateVerificationCoordinator._();
+
+ static final FotaPostUpdateVerificationCoordinator instance =
+ FotaPostUpdateVerificationCoordinator._();
+
+ static const Duration _maxPendingAge = Duration(minutes: 20);
+
+ final Map _pendingById = {};
+ int _nextVerificationId = 0;
+
+ Future armFromUpdateRequest({
+ required FirmwareUpdateRequest request,
+ Wearable? selectedWearable,
+ String? preResolvedWearableName,
+ String? preResolvedSideLabel,
+ }) async {
+ _cleanupExpired();
+
+ final rawName = selectedWearable?.name ??
+ request.peripheral?.name ??
+ preResolvedWearableName;
+ final displayName = _displayName(preResolvedWearableName ?? rawName);
+ final expectedName = _normalizeName(
+ rawName,
+ );
+ final expectedDeviceId = _normalizeId(
+ selectedWearable?.deviceId ?? request.peripheral?.identifier,
+ );
+ final expectedSideLabel = _normalizeSideLabel(
+ preResolvedSideLabel ??
+ await _resolveWearableSideLabel(selectedWearable) ??
+ _resolveSideLabelFromName(selectedWearable?.name) ??
+ _resolveSideLabelFromName(request.peripheral?.name),
+ );
+ final expectedFirmwareVersion =
+ _extractExpectedFirmwareVersion(request.firmware);
+
+ if (expectedName == null && expectedDeviceId == null) {
+ return null;
+ }
+
+ final verificationId =
+ 'fota_${DateTime.now().millisecondsSinceEpoch}_${_nextVerificationId++}';
+
+ _removeConflictingPending(
+ expectedDeviceId: expectedDeviceId,
+ expectedName: expectedName,
+ expectedSideLabel: expectedSideLabel,
+ );
+
+ _pendingById[verificationId] = _PendingPostUpdateVerification(
+ verificationId: verificationId,
+ expectedWearableName: expectedName,
+ displayWearableName: displayName,
+ expectedDeviceId: expectedDeviceId,
+ expectedSideLabel: expectedSideLabel,
+ expectedFirmwareVersion: expectedFirmwareVersion,
+ armedAt: DateTime.now(),
+ );
+
+ return ArmedFotaPostUpdateVerification(
+ verificationId: verificationId,
+ wearableName: displayName ?? 'OpenEarable',
+ sideLabel: expectedSideLabel,
+ );
+ }
+
+ Future