Conversation
DennisMoschina
commented
Feb 20, 2026
- redesigned the entire UI of the app
- improved usability
- added Settings page
…corder into separate views
…ions to all recordings page
|
Visit the preview URL for this PR (updated for commit 9b73f99): https://open-earable-web--pr211-ui-redesign-bgq4vyjd.web.app (expires Fri, 20 Mar 2026 15:30:26 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: c7397c11177c71d8d81172cea9365829823fb41c |
There was a problem hiding this comment.
Pull request overview
Redesigns the app UI to a more modern Material 3 look-and-feel, improves usability across device/app flows, and adds Settings navigation/pages.
Changes:
- Reworked core screens (apps, devices, FOTA) into card-based layouts with new pills/badges/toasts/banners.
- Added new reusable UI components (theme, toasts, prompts, status pills, wearable icons) plus improved routing.
- Updated device/sensor plumbing (shared sensor streams, caching/status resolution, auto-connect preferences).
Reviewed changes
Copilot reviewed 60 out of 94 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| open_wearable/lib/widgets/global_app_banner_overlay.dart | Switches global banner layout to stacked/dismissible vertical banners. |
| open_wearable/lib/widgets/fota/stepper_view/firmware_select.dart | Redesigns firmware selection UI into a tappable card with status chip. |
| open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart | Migrates logger screen to platform scaffold and improves loading/error layout. |
| open_wearable/lib/widgets/fota/fota_verification_banner.dart | Reworks FOTA verification banner to a styled stacked banner with deduping and deadlines. |
| open_wearable/lib/widgets/fota/firmware_update.dart | Rebuilds firmware update flow UI, adds guarded back navigation and toast warnings. |
| open_wearable/lib/widgets/devices/wearable_icon.dart | Adds reusable wearable icon widget with stereo-side resolution. |
| open_wearable/lib/widgets/devices/stereo_position_badge.dart | Adds stereo position badge widget. |
| open_wearable/lib/widgets/devices/device_status_pills.dart | Introduces reusable device metadata “pills” (battery/FW/HW/side). |
| open_wearable/lib/widgets/devices/device_detail/stereo_pos_label.dart | Replaces bespoke stereo label with new badge component. |
| open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart | Redesigns status LED controls UI and adds “disable LED” behavior. |
| open_wearable/lib/widgets/devices/device_detail/rgb_control.dart | Restyles RGB control button to outlined icon button. |
| open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart | Redesigns microphone selection UI with loading/apply states and error handling. |
| open_wearable/lib/widgets/devices/battery_state.dart | Replaces simple battery UI with cached/primed/live-updating badge component. |
| open_wearable/lib/widgets/common/no_devices_prompt.dart | Adds reusable “no devices connected” prompt widget. |
| open_wearable/lib/widgets/common/app_section_card.dart | Adds a reusable titled section card widget. |
| open_wearable/lib/widgets/app_toast.dart | Introduces toast abstraction backed by AppBannerController / SnackBar fallback. |
| open_wearable/lib/widgets/app_banner.dart | Restyles AppBanner and adds icon/foreground customization. |
| open_wearable/lib/view_models/wearables_provider.dart | Improves wearable naming, stereo grouping preferences, and time sync messaging. |
| open_wearable/lib/view_models/sensor_recorder_provider.dart | Switches recorder to shared sensor streams to avoid multiple subscriptions. |
| open_wearable/lib/view_models/sensor_data_provider.dart | Adds shared stream usage + “silent sensor” handling to keep time window moving. |
| open_wearable/lib/view_models/sensor_configuration_storage.dart | Improves config file listing robustness and adds scoped-key utilities. |
| open_wearable/lib/view_models/sensor_configuration_provider.dart | Adds pending-change tracking and richer restore result semantics. |
| open_wearable/lib/view_models/app_banner_controller.dart | Extends banner controller API with hide-by-key. |
| open_wearable/lib/theme/app_theme.dart | Adds a new Material 3 theme with consistent shapes/typography/colors. |
| open_wearable/lib/router.dart | Adds settings/recordings routes, improves home tab parsing, and unsupported FOTA redirect UX. |
| open_wearable/lib/models/wearable_status_cache.dart | Adds cache for stable wearable metadata (position/FW/HW/support). |
| open_wearable/lib/models/wearable_display_group.dart | Adds grouping/ordering logic for stereo wearables, with firmware compatibility gating. |
| open_wearable/lib/models/sensor_streams.dart | Adds broadcast stream sharing for sensors. |
| open_wearable/lib/models/device_name_formatter.dart | Adds display name normalization (e.g., BCL → OpenRing). |
| open_wearable/lib/models/bluetooth_auto_connector.dart | Reworks auto-connect flow to use preferences + avoids duplicate connections. |
| open_wearable/lib/models/auto_connect_preferences.dart | Adds SharedPreferences-backed auto-connect enablement & remembered names. |
| open_wearable/lib/models/app_shutdown_settings.dart | Adds persisted app shutdown/live graph settings via SharedPreferences. |
| open_wearable/lib/models/app_launch_session.dart | Adds session counter to track app-launch flows. |
| open_wearable/lib/models/app_background_execution_bridge.dart | Adds method-channel bridge for iOS background execution during shutdown. |
| open_wearable/lib/apps/widgets/select_earable_view.dart | Redesigns wearable selection UI, adds compatibility filtering. |
| open_wearable/lib/apps/widgets/apps_page.dart | Redesigns apps page UI and gates apps by connected compatible devices. |
| open_wearable/lib/apps/widgets/app_tile.dart | Rebuilds app tile UI with supported-device chips and disabled states. |
| open_wearable/lib/apps/widgets/app_compatibility.dart | Adds helper functions for app/device compatibility checks. |
| open_wearable/lib/apps/posture_tracker/view/settings_view.dart | Redesigns posture tracker settings UI with validation and calibration card. |
| open_wearable/lib/apps/posture_tracker/view/posture_roll_view.dart | Restyles roll visualization and plugs in new ArcPainter styling. |
| open_wearable/lib/apps/posture_tracker/view/arc_painter.dart | Refactors ArcPainter to parameterize colors/strokes and improve repaint logic. |
| open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart | Updates sensor config application to avoid marking pending options. |
| open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart | Improves rolling chart normalization/axis rendering and adds empty state. |
| open_wearable/lib/apps/heart_tracker/model/open_ring_classic_heart_processor.dart | Adds OpenRing classic HR/HRV processing pipeline and signal quality gating. |
| open_wearable/lib/apps/heart_tracker/model/msptd_fast_v2_detector.dart | Adds a Dart adaptation of MSPTD Fast v2 peak detector. |
| open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart | Hardens band-pass filter parameter handling and adds initialization behavior. |
| open_wearable/ios/Runner/AppDelegate.swift | Adds method channels for system settings and background execution window on iOS. |
| open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt | Adds method channel to open Bluetooth settings on Android. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (hasBanners) | ||
| Positioned( | ||
| top: MediaQuery.of(context).padding.top, | ||
| top: 0, |
There was a problem hiding this comment.
Two issues here: (1) key: banner.key ?? UniqueKey() creates a new UniqueKey() on every rebuild for banners without keys, which can break Dismissible state (animations/reset) and make identity unstable. Prefer requiring non-null keys for banners, generating a stable ValueKey from an ID, or storing a stable generated key alongside the banner in the controller. (2) Replacing the horizontal SingleChildScrollView with a plain Column inside a Positioned overlay can cause vertical overflow when multiple banners stack; wrap the list in a scrollable (e.g., SingleChildScrollView vertical) or constrain height (e.g., ConstrainedBox/ListView with shrinkWrap).
| child: Column( | ||
| mainAxisSize: MainAxisSize.min, | ||
| crossAxisAlignment: CrossAxisAlignment.stretch, | ||
| children: [ | ||
| const SizedBox(height: 6), | ||
| ...banners.map( | ||
| (banner) => Padding( | ||
| padding: const EdgeInsets.fromLTRB(10, 0, 10, 8), | ||
| child: Dismissible( | ||
| key: banner.key ?? UniqueKey(), | ||
| direction: DismissDirection.up, | ||
| onDismissed: (_) => controller.hideBanner(banner), | ||
| child: banner, | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ), | ||
| ], | ||
| ), |
There was a problem hiding this comment.
Two issues here: (1) key: banner.key ?? UniqueKey() creates a new UniqueKey() on every rebuild for banners without keys, which can break Dismissible state (animations/reset) and make identity unstable. Prefer requiring non-null keys for banners, generating a stable ValueKey from an ID, or storing a stable generated key alongside the banner in the controller. (2) Replacing the horizontal SingleChildScrollView with a plain Column inside a Positioned overlay can cause vertical overflow when multiple banners stack; wrap the list in a scrollable (e.g., SingleChildScrollView vertical) or constrain height (e.g., ConstrainedBox/ListView with shrinkWrap).
| final removed = activeBanners.remove(banner); | ||
| if (!removed) { | ||
| return; | ||
| } | ||
| notifyListeners(); | ||
| } | ||
|
|
||
| void hideBannerByKey(Key key) { | ||
| final before = activeBanners.length; | ||
| activeBanners.removeWhere((b) => b.key == key); | ||
| if (activeBanners.length == before) { | ||
| return; | ||
| } | ||
| notifyListeners(); | ||
| } |
There was a problem hiding this comment.
hideBanner changed semantics from 'remove by key' to 'remove by object identity'. This is a breaking behavioral change for any callers that construct a new AppBanner with the same key and call hideBanner(...) (previously it would work, now it will silently do nothing). Consider restoring key-based removal in hideBanner (or at least falling back to hideBannerByKey(banner.key!) when remove(banner) fails and banner.key != null).
| void didUpdateWidget(covariant BatteryStateView oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
| if (oldWidget.device != widget.device || | ||
| oldWidget.liveUpdates != widget.liveUpdates) { | ||
| _isDisconnected = false; | ||
| _attachDisconnectListener(); | ||
| _attachCapabilityListener(); | ||
| _resolveBatteryStreams(); | ||
| } | ||
| } |
There was a problem hiding this comment.
addDisconnectListener is being called whenever liveUpdates changes as well as when the device changes, which can register multiple disconnect listeners for the same device over the widget lifetime. Also, the listener updates state unconditionally for the current widget even if the widget has since switched to a different device. Suggestion: only attach a disconnect listener when the device instance changes (not when liveUpdates changes), and inside the callback guard against stale updates (e.g., check _deviceKey == keyAtListenerRegistration before calling setState).
| void _attachDisconnectListener() { | ||
| final keyAtListenerRegistration = _deviceKey; | ||
| widget.device.addDisconnectListener(() { | ||
| if (!mounted) { | ||
| return; | ||
| } | ||
| setState(() { | ||
| _isDisconnected = true; | ||
| _primeAttemptsByDeviceId.remove(keyAtListenerRegistration); | ||
| _batteryPercentageStream = null; | ||
| _powerStatusStream = null; | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
addDisconnectListener is being called whenever liveUpdates changes as well as when the device changes, which can register multiple disconnect listeners for the same device over the widget lifetime. Also, the listener updates state unconditionally for the current widget even if the widget has since switched to a different device. Suggestion: only attach a disconnect listener when the device instance changes (not when liveUpdates changes), and inside the callback guard against stale updates (e.g., check _deviceKey == keyAtListenerRegistration before calling setState).
| Future<String> _wearableNameWithSide(Wearable wearable) async { | ||
| final displayName = formatWearableDisplayName(wearable.name); | ||
|
|
||
| if (!wearable.hasCapability<StereoDevice>()) { | ||
| return displayName; | ||
| } | ||
|
|
||
| try { | ||
| final position = | ||
| await wearable.requireCapability<StereoDevice>().position; | ||
| return switch (position) { | ||
| DevicePosition.left => '$displayName (Left)', | ||
| DevicePosition.right => '$displayName (Right)', | ||
| _ => displayName, | ||
| }; | ||
| } catch (_) { | ||
| return displayName; | ||
| } | ||
| } |
There was a problem hiding this comment.
_wearableNameWithSide awaits StereoDevice.position without any timeout. If that future stalls (e.g., capability present but not responsive), it can block _syncTimeAndEmit and delay/skip time synchronization and its event emission. Consider adding a bounded timeout (and falling back to displayName) so time sync isn't gated on side resolution.
| final matchingValue = config.values | ||
| .where((value) => value.key == selectedKey) | ||
| .cast<SensorConfigurationValue?>() | ||
| .firstOrNull; |
There was a problem hiding this comment.
firstOrNull is not a dart:core API in many Dart/Flutter toolchains and typically requires an extension import (commonly package:collection/collection.dart). As written, this is likely a compile error. Either add the required import (if the repo already depends on it) or avoid firstOrNull by using firstWhere(..., orElse: () => null) with a nullable cast.
| .firstOrNull; | |
| .firstWhere( | |
| (_) => true, | |
| orElse: () => null, | |
| ); |
| final selectedWearable = compatibleWearables | ||
| .where((wearable) => wearable.deviceId == selectedId) | ||
| .firstOrNull; | ||
|
|
||
| if (selectedWearable == null) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Same issue as elsewhere: firstOrNull likely requires an extension import (e.g., package:collection/collection.dart) and may not compile without it. Add the appropriate import or rewrite to a firstWhere(..., orElse: () => null) pattern.
| } | ||
| } | ||
|
|
||
| void _stopScanning() { |
There was a problem hiding this comment.
_stopScanning() only cancels the local subscription but does not stop the underlying scan in WearableManager. This can leave scanning running (battery drain) and can also cause startScan() to fail later if a scan is already active. Prefer invoking the appropriate WearableManager API to stop scanning (or restoring the previous stop mechanism) in addition to cancelling the stream subscription.
| void _stopScanning() { | |
| void _stopScanning() { | |
| // Ensure the underlying scan in WearableManager is also stopped. | |
| // This prevents background scanning (battery drain) and allows future | |
| // calls to startScan() to succeed if they require a fresh scan. | |
| wearableManager.stopScan(); |
…lated views into a single file
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 65 out of 100 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>