From 6bbaea30b8fa265641c9d01dc4885818032d4717 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 15 Jun 2026 09:38:24 +0300 Subject: [PATCH] feat: Add onboarding to request bluetooth and microphone permissions --- .../android/app/src/main/AndroidManifest.xml | 2 + .../ios/Flutter/AppFrameworkInfo.plist | 2 +- open_wearable/ios/Podfile | 2 + open_wearable/ios/Podfile.lock | 143 +------ .../ios/Runner.xcodeproj/project.pbxproj | 51 ++- .../xcshareddata/swiftpm/Package.resolved | 59 +++ .../xcshareddata/xcschemes/Runner.xcscheme | 18 + .../xcshareddata/swiftpm/Package.resolved | 59 +++ open_wearable/lib/main.dart | 265 ++++++++++-- .../lib/models/app_upgrade_coordinator.dart | 6 + .../lib/models/bluetooth_auto_connector.dart | 53 ++- .../models/connect_devices_scan_session.dart | 2 +- .../connectors/websocket_ipc_server.dart | 38 +- .../lib/models/permissions_helper.dart | 80 ++++ .../lib/models/wearable_connector.dart | 6 +- open_wearable/lib/router.dart | 11 +- .../widgets/devices/connect_devices_page.dart | 210 +++++++--- .../permissions_onboarding_page.dart | 396 ++++++++++++++++++ .../widgets/startup/startup_loading_page.dart | 33 ++ .../lib/widgets/updates/app_upgrade_page.dart | 28 +- open_wearable/pubspec.lock | 98 ++--- open_wearable/pubspec.yaml | 3 +- 22 files changed, 1206 insertions(+), 359 deletions(-) create mode 100644 open_wearable/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 open_wearable/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 open_wearable/lib/models/permissions_helper.dart create mode 100644 open_wearable/lib/widgets/onboarding/permissions_onboarding_page.dart create mode 100644 open_wearable/lib/widgets/startup/startup_loading_page.dart diff --git a/open_wearable/android/app/src/main/AndroidManifest.xml b/open_wearable/android/app/src/main/AndroidManifest.xml index 8cb9f463..eb49e63d 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/ios/Flutter/AppFrameworkInfo.plist b/open_wearable/ios/Flutter/AppFrameworkInfo.plist index 4ffccfc3..020f8c12 100644 --- a/open_wearable/ios/Flutter/AppFrameworkInfo.plist +++ b/open_wearable/ios/Flutter/AppFrameworkInfo.plist @@ -11,7 +11,7 @@ CFBundleInfoDictionaryVersion 6.0 MinimumOSVersion - 13.0 + 13.1 CFBundleName App CFBundlePackageType diff --git a/open_wearable/ios/Podfile b/open_wearable/ios/Podfile index fad4db75..32f016a8 100644 --- a/open_wearable/ios/Podfile +++ b/open_wearable/ios/Podfile @@ -30,6 +30,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 1e2cdfa2..6d748029 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -1,173 +1,56 @@ PODS: - - audioplayers_darwin (0.0.1): - - Flutter - - FlutterMacOS - - device_info_plus (0.0.1): - - Flutter - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery - - Flutter - - file_selector_ios (0.0.1): - - Flutter - Flutter (1.0.0) - flutter_archive (0.0.1): - Flutter - ZIPFoundation (= 0.9.19) - - iOSMcuManagerLibrary (1.10.1): - - SwiftCBOR (= 0.4.7) + - iOSMcuManagerLibrary (1.12): + - SwiftCBOR (= 0.5.0) - ZIPFoundation (= 0.9.19) - - mcumgr_flutter (0.6.1): + - mcumgr_flutter (0.9.0): - Flutter - - iOSMcuManagerLibrary (= 1.10.1) + - FlutterMacOS + - iOSMcuManagerLibrary (= 1.12) - SwiftProtobuf - open_file_ios (1.0.3): - Flutter - - package_info_plus (0.4.5): - - Flutter - - permission_handler_apple (9.3.0): - - Flutter - - SDWebImage (5.21.7): - - SDWebImage/Core (= 5.21.7) - - SDWebImage/Core (5.21.7) - - sensors_plus (0.0.1): - - Flutter - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - SwiftCBOR (0.4.7) + - SwiftCBOR (0.5.0) - SwiftProtobuf (1.37.0) - - SwiftyGif (5.4.5) - - universal_ble (0.0.1): - - Flutter - - FlutterMacOS - - url_launcher_ios (0.0.1): - - Flutter - - wakelock_plus (0.0.1): - - Flutter - ZIPFoundation (0.9.19) DEPENDENCIES: - - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) - - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_archive (from `.symlinks/plugins/flutter_archive/ios`) - - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) + - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/darwin`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - sensors_plus (from `.symlinks/plugins/sensors_plus/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`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + - SwiftProtobuf (= 1.37.0) SPEC REPOS: trunk: - - DKImagePickerController - - DKPhotoGallery - iOSMcuManagerLibrary - - SDWebImage - SwiftCBOR - SwiftProtobuf - - SwiftyGif - ZIPFoundation EXTERNAL SOURCES: - audioplayers_darwin: - :path: ".symlinks/plugins/audioplayers_darwin/darwin" - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" - file_selector_ios: - :path: ".symlinks/plugins/file_selector_ios/ios" Flutter: :path: Flutter flutter_archive: :path: ".symlinks/plugins/flutter_archive/ios" mcumgr_flutter: - :path: ".symlinks/plugins/mcumgr_flutter/ios" + :path: ".symlinks/plugins/mcumgr_flutter/darwin" open_file_ios: :path: ".symlinks/plugins/open_file_ios/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" - sensors_plus: - :path: ".symlinks/plugins/sensors_plus/ios" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - universal_ble: - :path: ".symlinks/plugins/universal_ble/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 - device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe - iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58 - mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a + iOSMcuManagerLibrary: f72db849f6dd66b1f26cd7eea776050a22c8591c + mcumgr_flutter: b9864b2ae8334b6ba2b33f9ab4fc0a67bbe75c7a open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf - sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - SwiftCBOR: 465775bed0e8bac7bfb8160bcf7b95d7f75971e4 + SwiftCBOR: aa87349b4ccddd19f861aa544f7c27d43dee5772 SwiftProtobuf: 3fafd1b2fb97e6d95ad9c8adb2215da9afec7c83 - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - universal_ble: ff19787898040d721109c6324472e5dd4bc86adc - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: 1857a7cdb7dfafe45f2b0e9a9af44644190f7506 +PODFILE CHECKSUM: 63ecfe997c48700999998375d8161280aec0511d COCOAPODS: 1.16.2 diff --git a/open_wearable/ios/Runner.xcodeproj/project.pbxproj b/open_wearable/ios/Runner.xcodeproj/project.pbxproj index c65acc9f..71eb9445 100644 --- a/open_wearable/ios/Runner.xcodeproj/project.pbxproj +++ b/open_wearable/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 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 */; }; @@ -53,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 = ""; }; @@ -80,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, F46DF65FA99CF361E7CEAA40 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -120,6 +123,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -198,13 +202,15 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 4BE2C1EC56EB7B056F761ABF /* [CP] Embed Pods Frameworks */, - EA0E9000CF1E32719EBA3126 /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -238,6 +244,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -362,23 +371,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - EA0E9000CF1E32719EBA3126 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -472,7 +464,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -491,6 +483,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", @@ -602,7 +595,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -653,7 +646,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -674,6 +667,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", @@ -697,6 +691,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", @@ -743,6 +738,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/open_wearable/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/open_wearable/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..4d7193ee --- /dev/null +++ b/open_wearable/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/open_wearable/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/open_wearable/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d42..c3fedb29 100644 --- a/open_wearable/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/open_wearable/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + 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,197 @@ 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: (_) => 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: (_) => 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 +463,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 +920,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 +932,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/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart index 594a52c8..69c1f905 100644 --- a/open_wearable/lib/models/bluetooth_auto_connector.dart +++ b/open_wearable/lib/models/bluetooth_auto_connector.dart @@ -3,10 +3,12 @@ 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: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. /// @@ -27,7 +29,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; @@ -54,10 +56,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 { final token = ++_sessionToken; @@ -296,22 +300,22 @@ class BluetoothAutoConnector { } _isAttemptingConnection = true; - if (!Platform.isIOS) { - 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 { @@ -326,7 +330,9 @@ class BluetoothAutoConnector { _isStartingScan = true; try { await _applyIosScanCooldownIfNeeded(); - await wearableManager.startScan(); + await wearableManager.startScan( + checkAndRequestPermissions: false, + ); } finally { _isStartingScan = false; } @@ -444,7 +450,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 bb453e12..cc481989 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -1,9 +1,11 @@ import 'dart:async'; -import 'dart:typed_data'; +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'; @@ -30,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; @@ -51,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(); @@ -65,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(), ); @@ -99,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( @@ -120,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, @@ -168,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: [ @@ -263,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, @@ -293,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'), @@ -443,7 +525,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', ); @@ -473,7 +555,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'); @@ -509,9 +591,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', @@ -555,25 +637,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..0d6c256d --- /dev/null +++ b/open_wearable/lib/widgets/onboarding/permissions_onboarding_page.dart @@ -0,0 +1,396 @@ +import 'dart:async'; + +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'; + +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/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 fb9cafb7..ec77d0af 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: "direct main" description: name: audioplayers - sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 + sha256: f16640453cc47487b7de72a2b28d37c7df1ac97999849f4a46d92b1d2b0f093d url: "https://pub.dev" source: hosted - version: "6.6.0" + version: "6.7.1" audioplayers_android: dependency: transitive description: @@ -69,18 +69,18 @@ packages: dependency: transitive description: name: audioplayers_web - sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 + sha256: "24a6f258062bd7da8cb2157e83fccb9816a08dd306cbaaa24f9813d071470545" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.2.1" audioplayers_windows: dependency: transitive description: name: audioplayers_windows - sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 + sha256: "95f875a96c88c3dbbcb608d4f8288e300b0113d256a81d0b3197fcc18f0dc91a" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.3.1" bloc: dependency: transitive description: @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.2.1" collection: dependency: transitive description: @@ -189,10 +189,10 @@ packages: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.14" device_info_plus: dependency: "direct main" description: @@ -261,10 +261,10 @@ packages: dependency: transitive description: name: file_selector_android - sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" + sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b" url: "https://pub.dev" source: hosted - version: "0.5.2+6" + version: "0.5.2+8" file_selector_ios: dependency: transitive description: @@ -301,10 +301,10 @@ packages: dependency: transitive description: name: file_selector_web - sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" url: "https://pub.dev" source: hosted - version: "0.9.4+2" + version: "0.9.5" file_selector_windows: dependency: transitive description: @@ -378,10 +378,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.34" + version: "2.0.35" flutter_staggered_grid_view: dependency: "direct main" description: @@ -416,30 +416,22 @@ packages: description: flutter source: sdk version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" go_router: dependency: "direct main" description: name: go_router - sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" url: "https://pub.dev" source: hosted - version: "17.2.3" + version: "17.3.0" hooks: dependency: transitive description: name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.0.2" http: dependency: "direct main" description: @@ -576,14 +568,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.dev" - source: hosted - version: "0.17.6" nested: dependency: transitive description: @@ -596,18 +580,18 @@ packages: dependency: transitive description: name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" url: "https://pub.dev" source: hosted - version: "9.3.0" + version: "9.4.1" open_earable_flutter: dependency: "direct main" description: name: open_earable_flutter - sha256: ddacc55c036487b5be86e997e636faea81f861a5c6bddbdaaa6984f4d9d8bd40 + sha256: d7a2e491fa589ea14093101fa37b182d748240ac26fe9cafe9938371f6256b67 url: "https://pub.dev" source: hosted - version: "2.3.8" + version: "2.3.10" open_file: dependency: "direct main" description: @@ -761,13 +745,13 @@ packages: source: hosted version: "2.3.0" permission_handler: - dependency: transitive + dependency: "direct main" description: name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 url: "https://pub.dev" source: hosted - version: "12.0.1" + version: "12.0.3" permission_handler_android: dependency: transitive description: @@ -780,10 +764,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d url: "https://pub.dev" source: hosted - version: "9.4.7" + version: "9.4.9" permission_handler_html: dependency: transitive description: @@ -924,10 +908,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0 url: "https://pub.dev" source: hosted - version: "2.4.23" + version: "2.4.25" shared_preferences_foundation: dependency: transitive description: @@ -1009,10 +993,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" url: "https://pub.dev" source: hosted - version: "3.4.0+1" + version: "3.4.1" term_glyph: dependency: transitive description: @@ -1049,10 +1033,10 @@ packages: dependency: "direct main" description: name: universal_ble - sha256: "8e5f4e2827375900b805fe3eb6cb8a305ade2303289d9a5cba0d09a3a37fea29" + sha256: da15f61251299daf9c290bb8e40ded1e5313c4636fef6c796a7a656a06c93b4a url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.4" url_launcher: dependency: "direct main" description: @@ -1065,10 +1049,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 url: "https://pub.dev" source: hosted - version: "6.3.29" + version: "6.3.32" url_launcher_ios: dependency: transitive description: @@ -1145,10 +1129,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e + sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.2.5" vector_math: dependency: transitive description: @@ -1230,5 +1214,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.4 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 79bd286b..ea2a6b35 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -36,7 +36,8 @@ dependencies: cupertino_icons: ^1.0.8 open_file: ^3.3.2 open_earable_flutter: ^2.3.8 - universal_ble: ^2.0.1 + universal_ble: ^2.0.4 + permission_handler: ^12.0.1 flutter_platform_widgets: ^10.0.1 provider: ^6.1.2 logger: ^2.5.0