diff --git a/.github/workflows/pr_build_all_platforms.yml b/.github/workflows/pr_build_all_platforms.yml index 51e0b326..22dc3d47 100644 --- a/.github/workflows/pr_build_all_platforms.yml +++ b/.github/workflows/pr_build_all_platforms.yml @@ -44,7 +44,7 @@ jobs: linux_dependencies: true - target: windows name: Windows - os: windows-latest + os: windows-2022 build_command: flutter build windows --release artifact_name: windows-runner artifact_path: open_wearable/build/windows/x64/runner/Release diff --git a/open_wearable/ios/Podfile b/open_wearable/ios/Podfile index cd8d4c9b..97e4628b 100644 --- a/open_wearable/ios/Podfile +++ b/open_wearable/ios/Podfile @@ -31,6 +31,8 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! + pod 'SwiftProtobuf', '1.37.0' + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 61474c34..b8d36121 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -28,6 +28,7 @@ DEPENDENCIES: - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/darwin`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - SwiftProtobuf (= 1.37.0) SPEC REPOS: trunk: @@ -62,6 +63,6 @@ SPEC CHECKSUMS: SwiftProtobuf: 3fafd1b2fb97e6d95ad9c8adb2215da9afec7c83 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: 0e9eed5871e122220f30e4ea32b6cb406980544a +PODFILE CHECKSUM: 90b3a4074c9b926b9a16e9353324d16f640ce381 COCOAPODS: 1.16.2 diff --git a/open_wearable/ios/Runner.xcodeproj/project.pbxproj b/open_wearable/ios/Runner.xcodeproj/project.pbxproj index 901c2c77..0c280e6e 100644 --- a/open_wearable/ios/Runner.xcodeproj/project.pbxproj +++ b/open_wearable/ios/Runner.xcodeproj/project.pbxproj @@ -12,11 +12,11 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; F46DF65FA99CF361E7CEAA40 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF0861E71FF5A330973499A0 /* Pods_Runner.framework */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -54,6 +54,7 @@ 52F9CE79C4C8FF8BCB3E77E2 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 8E7C81D1F19D5FDC2058E571 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -66,7 +67,6 @@ A9582C5AF71A83F5C1675FE0 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; DA11AB685288DE423745C68D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; EF0861E71FF5A330973499A0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -191,9 +191,6 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -205,13 +202,16 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 4BE2C1EC56EB7B056F761ABF /* [CP] Embed Pods Frameworks */, - EA0E9000CF1E32719EBA3126 /* [CP] Copy Pods Resources */, + 6439B04563FC41BBC327886B /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -220,9 +220,6 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -248,6 +245,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -357,37 +357,37 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { + 6439B04563FC41BBC327886B /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); - inputPaths = ( + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "Run Script"; - outputPaths = ( + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; }; - EA0E9000CF1E32719EBA3126 /* [CP] Copy Pods Resources */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + inputPaths = ( ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + name = "Run Script"; + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; /* End PBXShellScriptBuildPhase section */ @@ -501,6 +501,7 @@ DEVELOPMENT_TEAM = 6DCQ69GP5G; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -684,6 +685,7 @@ DEVELOPMENT_TEAM = 6DCQ69GP5G; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -707,6 +709,7 @@ DEVELOPMENT_TEAM = 6DCQ69GP5G; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -753,12 +756,14 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; /* End XCLocalSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { isa = XCSwiftPackageProductDependency; diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index 1b42839c..7abab9c5 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; @@ -14,6 +16,7 @@ import 'package:open_wearable/models/auto_connect_preferences.dart'; import 'package:open_wearable/models/connector_settings.dart'; import 'package:open_wearable/models/log_file_manager.dart'; import 'package:open_wearable/models/fota_post_update_verification.dart'; +import 'package:open_wearable/models/permissions_helper.dart'; import 'package:open_wearable/models/wearable_connector.dart' hide WearableEvent; import 'package:open_wearable/router.dart'; @@ -24,8 +27,10 @@ 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:open_wearable/widgets/updates/app_upgrade_page.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:open_wearable/widgets/onboarding/permissions_onboarding_page.dart'; import 'models/bluetooth_auto_connector.dart'; import 'models/logger.dart'; @@ -40,9 +45,7 @@ void main() async { initLogger(logFileManager.logger); await AutoConnectPreferences.initialize(); await AppShutdownSettings.initialize(); - await ConnectorSettings.initialize( - wearableConnector: wearableConnector, - ); + await ConnectorSettings.initialize(wearableConnector: wearableConnector); runApp( MultiProvider( @@ -83,7 +86,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State with WidgetsBindingObserver { late final StreamSubscription _unsupportedFirmwareSub; late final StreamSubscription _wearableEventSub; - late final StreamSubscription _bleAvailabilitySub; + StreamSubscription? _bleAvailabilitySub; late final BluetoothAutoConnector _autoConnector; late final WearableConnector _wearableConnector; late final Future _prefsFuture; @@ -99,6 +102,12 @@ class _MyAppState extends State with WidgetsBindingObserver { bool _backgroundExecutionRequestedForRecording = false; bool _isBackgroundExecutionActive = false; bool _isBluetoothPoweredOn = true; + bool _permissionsOnboardingCompleted = false; + bool _startupFlowStarted = false; + AppUpgradeHighlight? _startupUpgradeHighlight; + + static const String _permissionsOnboardingShownKey = + 'permissions_onboarding_shown'; static const Duration _closeShutdownGracePeriod = Duration( seconds: 10, @@ -240,22 +249,15 @@ class _MyAppState extends State with WidgetsBindingObserver { _autoConnector = BluetoothAutoConnector( navStateGetter: () => rootNavigatorKey.currentState, - wearableManager: WearableManager(), prefsFuture: _prefsFuture, onWearableConnected: _handleWearableConnected, ); AutoConnectPreferences.autoConnectEnabledListenable.addListener( _syncAutoConnectorWithSetting, ); - _bleAvailabilitySub = UniversalBle.availabilityStream.listen( - _handleBleAvailabilityChanged, - onError: (error, stackTrace) { - logger.w( - 'Failed to observe Bluetooth availability updates: $error\n$stackTrace', - ); - }, - ); - unawaited(_syncInitialBluetoothAvailability()); + if (!kIsWeb && !Platform.isIOS && !Platform.isMacOS) { + _startBleAvailabilityMonitoring(); + } _wearableEventSub = _wearableConnector.events.listen((event) { if (event is WearableConnectEvent) { @@ -263,44 +265,202 @@ class _MyAppState extends State with WidgetsBindingObserver { } }); - _syncAutoConnectorWithSetting(); - unawaited(_presentPendingUpgradeHighlight()); + startupRouteReadyCallback = _handleStartupRouteReady; } - /// Presents the current version's upgrade highlight when required. - Future _presentPendingUpgradeHighlight() async { - final AppUpgradeHighlight? highlight = - await _appUpgradeCoordinator.loadPendingHighlight(); - if (!mounted || highlight == null) { + Future _initStartupFlow() async { + try { + final SharedPreferences prefs = await _prefsFuture; + final String? acknowledgedVersionAtStartup = prefs.getString( + AppUpgradeCoordinator.acknowledgedVersionKey, + ); + + final bool didShowOnboarding = + await _presentPermissionsOnboardingIfNeeded(); + _syncAutoConnectorWithSetting(); + if (!mounted) { + return; + } + + if (didShowOnboarding && acknowledgedVersionAtStartup == null) { + _startupUpgradeHighlight = + await _appUpgradeCoordinator.loadCurrentHighlight(); + } else { + _startupUpgradeHighlight = + await _appUpgradeCoordinator.loadPendingHighlight(); + } + await _presentPendingUpgradeHighlight(_startupUpgradeHighlight); + } catch (e, st) { + logger.w('Failed to complete startup flow: $e\n$st'); + } finally { + if (mounted) { + router.go('/'); + } + } + } + + void _handleStartupRouteReady() { + if (!mounted || _startupFlowStarted) { return; } + _startupFlowStarted = true; + unawaited(_initStartupFlow()); + } - WidgetsBinding.instance.addPostFrameCallback((_) async { - final NavigatorState? navigator = rootNavigatorKey.currentState; - if (!mounted || navigator == null) { - return; + Future _presentPermissionsOnboardingIfNeeded() async { + try { + if (kIsWeb) { + final prefs = await _prefsFuture; + await prefs.setBool(_permissionsOnboardingShownKey, true); + _permissionsOnboardingCompleted = true; + return false; + } + + final prefs = await _prefsFuture; + final shown = prefs.getBool(_permissionsOnboardingShownKey) ?? false; + final hasBlePermissions = await PermissionsHelper.hasBlePermissions(); + final requiresMicPermission = Platform.isAndroid || Platform.isWindows; + final hasMicPermission = requiresMicPermission + ? await _hasMicrophonePermissionGranted() + : true; + + if (shown) { + // If permissions are still missing (for example after deny/reset), + // present onboarding again instead of skipping directly to upgrades. + if (!hasBlePermissions || !hasMicPermission) { + final navigator = rootNavigatorKey.currentState; + if (navigator == null || !mounted) return false; + + await navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => BluetoothPermissionsPage( + onCompleted: _completePermissionsOnboarding, + onBluetoothRequestCompleted: + _handleBluetoothPermissionRequested, + ), + ), + ); + + if (!mounted) return false; + _permissionsOnboardingCompleted = true; + await _startBleAvailabilityMonitoringWhenAllowed(); + return true; + } + + _permissionsOnboardingCompleted = true; + await _startBleAvailabilityMonitoringWhenAllowed(); + return false; + } + + final navigator = rootNavigatorKey.currentState; + if (navigator == null || !mounted) return false; + + if (hasBlePermissions && requiresMicPermission) { + await navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => PermissionsWelcomePage( + nextPageBuilder: (_) => MicrophonePermissionsPage( + onCompleted: _completePermissionsOnboarding, + ), + ), + ), + ); + + if (!mounted) return false; + await prefs.setBool(_permissionsOnboardingShownKey, true); + _permissionsOnboardingCompleted = true; + await _startBleAvailabilityMonitoringWhenAllowed(); + return true; + } + + if (hasBlePermissions) { + await prefs.setBool(_permissionsOnboardingShownKey, true); + _permissionsOnboardingCompleted = true; + await _startBleAvailabilityMonitoringWhenAllowed(); + return false; } await navigator.push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => AppUpgradePage( - highlight: highlight, - onContinue: () { - rootNavigatorKey.currentState?.pop(); - }, + builder: (_) => PermissionsWelcomePage( + nextPageBuilder: (_) => BluetoothPermissionsPage( + onCompleted: _completePermissionsOnboarding, + onBluetoothRequestCompleted: _handleBluetoothPermissionRequested, + ), ), ), ); - if (!mounted) { - return; - } - await _appUpgradeCoordinator.acknowledgeVersion(highlight.version); - }); + if (!mounted) return false; + await prefs.setBool(_permissionsOnboardingShownKey, true); + _permissionsOnboardingCompleted = true; + await _startBleAvailabilityMonitoringWhenAllowed(); + return true; + } catch (e, st) { + logger.w('Failed to present permissions onboarding: $e\n$st'); + } + return false; + } + + Future _completePermissionsOnboarding(BuildContext context) async { + final prefs = await _prefsFuture; + await prefs.setBool(_permissionsOnboardingShownKey, true); + _permissionsOnboardingCompleted = true; + + if (!mounted || !context.mounted) return; + Navigator.of(context).popUntil((route) => route.isFirst); + } + + Future _handleBluetoothPermissionRequested() async { + await _startBleAvailabilityMonitoringWhenAllowed(); + _syncAutoConnectorWithSetting(); + } + + /// Presents the current version's upgrade highlight when required. + Future _presentPendingUpgradeHighlight( + AppUpgradeHighlight? highlight, + ) async { + if (!mounted || highlight == null) { + return; + } + + final NavigatorState? navigator = rootNavigatorKey.currentState; + if (!mounted || navigator == null) { + return; + } + + await navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AppUpgradePage( + highlight: highlight, + onContinue: navigator.pop, + ), + ), + ); + + if (!mounted) { + return; + } + await _appUpgradeCoordinator.acknowledgeVersion(highlight.version); } void _syncAutoConnectorWithSetting() { + if (!_permissionsOnboardingCompleted) { + _autoConnector.stop(); + return; + } + + if (!kIsWeb && + (Platform.isIOS || Platform.isMacOS) && + _bleAvailabilitySub == null) { + _autoConnector.stop(); + return; + } + if (AutoConnectPreferences.autoConnectEnabled && _isBluetoothPoweredOn) { _autoConnector.start(); return; @@ -308,6 +468,41 @@ class _MyAppState extends State with WidgetsBindingObserver { _autoConnector.stop(); } + void _startBleAvailabilityMonitoring() { + if (kIsWeb || _bleAvailabilitySub != null) { + return; + } + + _bleAvailabilitySub = UniversalBle.availabilityStream.listen( + _handleBleAvailabilityChanged, + onError: (error, stackTrace) { + logger.w( + 'Failed to observe Bluetooth availability updates: $error\n$stackTrace', + ); + }, + ); + unawaited(_syncInitialBluetoothAvailability()); + } + + Future _startBleAvailabilityMonitoringWhenAllowed() async { + if (kIsWeb) { + return; + } + + if (!await PermissionsHelper.hasBlePermissions()) { + return; + } + + _startBleAvailabilityMonitoring(); + } + + Future _hasMicrophonePermissionGranted() async { + if (!Platform.isAndroid && !Platform.isWindows) { + return true; + } + return await Permission.microphone.isGranted; + } + Future _syncInitialBluetoothAvailability() async { try { final state = await UniversalBle.getBluetoothAvailabilityState(); @@ -730,7 +925,7 @@ class _MyAppState extends State with WidgetsBindingObserver { unawaited(ConnectorSettings.dispose()); _unsupportedFirmwareSub.cancel(); _wearableEventSub.cancel(); - _bleAvailabilitySub.cancel(); + _bleAvailabilitySub?.cancel(); _wearableProvEventSub.cancel(); AutoConnectPreferences.autoConnectEnabledListenable.removeListener( _syncAutoConnectorWithSetting, @@ -742,6 +937,9 @@ class _MyAppState extends State with WidgetsBindingObserver { _setBackgroundExecutionForShutdown(false); _setBackgroundExecutionForRecording(false); _autoConnector.stop(); + if (identical(startupRouteReadyCallback, _handleStartupRouteReady)) { + startupRouteReadyCallback = null; + } super.dispose(); } diff --git a/open_wearable/lib/models/app_upgrade_coordinator.dart b/open_wearable/lib/models/app_upgrade_coordinator.dart index 754a9946..4fd83a15 100644 --- a/open_wearable/lib/models/app_upgrade_coordinator.dart +++ b/open_wearable/lib/models/app_upgrade_coordinator.dart @@ -38,6 +38,12 @@ class AppUpgradeCoordinator { final AppVersionProvider _versionProvider; + /// Returns the configured highlight for the current app version, if any. + Future loadCurrentHighlight() async { + final String currentVersion = await _versionProvider.getVersion(); + return AppUpgradeRegistry.forVersion(currentVersion); + } + /// Returns the highlight that should be displayed on this launch, if any. Future loadPendingHighlight() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); diff --git a/open_wearable/lib/models/app_upgrade_registry.dart b/open_wearable/lib/models/app_upgrade_registry.dart index a7e5fe16..d34a0ff8 100644 --- a/open_wearable/lib/models/app_upgrade_registry.dart +++ b/open_wearable/lib/models/app_upgrade_registry.dart @@ -68,7 +68,7 @@ class AppUpgradeRegistry { 'OpenWearables now includes a connector that exposes app control through a WebSocket API. ' 'The Python API builds on that connection, making scripted workflows, external tools, ' 'and repeatable automation possible.', - accentColor: Color(0xFF2F7D6D), + accentColor: Color(0xFF8F6A67), useHeroGradient: false, features: [ AppUpgradeFeatureHighlight( diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart index e312416f..41de28c8 100644 --- a/open_wearable/lib/models/bluetooth_auto_connector.dart +++ b/open_wearable/lib/models/bluetooth_auto_connector.dart @@ -4,10 +4,12 @@ import 'package:flutter/foundation.dart'; 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:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'auto_connect_preferences.dart'; import 'logger.dart'; +import 'permissions_helper.dart'; /// Background reconnect orchestrator for remembered Bluetooth wearables. /// @@ -28,7 +30,7 @@ class BluetoothAutoConnector { static const Duration _iosScanRestartDelay = Duration(seconds: 1); final NavigatorState? Function() navStateGetter; - final WearableManager wearableManager; + WearableManager? _wearableManager; final Future prefsFuture; final void Function(Wearable wearable) onWearableConnected; @@ -55,10 +57,12 @@ class BluetoothAutoConnector { BluetoothAutoConnector({ required this.navStateGetter, - required this.wearableManager, + WearableManager? wearableManager, required this.prefsFuture, required this.onWearableConnected, - }); + }) : _wearableManager = wearableManager; + + WearableManager get wearableManager => _wearableManager ??= WearableManager(); void start() async { if (kIsWeb) { @@ -305,25 +309,22 @@ class BluetoothAutoConnector { } _isAttemptingConnection = true; - - if (!kIsWeb && Platform.isAndroid) { - final hasPerm = await wearableManager.hasPermissions(); - if (activeToken != _sessionToken) { - _isAttemptingConnection = false; - return; - } - if (!hasPerm) { - logger.w( - 'Bluetooth permissions not granted. Showing permissions dialog.', - ); - if (!_askedPermissionsThisSession) { - _askedPermissionsThisSession = true; - _showPermissionsDialog(); - } - logger.w('Skipping auto-connect: no permissions granted yet.'); - _isAttemptingConnection = false; - return; + final hasPerm = await PermissionsHelper.hasBlePermissions(); + if (activeToken != _sessionToken) { + _isAttemptingConnection = false; + return; + } + if (!hasPerm) { + logger.w( + 'Bluetooth permissions not granted. Showing permissions dialog.', + ); + if (!_askedPermissionsThisSession) { + _askedPermissionsThisSession = true; + _showPermissionsDialog(); } + logger.w('Skipping auto-connect: no permissions granted yet.'); + _isAttemptingConnection = false; + return; } try { @@ -338,7 +339,9 @@ class BluetoothAutoConnector { _isStartingScan = true; try { await _applyIosScanCooldownIfNeeded(); - await wearableManager.startScan(); + await wearableManager.startScan( + checkAndRequestPermissions: false, + ); } finally { _isStartingScan = false; } @@ -419,7 +422,9 @@ class BluetoothAutoConnector { _isStartingScan = true; try { await _applyIosScanCooldownIfNeeded(); - await wearableManager.startScan(); + await wearableManager.startScan( + checkAndRequestPermissions: false, + ); } finally { _isStartingScan = false; } @@ -456,7 +461,14 @@ class BluetoothAutoConnector { actions: [ PlatformDialogAction( onPressed: nav.pop, - child: PlatformText("OK"), + child: PlatformText("Cancel"), + ), + PlatformDialogAction( + onPressed: () async { + nav.pop(); + await openAppSettings(); + }, + child: PlatformText("Open Settings"), ), ], ), diff --git a/open_wearable/lib/models/connect_devices_scan_session.dart b/open_wearable/lib/models/connect_devices_scan_session.dart index 7e0b09e1..b620e15a 100644 --- a/open_wearable/lib/models/connect_devices_scan_session.dart +++ b/open_wearable/lib/models/connect_devices_scan_session.dart @@ -101,7 +101,7 @@ class ConnectDevicesScanSession { if (scanToken != _scanToken) { return; } - await _wearableManager.startScan(); + await _wearableManager.startScan(checkAndRequestPermissions: false); } catch (error, stackTrace) { logger.w('Failed to start scan: $error\n$stackTrace'); await stopScanning(); diff --git a/open_wearable/lib/models/connectors/websocket_ipc_server.dart b/open_wearable/lib/models/connectors/websocket_ipc_server.dart index 15358c5a..9c04b9e6 100644 --- a/open_wearable/lib/models/connectors/websocket_ipc_server.dart +++ b/open_wearable/lib/models/connectors/websocket_ipc_server.dart @@ -13,6 +13,7 @@ import 'package:open_wearable/models/connectors/audio_playback_config.dart'; import 'package:open_wearable/models/connectors/websocket_audio_playback_service.dart'; import 'package:open_wearable/models/logger.dart'; import 'package:open_wearable/models/network/device_ip_address.dart'; +import 'package:open_wearable/models/permissions_helper.dart'; import 'package:open_wearable/models/wearable_connector.dart'; /// Websocket-based IPC server that exposes wearable operations to external clients. @@ -20,8 +21,8 @@ class WebSocketIpcServer implements CommandRuntime { static const int defaultPort = 8765; static const String defaultPath = '/ws'; - final WearableManager _wearableManager; - final WearableConnector _wearableConnector; + WearableManager? _wearableManager; + WearableConnector? _wearableConnector; final WebsocketAudioPlaybackService _audioPlaybackService; HttpServer? _httpServer; @@ -49,8 +50,8 @@ class WebSocketIpcServer implements CommandRuntime { WearableManager? wearableManager, WearableConnector? wearableConnector, WebsocketAudioPlaybackService? audioPlaybackService, - }) : _wearableManager = wearableManager ?? WearableManager(), - _wearableConnector = wearableConnector ?? WearableConnector(), + }) : _wearableManager = wearableManager, + _wearableConnector = wearableConnector, _audioPlaybackService = audioPlaybackService ?? WebsocketAudioPlaybackService() { for (final command in createDefaultIpcCommands(this)) { @@ -61,6 +62,11 @@ class WebSocketIpcServer implements CommandRuntime { } } + WearableManager get wearableManager => _wearableManager ??= WearableManager(); + + WearableConnector get wearableConnector => + _wearableConnector ??= WearableConnector(); + /// Returns whether the websocket server is currently bound and accepting requests. bool get isRunning => _httpServer != null; @@ -230,13 +236,13 @@ class WebSocketIpcServer implements CommandRuntime { @override /// Returns whether the underlying wearable runtime already has required permissions. - Future hasPermissions() => _wearableManager.hasPermissions(); + Future hasPermissions() => PermissionsHelper.hasBlePermissions(); @override /// Checks for and requests missing runtime permissions from the platform. Future checkAndRequestPermissions() => - WearableManager.checkAndRequestPermissions(); + PermissionsHelper.requestBlePermissions(); /// Starts device scanning through the wearable manager. @override @@ -244,9 +250,13 @@ class WebSocketIpcServer implements CommandRuntime { bool checkAndRequestPermissions = true, }) async { _discoveredDevicesById.clear(); - await _wearableManager.startScan( - checkAndRequestPermissions: checkAndRequestPermissions, - ); + final hasPermissions = checkAndRequestPermissions + ? await PermissionsHelper.requestBlePermissions() + : await PermissionsHelper.hasBlePermissions(); + if (!hasPermissions) { + return {'started': false}; + } + await wearableManager.startScan(checkAndRequestPermissions: false); return {'started': true}; } @@ -276,7 +286,7 @@ class WebSocketIpcServer implements CommandRuntime { ? {const ConnectedViaSystem()} : const {}; - final wearable = await _wearableConnector.connect( + final wearable = await wearableConnector.connect( discovered, options: options, ); @@ -289,7 +299,7 @@ class WebSocketIpcServer implements CommandRuntime { Future>> connectSystemDevices({ List ignoredDeviceIds = const [], }) async { - final wearables = await _wearableConnector.connectToSystemDevices( + final wearables = await wearableConnector.connectToSystemDevices( ignoredDeviceIds: ignoredDeviceIds, ); for (final wearable in wearables) { @@ -433,7 +443,7 @@ class WebSocketIpcServer implements CommandRuntime { /// Hooks wearable manager streams into websocket broadcast events. void _attachManagerSubscriptions() { - _scanSubscription ??= _wearableManager.scanStream.listen((device) { + _scanSubscription ??= wearableManager.scanStream.listen((device) { _discoveredDevicesById[device.id] = device; _scanEventsController.add(device); _broadcastEvent( @@ -445,7 +455,7 @@ class WebSocketIpcServer implements CommandRuntime { }); _connectingSubscription ??= - _wearableManager.connectingStream.listen((device) { + wearableManager.connectingStream.listen((device) { _broadcastEvent( { 'event': 'connecting', @@ -454,7 +464,7 @@ class WebSocketIpcServer implements CommandRuntime { ); }); - _connectSubscription ??= _wearableManager.connectStream.listen((wearable) { + _connectSubscription ??= wearableManager.connectStream.listen((wearable) { _registerConnectedWearable(wearable); _broadcastEvent( { diff --git a/open_wearable/lib/models/permissions_helper.dart b/open_wearable/lib/models/permissions_helper.dart new file mode 100644 index 00000000..cb1a8276 --- /dev/null +++ b/open_wearable/lib/models/permissions_helper.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:universal_ble/universal_ble.dart'; + +/// Helper for checking app-wide permissions including BLE and microphone. +class PermissionsHelper { + static const bool _requestAndroidBleLocationPermission = true; + + /// Checks if all required permissions are granted. + static Future hasAllPermissions() async { + if (kIsWeb) { + return true; // Permissions are not required on web + } + + final hasBle = await hasBlePermissions(); + if (!hasBle) { + return false; + } + + // Microphone permission is currently requested on Android and Windows. + if (Platform.isAndroid || Platform.isWindows) { + return await Permission.microphone.isGranted; + } + + return true; + } + + /// Checks if BLE permissions are granted (without microphone). + static Future hasBlePermissions() async { + if (kIsWeb) { + return true; + } + + if (Platform.isAndroid) { + return await UniversalBle.hasPermissions( + withAndroidFineLocation: _requestAndroidBleLocationPermission, + ); + } + + if (Platform.isIOS || Platform.isMacOS) { + return await UniversalBle.hasPermissions(); + } + + return true; + } + + /// Requests BLE permissions using the same platform path as the app checks. + static Future requestBlePermissions() async { + if (kIsWeb) { + return true; + } + + try { + if (Platform.isAndroid) { + await UniversalBle.requestPermissions( + withAndroidFineLocation: _requestAndroidBleLocationPermission, + ); + } else if (Platform.isIOS || Platform.isMacOS) { + await UniversalBle.requestPermissions(); + } else { + return true; + } + } catch (_) { + // Fall through to the final status check so callers get a consistent bool. + } + + return await hasBlePermissions(); + } + + /// Checks if microphone permission is granted. + static Future hasMicrophonePermission() async { + if (kIsWeb || (!Platform.isAndroid && !Platform.isWindows)) { + return true; + } + + return await Permission.microphone.isGranted; + } +} diff --git a/open_wearable/lib/models/wearable_connector.dart b/open_wearable/lib/models/wearable_connector.dart index 1907b240..e28e4871 100644 --- a/open_wearable/lib/models/wearable_connector.dart +++ b/open_wearable/lib/models/wearable_connector.dart @@ -51,13 +51,15 @@ final class WearableStereoPairedEvent extends WearableEvent { class WearableConnector { // final Map _connectedDevices = {}; - final WearableManager _wm; + WearableManager? _wearableManager; final Set _trackedWearableIds = {}; final _events = StreamController.broadcast(); Stream get events => _events.stream; - WearableConnector([WearableManager? wm]) : _wm = wm ?? WearableManager(); + WearableConnector([WearableManager? wm]) : _wearableManager = wm; + + WearableManager get _wm => _wearableManager ??= WearableManager(); Future connect( DiscoveredDevice device, { diff --git a/open_wearable/lib/router.dart b/open_wearable/lib/router.dart index f277585d..5695ec33 100644 --- a/open_wearable/lib/router.dart +++ b/open_wearable/lib/router.dart @@ -12,6 +12,7 @@ import 'package:open_wearable/widgets/logging/log_files_screen.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart'; import 'package:open_wearable/widgets/settings/connectors_page.dart'; import 'package:open_wearable/widgets/settings/general_settings_page.dart'; +import 'package:open_wearable/widgets/startup/startup_loading_page.dart'; import 'package:open_wearable/widgets/updates/app_upgrade_history_page.dart'; import 'dart:io' show Platform; import 'package:flutter/cupertino.dart'; @@ -21,6 +22,7 @@ import 'package:open_wearable/widgets/updates/app_upgrade_page.dart'; /// Global navigator key for go_router final GlobalKey rootNavigatorKey = GlobalKey(); +VoidCallback? startupRouteReadyCallback; bool _unsupportedFotaDialogVisible = false; void _showUnsupportedFotaDialog() { @@ -89,8 +91,15 @@ int _parseHomeSectionIndex(String? tabParam) { /// Router configuration for the app final GoRouter router = GoRouter( navigatorKey: rootNavigatorKey, - initialLocation: '/', + initialLocation: '/startup', routes: [ + GoRoute( + path: '/startup', + name: 'startup', + builder: (context, state) => StartupLoadingPage( + onReady: startupRouteReadyCallback, + ), + ), GoRoute( path: '/', name: 'home', diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 2bf53acd..7608a9f0 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -1,10 +1,11 @@ import 'dart:async'; -import 'dart:typed_data'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart'; 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:open_wearable/models/permissions_helper.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:universal_ble/universal_ble.dart'; import 'package:open_wearable/models/connect_devices_scan_session.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; @@ -31,7 +32,8 @@ class ConnectDevicesPage extends StatefulWidget { } class _ConnectDevicesPageState extends State { - final WearableManager _wearableManager = WearableManager(); + bool _hasBlePermissions = false; + bool _hasMicPermission = true; final Map _connectingDevices = {}; late ConnectDevicesScanSnapshot _scanSnapshot; @@ -52,12 +54,70 @@ class _ConnectDevicesPageState extends State { }; ConnectDevicesScanSession.notifier.addListener(_scanSnapshotListener); - if (!_scanSnapshot.isScanning) { - unawaited(ConnectDevicesScanSession.startScanning(clearPrevious: true)); - } + unawaited(_checkPermissions()); unawaited(_addThisDeviceToDiscovered()); } + Future _checkPermissions() async { + try { + final hasBle = await _hasBlePermissionsGranted(); + final micGranted = defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.windows + ? await Permission.microphone.isGranted + : true; + if (!mounted) return hasBle; + setState(() { + _hasBlePermissions = hasBle; + _hasMicPermission = micGranted; + }); + return hasBle; + } catch (_) { + // conservative default on error + if (!mounted) return true; + setState(() { + _hasBlePermissions = true; + _hasMicPermission = true; + }); + return true; + } + } + + Future _hasBlePermissionsGranted() async { + return await PermissionsHelper.hasBlePermissions(); + } + + Future _requestBlePermissions() async { + await PermissionsHelper.requestBlePermissions(); + final hasBlePermissions = await _checkPermissions(); + if (hasBlePermissions) { + await _startScanningIfAllowed(clearPrevious: true); + } + } + + Future _requestMicPermission() async { + try { + await Permission.microphone.request(); + } catch (_) {} + await _checkPermissions(); + } + + Future _startScanningIfAllowed({bool clearPrevious = false}) async { + if (_scanSnapshot.isScanning) { + return; + } + + if (defaultTargetPlatform == TargetPlatform.iOS && !_hasBlePermissions) { + return; + } + + final hasBlePermissions = await _checkPermissions(); + if (!hasBlePermissions) { + return; + } + + await ConnectDevicesScanSession.startScanning(clearPrevious: clearPrevious); + } + @override Widget build(BuildContext context) { final wearablesProvider = context.watch(); @@ -66,11 +126,7 @@ class _ConnectDevicesPageState extends State { connectedWearables.map((wearable) => wearable.deviceId).toSet(); final connectedGroups = orderWearableGroupsForOverview( connectedWearables - .map( - (wearable) => WearableDisplayGroup.single( - wearable: wearable, - ), - ) + .map((wearable) => WearableDisplayGroup.single(wearable: wearable)) .toList(), ); @@ -100,16 +156,14 @@ class _ConnectDevicesPageState extends State { : const Icon(Icons.bluetooth_searching), onPressed: _scanSnapshot.isScanning ? null - : () => ConnectDevicesScanSession.startScanning( - clearPrevious: true, - ), + : () => _startScanningIfAllowed(clearPrevious: true), ), ], ), body: RefreshIndicator( onRefresh: () async { if (!_scanSnapshot.isScanning) { - await ConnectDevicesScanSession.startScanning(clearPrevious: true); + await _startScanningIfAllowed(clearPrevious: true); } }, child: ListView( @@ -121,6 +175,44 @@ class _ConnectDevicesPageState extends State { 16 + MediaQuery.paddingOf(context).bottom, ), children: [ + if (!_hasBlePermissions) + Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Icon( + Icons.bluetooth, + color: Theme.of(context).colorScheme.primary, + ), + title: const Text('Enable Bluetooth & Location'), + subtitle: const Text( + 'Allow Bluetooth and Location so the app can find and connect to your wearable.', + ), + trailing: PlatformElevatedButton( + onPressed: _requestBlePermissions, + child: const Text('Enable'), + ), + ), + ), + if (!_hasMicPermission && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.windows)) + Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Icon( + Icons.mic, + color: Theme.of(context).colorScheme.primary, + ), + title: const Text('Enable Microphone'), + subtitle: const Text( + 'OpenWearable can record audio from the device microphone for synchronized audio data. Grant microphone access to enable this.', + ), + trailing: PlatformElevatedButton( + onPressed: _requestMicPermission, + child: const Text('Enable'), + ), + ), + ), _buildScanStatusCard( context, connectedCount: connectedWearables.length, @@ -169,41 +261,33 @@ class _ConnectDevicesPageState extends State { : 'Press scan again or pull to refresh.', ) else - ...availableDevices.map( - (device) { - final isThisDevice = device.id == _thisDeviceEntry?.id; - final connect = isThisDevice - ? () => _connectToThisDevice(context) - : () => _connectToDevice(device, context); - - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: PlatformListTile( - leading: Icon( - isThisDevice ? Icons.smartphone : Icons.bluetooth, - ), - title: PlatformText( - _deviceName(device, isThisDevice: isThisDevice), - ), - subtitle: PlatformText(device.id), - trailing: _buildTrailingWidget( - device, - onConnect: connect, - ), - onTap: _connectingDevices[device.id] == true - ? null - : connect, + ...availableDevices.map((device) { + final isThisDevice = device.id == _thisDeviceEntry?.id; + final connect = isThisDevice + ? () => _connectToThisDevice(context) + : () => _connectToDevice(device, context); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: PlatformListTile( + leading: Icon( + isThisDevice ? Icons.smartphone : Icons.bluetooth, ), - ); - }, - ), + title: PlatformText( + _deviceName(device, isThisDevice: isThisDevice), + ), + subtitle: PlatformText(device.id), + trailing: _buildTrailingWidget(device, onConnect: connect), + onTap: + _connectingDevices[device.id] == true ? null : connect, + ), + ); + }), const SizedBox(height: 10), PlatformElevatedButton( onPressed: _scanSnapshot.isScanning ? null - : () => ConnectDevicesScanSession.startScanning( - clearPrevious: true, - ), + : () => _startScanningIfAllowed(clearPrevious: true), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -264,10 +348,7 @@ class _ConnectDevicesPageState extends State { ], ), const SizedBox(height: 4), - Text( - helperText, - style: Theme.of(context).textTheme.bodySmall, - ), + Text(helperText, style: Theme.of(context).textTheme.bodySmall), const SizedBox(height: 10), Wrap( spacing: 8, @@ -294,9 +375,9 @@ class _ConnectDevicesPageState extends State { children: [ Text( title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(width: 8), _StatusPill(label: '$count'), @@ -447,7 +528,7 @@ class _ConnectDevicesPageState extends State { return; } - final message = _wearableManager.deviceErrorMessage(e, device.name); + final message = WearableManager().deviceErrorMessage(e, device.name); logger.e( 'Failed to connect to device: ${device.name}, error: $message\n$stackTrace', ); @@ -477,7 +558,7 @@ class _ConnectDevicesPageState extends State { bool _isAlreadyConnectedError(Object error, DiscoveredDevice device) { try { - final message = _wearableManager.deviceErrorMessage(error, device.name); + final message = WearableManager().deviceErrorMessage(error, device.name); return message.toLowerCase().contains('already connected'); } catch (_) { return error.toString().toLowerCase().contains('already connected'); @@ -518,9 +599,9 @@ class _ConnectDevicesPageState extends State { try { await UniversalBle.connect(device.id); - await UniversalBle.connectionStream(device.id) - .firstWhere((isConnected) => isConnected) - .timeout(Duration(seconds: 2)); + await UniversalBle.connectionStream( + device.id, + ).firstWhere((isConnected) => isConnected).timeout(Duration(seconds: 2)); } catch (error, stackTrace) { logger.d( 'Low-level connect probe for ${device.id} did not complete during stale recovery: $error\n$stackTrace', @@ -564,25 +645,23 @@ class _ConnectDevicesPageState extends State { class _StatusPill extends StatelessWidget { final String label; - const _StatusPill({ - required this.label, - }); + const _StatusPill({required this.label}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer.withValues( - alpha: 0.65, - ), + color: Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999), ), child: Text( label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), ), ); } diff --git a/open_wearable/lib/widgets/onboarding/permissions_onboarding_page.dart b/open_wearable/lib/widgets/onboarding/permissions_onboarding_page.dart new file mode 100644 index 00000000..e30f8961 --- /dev/null +++ b/open_wearable/lib/widgets/onboarding/permissions_onboarding_page.dart @@ -0,0 +1,556 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_wearable/models/permissions_helper.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PermissionsWelcomePage extends StatelessWidget { + const PermissionsWelcomePage({ + super.key, + required this.nextPageBuilder, + }); + + static const String _appIconAsset = + 'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png'; + + final WidgetBuilder nextPageBuilder; + + void _continue(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute(builder: nextPageBuilder), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return PlatformScaffold( + appBar: PlatformAppBar( + automaticallyImplyLeading: false, + title: const Text('Welcome'), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Spacer(), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.asset( + _appIconAsset, + width: 88, + height: 88, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 20), + Text( + 'Welcome to OpenWearable', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'OpenWearable supports research and development of the next generation wearable applications by helping you connect devices, inspect live sensor data, and record synchronized sessions.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 18), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: _SupportEmailText( + style: theme.textTheme.bodyMedium, + linkStyle: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + const SizedBox(height: 32), + PlatformElevatedButton( + onPressed: () => _continue(context), + child: const Text('Get Started'), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +class _SupportEmailText extends StatefulWidget { + const _SupportEmailText({ + required this.style, + required this.linkStyle, + }); + + final TextStyle? style; + final TextStyle? linkStyle; + + @override + State<_SupportEmailText> createState() => _SupportEmailTextState(); +} + +class _SupportEmailTextState extends State<_SupportEmailText> { + static final Uri _supportEmailUri = Uri( + scheme: 'mailto', + path: 'info@openwearables.com', + ); + + late final TapGestureRecognizer _emailRecognizer; + + @override + void initState() { + super.initState(); + _emailRecognizer = TapGestureRecognizer()..onTap = _openSupportEmail; + } + + @override + void dispose() { + _emailRecognizer.dispose(); + super.dispose(); + } + + Future _openSupportEmail() async { + await launchUrl( + _supportEmailUri, + mode: LaunchMode.externalApplication, + ); + } + + @override + Widget build(BuildContext context) { + return Text.rich( + TextSpan( + style: widget.style, + children: [ + const TextSpan( + text: + 'Thank you for trusting OpenWearable. If you have any questions or run into problems, reach out to ', + ), + TextSpan( + text: 'info@openwearables.com', + style: widget.linkStyle, + recognizer: _emailRecognizer, + ), + const TextSpan(text: '.'), + ], + ), + textAlign: TextAlign.center, + ); + } +} + +class BluetoothPermissionsPage extends StatefulWidget { + const BluetoothPermissionsPage({ + super.key, + this.onCompleted, + this.onBluetoothRequestCompleted, + }); + + final Future Function(BuildContext context)? onCompleted; + final Future Function()? onBluetoothRequestCompleted; + + @override + State createState() => + _BluetoothPermissionsPageState(); +} + +class _BluetoothPermissionsPageState extends State + with WidgetsBindingObserver { + static const Duration _postGrantTransitionDelay = Duration( + milliseconds: 180, + ); + + bool _requestInProgress = false; + bool _granted = false; + bool _advanced = false; + bool _awaitingMacDialogCompletion = false; + bool _sawInactiveDuringMacRequest = false; + bool _macPermissionPolling = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _refresh(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (!_awaitingMacDialogCompletion || + defaultTargetPlatform != TargetPlatform.macOS) { + return; + } + + if (state == AppLifecycleState.inactive || + state == AppLifecycleState.paused || + state == AppLifecycleState.hidden) { + _sawInactiveDuringMacRequest = true; + return; + } + + if (state == AppLifecycleState.resumed && _sawInactiveDuringMacRequest) { + _awaitingMacDialogCompletion = false; + _sawInactiveDuringMacRequest = false; + unawaited(_waitForMacPermissionGrantAndAdvance()); + } + } + + Future _refresh() async { + final has = await _hasBlePermissions(); + if (!mounted) return; + setState(() => _granted = has); + if (_granted) { + if (defaultTargetPlatform == TargetPlatform.macOS) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_advanceAfterBluetoothPermissionWithDelay()); + }); + } + } + + Future _hasBlePermissions() async { + return await PermissionsHelper.hasBlePermissions(); + } + + void _advanceAfterBluetoothPermission() { + if (_advanced) { + return; + } + _advanced = true; + + final requiresMicrophoneOnboarding = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.windows; + if (!requiresMicrophoneOnboarding) { + unawaited(_completeOnboarding()); + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + MicrophonePermissionsPage(onCompleted: widget.onCompleted), + ), + ); + } + + Future _advanceAfterBluetoothPermissionWithDelay() async { + await Future.delayed(_postGrantTransitionDelay); + if (!mounted || _advanced) { + return; + } + _advanceAfterBluetoothPermission(); + } + + Future _request() async { + if (_granted) { + await widget.onBluetoothRequestCompleted?.call(); + await _advanceAfterBluetoothPermissionWithDelay(); + return; + } + + if (_requestInProgress) return; + setState(() => _requestInProgress = true); + if (defaultTargetPlatform == TargetPlatform.macOS) { + _awaitingMacDialogCompletion = true; + _sawInactiveDuringMacRequest = false; + } + try { + final hasPermissions = await PermissionsHelper.requestBlePermissions(); + if (defaultTargetPlatform == TargetPlatform.macOS) { + if (_sawInactiveDuringMacRequest) { + return; + } + _awaitingMacDialogCompletion = false; + if (hasPermissions) { + await widget.onBluetoothRequestCompleted?.call(); + await _advanceAfterBluetoothPermissionWithDelay(); + return; + } + await _waitForMacPermissionGrantAndAdvance(); + return; + } + if (!mounted) return; + setState(() => _granted = hasPermissions); + if (hasPermissions) { + await widget.onBluetoothRequestCompleted?.call(); + await _advanceAfterBluetoothPermissionWithDelay(); + } + } catch (_) { + _awaitingMacDialogCompletion = false; + await _refresh(); + } finally { + if (mounted) setState(() => _requestInProgress = false); + } + } + + Future _checkMacPermissionAndAdvanceIfGranted() async { + final hasPermissions = await PermissionsHelper.hasBlePermissions(); + if (!mounted) return; + setState(() => _granted = hasPermissions); + if (!hasPermissions) { + return; + } + await widget.onBluetoothRequestCompleted?.call(); + await _advanceAfterBluetoothPermissionWithDelay(); + } + + Future _waitForMacPermissionGrantAndAdvance() async { + if (_macPermissionPolling) { + return; + } + _macPermissionPolling = true; + try { + // macOS permission propagation can lag behind the dialog dismissal. + for (var i = 0; i < 20; i++) { + await _checkMacPermissionAndAdvanceIfGranted(); + if (_advanced || !mounted) { + return; + } + await Future.delayed(const Duration(milliseconds: 250)); + } + } finally { + _macPermissionPolling = false; + } + } + + Future _completeOnboarding() async { + final completion = widget.onCompleted; + if (completion != null) { + await completion(context); + return; + } + + if (!mounted) return; + Navigator.of(context).popUntil((route) => route.isFirst); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return PlatformScaffold( + appBar: PlatformAppBar( + automaticallyImplyLeading: false, + title: const Text('Bluetooth Permission'), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Spacer(), + Icon(Icons.bluetooth, size: 72, color: theme.colorScheme.primary), + const SizedBox(height: 20), + Text( + defaultTargetPlatform == TargetPlatform.android + ? 'Enable Bluetooth & Location' + : 'Enable Bluetooth', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + defaultTargetPlatform == TargetPlatform.android + ? 'Bluetooth and Location are required to discover and connect to your wearable device. Location permission is needed by some platforms for BLE scanning.' + : 'Bluetooth is required to discover and connect to your wearable device.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const Spacer(), + const SizedBox(height: 32), + PlatformElevatedButton( + onPressed: _requestInProgress ? null : _request, + child: Text( + _requestInProgress + ? 'Requesting...' + : _granted + ? 'Continue' + : 'Enable Bluetooth', + ), + ), + const SizedBox(height: 8), + PlatformTextButton( + onPressed: _requestInProgress ? null : _completeOnboarding, + child: const Text('Skip for now'), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } +} + +/// Second onboarding screen: Microphone permission +class MicrophonePermissionsPage extends StatefulWidget { + const MicrophonePermissionsPage({super.key, this.onCompleted}); + + final Future Function(BuildContext context)? onCompleted; + + @override + State createState() => + _MicrophonePermissionsPageState(); +} + +class _MicrophonePermissionsPageState extends State { + static const Duration _postGrantTransitionDelay = Duration( + milliseconds: 180, + ); + + bool _requestInProgress = false; + bool _granted = false; + bool _completionStarted = false; + + @override + void initState() { + super.initState(); + if (defaultTargetPlatform != TargetPlatform.macOS) { + _refresh(); + } + } + + Future _refresh() async { + final mic = await Permission.microphone.status; + if (!mounted) return; + setState(() => _granted = mic.isGranted); + if (_granted) { + if (defaultTargetPlatform == TargetPlatform.macOS) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_completeOnboardingWithDelay()); + }); + } + } + + Future _completeOnboardingWithDelay() async { + await Future.delayed(_postGrantTransitionDelay); + if (!mounted || _completionStarted) { + return; + } + await _completeOnboarding(); + } + + Future _completeOnboarding() async { + if (_completionStarted) return; + _completionStarted = true; + + try { + final completion = widget.onCompleted; + if (completion != null) { + await completion(context); + return; + } + + if (!mounted) return; + Navigator.of(context).popUntil((route) => route.isFirst); + } finally { + _completionStarted = false; + } + } + + Future _request() async { + if (_requestInProgress) return; + setState(() => _requestInProgress = true); + try { + await Permission.microphone.request(); + await _refresh(); + } catch (_) { + await _refresh(); + } finally { + if (mounted) setState(() => _requestInProgress = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return PlatformScaffold( + appBar: PlatformAppBar( + automaticallyImplyLeading: false, + title: const Text('Microphone Permission'), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Spacer(), + Icon(Icons.mic, size: 72, color: theme.colorScheme.primary), + const SizedBox(height: 20), + Text( + 'Enable Microphone', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'Microphone access lets the wearable record audio alongside sensor data for synchronized captures. Audio is stored locally unless you choose to share it.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const Spacer(), + const SizedBox(height: 32), + PlatformElevatedButton( + onPressed: _requestInProgress ? null : _request, + child: Text( + _requestInProgress ? 'Requesting...' : 'Enable Microphone', + ), + ), + const SizedBox(height: 8), + PlatformTextButton( + onPressed: _requestInProgress ? null : _completeOnboarding, + child: const Text('Skip for now'), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } +} diff --git a/open_wearable/lib/widgets/settings/settings_page.dart b/open_wearable/lib/widgets/settings/settings_page.dart index ff0baecb..bb222f32 100644 --- a/open_wearable/lib/widgets/settings/settings_page.dart +++ b/open_wearable/lib/widgets/settings/settings_page.dart @@ -96,7 +96,7 @@ class _AboutPageState extends State<_AboutPage> { static final Uri _tecoUri = Uri.parse('https://teco.edu'); static final Uri _openWearablesUri = Uri.parse('https://openwearables.com'); static const String _aboutAttribution = - 'The OpenWearables App is developed and maintained by the TECO research group at the Karlsruhe Institute of Technology and OpenWearables GmbH.'; + 'The OpenWearables App is developed and maintained by the TECO research group at the Karlsruhe Institute of Technology and OpenWearables.'; @override void initState() { @@ -265,7 +265,7 @@ class _AboutPageState extends State<_AboutPage> { const SizedBox(height: 6), _AboutExternalLink( icon: Icons.language_rounded, - title: 'OpenWearables GmbH', + title: 'OpenWearables', urlText: 'openwearables.com', trailing: const _OpenWearablesFloatingBadge(), onTap: () => _openExternalUrl( diff --git a/open_wearable/lib/widgets/startup/startup_loading_page.dart b/open_wearable/lib/widgets/startup/startup_loading_page.dart new file mode 100644 index 00000000..50e33555 --- /dev/null +++ b/open_wearable/lib/widgets/startup/startup_loading_page.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class StartupLoadingPage extends StatefulWidget { + const StartupLoadingPage({super.key, this.onReady}); + + final VoidCallback? onReady; + + @override + State createState() => _StartupLoadingPageState(); +} + +class _StartupLoadingPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + widget.onReady?.call(); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + backgroundColor: theme.colorScheme.surface, + body: const SizedBox.expand(), + ); + } +} diff --git a/open_wearable/lib/widgets/updates/app_upgrade_page.dart b/open_wearable/lib/widgets/updates/app_upgrade_page.dart index 73f6ce68..bb19f249 100644 --- a/open_wearable/lib/widgets/updates/app_upgrade_page.dart +++ b/open_wearable/lib/widgets/updates/app_upgrade_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_wearable/models/app_upgrade_highlight.dart'; const double _upgradeCardSpacing = 8; +const EdgeInsets _upgradeCardContentPadding = EdgeInsets.all(20); /// Full-screen page that presents a custom "what's new" experience. class AppUpgradePage extends StatelessWidget { @@ -109,6 +110,7 @@ class _CompactUpgradeLayout extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _UpgradeHeroCard(highlight: highlight, accentColor: accentColor), const SizedBox(height: _upgradeCardSpacing), @@ -167,6 +169,7 @@ class _WideUpgradeLayout extends StatelessWidget { Expanded( flex: 12, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ GridView.count( crossAxisCount: 2, @@ -206,6 +209,7 @@ class _UpgradeHeroCard extends StatelessWidget { final ColorScheme colorScheme = theme.colorScheme; return Card( + margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, child: Container( constraints: BoxConstraints(minHeight: expanded ? 520 : 0), @@ -222,7 +226,7 @@ class _UpgradeHeroCard extends StatelessWidget { ) : null, child: Padding( - padding: const EdgeInsets.fromLTRB(22, 22, 22, 22), + padding: _upgradeCardContentPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -303,8 +307,9 @@ class _UpgradeFeatureCard extends StatelessWidget { final ColorScheme colorScheme = theme.colorScheme; return Card( + margin: EdgeInsets.zero, child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + padding: _upgradeCardContentPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -355,14 +360,17 @@ class _UpgradeFooter extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), - child: SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: onContinue, - icon: const Icon(Icons.arrow_forward_rounded), - label: const Text('Continue'), + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: _upgradeCardContentPadding, + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: onContinue, + icon: const Icon(Icons.arrow_forward_rounded), + label: const Text('Continue'), + ), ), ), ); diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 660c3da9..bdcab1ef 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -689,7 +689,7 @@ packages: source: hosted version: "2.3.0" permission_handler: - dependency: transitive + dependency: "direct main" description: name: permission_handler sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 @@ -796,50 +796,50 @@ packages: dependency: "direct main" description: name: record - sha256: "11bab1e1d9e75229588222f66a468aae1967e1ce490c80f8bd782aa165ec822b" + sha256: d28ec249ee4af6753a68a7a5077d6a2eee71e38dc61a241724aded53263274ba url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.1.0" record_android: dependency: transitive description: name: record_android - sha256: "16e28607ad92dc2e7d93328f4f4720a9f82a78b639497690022e0e135a326f8e" + sha256: "28f1108626a190e249b01ffa9f639070e31e5157474b64a5ae380bf36aec9559" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.2" record_ios: dependency: transitive description: name: record_ios - sha256: a7f456c9b2c5ba10f1ad0a7dfe19dcabcbfda0c2a09d4eef0c43e48e762a7b00 + sha256: "21d189f49a598af4697dac4cc9e48389ac0a1fb3e916622b5504a58d9b96313e" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.1" record_linux: dependency: transitive description: name: record_linux - sha256: "64c70dede6f3a8235927531067ceb8e2ef1b737071f2ff72390ab49f88644f2a" + sha256: "305526af0560866a0cc4219aa3726ef8541f819e14bd5f65b73ffdb7dc5911be" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" record_macos: dependency: transitive description: name: record_macos - sha256: b8cd9dd88aff1b7a5873bb2a2a875b376ce1a68c6f84bce3ce0448e413a84700 + sha256: ced7495abf3d683e8a7dbe8fc96df8ea5722837a26f922b7cb7c5de11661bb46 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.1" record_platform_interface: dependency: transitive description: name: record_platform_interface - sha256: fc1b2b26113b8ab2bffa39148288b0b588c7b3b356a21aeee7bea24b91b6f1a4 + sha256: d94b37cadb8fe203e64b0e9893271c0b71b34f2550ee7fb0c6105d650e00c1f5 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" record_use: dependency: transitive description: @@ -852,18 +852,18 @@ packages: dependency: transitive description: name: record_web - sha256: f3374c9ba6b6f9edcb3589be37515e420faba631b9d54421bb28562667831b05 + sha256: "9d2d43162afff63d8608eb09c2982b9c6898dd8fbf5b05395ca7d08305b6db40" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" record_windows: dependency: transitive description: name: record_windows - sha256: "603429b542a2a7120ad988218ebebe50ed5565087db68ab694ae6c2d5a6e7749" + sha256: "39ca03e7ae4df5e2512bc3c92b0ef9a7b148d9295ae8ac92df6beb4daf939d94" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.2.0" rxdart: dependency: transitive description: @@ -1041,10 +1041,10 @@ packages: dependency: "direct main" description: name: universal_ble - sha256: ff3c4ea34e1360ba593c68f68ff2d0cd598dcb054b0de1d89c0936dbb5dd9707 + sha256: da15f61251299daf9c290bb8e40ded1e5313c4636fef6c796a7a656a06c93b4a url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.4" url_launcher: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 9b723337..d3eafda8 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -36,7 +36,8 @@ dependencies: cupertino_icons: ^1.0.9 open_file: ^3.5.11 open_earable_flutter: ^2.3.10 - universal_ble: ^2.0.2 + universal_ble: ^2.0.4 + permission_handler: ^12.0.1 flutter_platform_widgets: ^10.0.1 provider: ^6.1.5+1 logger: ^2.7.0